diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 08d1305a6c0..0f377392820 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -127,8 +127,19 @@ jobs: lib/billing/core/usage-log.postgres.test.ts lib/billing/core/organization-activity.postgres.test.ts lib/billing/core/usage-analytics-queries.postgres.test.ts + lib/billing/core/organization-usage-pagination.postgres.test.ts + lib/billing/organizations/member-limits.postgres.test.ts + lib/workspaces/organization-workspaces.postgres.test.ts lib/billing/calculations/usage-reservation.test.ts + - name: Verify access request pagination and impact in PostgreSQL + working-directory: apps/sim + env: + ACCESS_REQUESTS_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_access_requests_test + run: | + bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); await sql.unsafe("CREATE DATABASE sim_access_requests_test"); await sql.end()' + bunx vitest run ee/access-requests/lib/repository.postgres.test.ts ee/access-requests/lib/impact.postgres.test.ts + - name: Verify fork previews ignore execution file history in PostgreSQL working-directory: apps/sim env: diff --git a/apps/docs/content/docs/api-reference/meta.json b/apps/docs/content/docs/api-reference/meta.json index bd9a0920315..1d043f0872f 100644 --- a/apps/docs/content/docs/api-reference/meta.json +++ b/apps/docs/content/docs/api-reference/meta.json @@ -19,6 +19,7 @@ "(generated)/workspaces", "(generated)/organizations", "(generated)/permission-groups", + "(generated)/access-requests", "(generated)/workspace-sync", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/cli/organizations.mdx b/apps/docs/content/docs/cli/organizations.mdx index 07b9f42b668..9d19dcc8793 100644 --- a/apps/docs/content/docs/cli/organizations.mdx +++ b/apps/docs/content/docs/cli/organizations.mdx @@ -7,6 +7,223 @@ import { CommandTable } from '@/components/ui/command-table' Every command below also accepts the [global options](/cli/commands#global-options). +## Cancel organization access request + +```bash +sim organizations access-requests cancel [options] +``` + +Cancel Organization Access Request (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## Create organization access request + +```bash +sim organizations access-requests create [options] +``` + +Create Organization Access Request (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--target ` | Yes | Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable. (JSON, or @path / @- to read a file or stdin). | +| `--reason ` | No | Why the acting user needs this access. | + + + +## Discover organization access requests + +```bash +sim organizations access-requests discover [options] +``` + +Discover Organization Access Requests (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against the access item label. | +| `--target-kind ` | No | Category of access to discover. Accepted values: `feature`, `integration`, `provider`, `model`, `tool`, `knowledge_connector`, `file_share_auth`, `chat_deploy_auth`, `usage_limit`. | +| `--state ` | No | Filter by the acting user’s current access. Requestable items can be submitted for review. Accepted values: `allowed`, `requestable`, `unavailable`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `label`. | +| `--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. | + + + +## Get organization access request settings + +```bash +sim organizations access-requests settings get [options] +``` + +Get Organization Access Request Settings (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## Update organization access request settings + +```bash +sim organizations access-requests settings update [options] +``` + +Update Organization Access Request Settings (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--allow-requests ` | Yes | Allow new requests and approvals. Disabling requests preserves history and still allows cancellation and decline. Accepted values: `true`, `false`. | + + + +## List my organization access requests + +```bash +sim organizations access-requests mine [options] +``` + +List My Organization Access Requests (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--status ` | No | Filter by request status; omit to include all statuses. Accepted values: `pending`, `fulfilled`, `declined`, `cancelled`, `closed`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`, `targetLabel`. | +| `--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. | + + + +## List organization access requests + +```bash +sim organizations access-requests list [options] +``` + +List Organization Access Requests (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--status ` | No | Filter by request status; omit to include all statuses. Accepted values: `pending`, `fulfilled`, `declined`, `cancelled`, `closed`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`, `targetLabel`. | +| `--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. | +| `--search ` | No | Case-insensitive substring match against the target label or requester name or email. | + + + +## Preview organization access request + +```bash +sim organizations access-requests preview [options] +``` + +Preview Organization Access Request (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## Resolve organization access request + +```bash +sim organizations access-requests resolve [options] +``` + +Resolve Organization Access Request (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--action ` | Yes | apply: Apply the reviewed change to the governing group or member credit cap. decline: Decline the request without changing permissions or credit limits. Accepted values: `apply`, `decline`. | +| `--expected-fingerprint ` | No | Fingerprint from Preview Organization Access Request. Review its changes and impact before applying; a stale preview returns a conflict. Available when action is apply. Required when action is apply. | +| `--new-limit-credits ` | No | Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests. Available when action is apply. | +| `--reason ` | No | Required explanation for declining this request. Available when action is decline. Required when action is decline. | + + + ## Create organization invitation ```bash @@ -79,6 +296,39 @@ List Organization Invitations (OAuth login or personal API key required) +## List organization invitation workspaces + +```bash +sim organizations invitations workspaces [options] +``` + +List Organization Invitation Workspaces (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**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 `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + ## Resend organization invitation ```bash @@ -154,6 +404,63 @@ Get Organization (OAuth login or personal API key required) +## Get organization member credit limit + +```bash +sim organizations members usage-limit get [options] +``` + +Get Organization Member Credit Limit (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## Update organization member credit limit + +```bash +sim organizations members usage-limit update [options] +``` + +Update Organization Member Credit Limit (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--credit-limit ` | Yes | Credit cap for this person. Send null to clear the cap or 0 to prevent further credit-consuming usage. Organization limits still apply. | + + + ## List organization members ```bash @@ -234,6 +541,79 @@ Update Organization Member (OAuth login or personal API key required) +## Get organization usage breakdown + +```bash +sim organizations usage breakdown [options] +``` + +Get Organization Usage Breakdown (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--preset ` | No | Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days. Accepted values: `current-period`, `previous-period`, `7d`, `30d`, `custom`. | +| `--start-date ` | No | First calendar date included, in the selected timezone. Requires preset=custom. | +| `--end-date ` | No | Last calendar date included, in the selected timezone. Requires preset=custom. | +| `--timezone ` | No | IANA timezone for calendar boundaries; defaults to UTC. | +| `--dimension ` | Yes | Usage grouping dimension. Accepted values: `member`, `workspace`, `workflow`, `model`, `byok`, `source`. | +| `--limit ` | No | Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50. | + + + +## Get organization usage summary + +```bash +sim organizations usage summary [options] +``` + +Get Organization Usage Summary (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--preset ` | No | Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days. Accepted values: `current-period`, `previous-period`, `7d`, `30d`, `custom`. | +| `--start-date ` | No | First calendar date included, in the selected timezone. Requires preset=custom. | +| `--end-date ` | No | Last calendar date included, in the selected timezone. Requires preset=custom. | +| `--timezone ` | No | IANA timezone for calendar boundaries; defaults to UTC. | + + + +## List organization usage events + +```bash +sim organizations usage events [options] +``` + +List Organization Usage Events (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--preset ` | No | Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days. Accepted values: `current-period`, `previous-period`, `7d`, `30d`, `custom`. | +| `--start-date ` | No | First calendar date included, in the selected timezone. Requires preset=custom. | +| `--end-date ` | No | Last calendar date included, in the selected timezone. Requires preset=custom. | +| `--timezone ` | No | IANA timezone for calendar boundaries; defaults to UTC. | +| `--source ` | No | Restrict events to one product surface. Accepted values: `workflow`, `wand`, `sim-chat`, `mcp_copilot`, `mothership_block`, `knowledge-base`, `voice-input`, `enrichment`, `voice-output`, `api-tool`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + ## List organizations ```bash diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 7ece46c6d2d..5a0af9c37e0 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2998,6 +2998,223 @@ sim meta status ## sim organizations +### sim organizations access-requests cancel + +Cancel Organization Access Request (OAuth login or personal API key required) + +```bash +sim organizations access-requests cancel [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations access-requests create + +Create Organization Access Request (OAuth login or personal API key required) + +```bash +sim organizations access-requests create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--target ` | Yes | Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable. (JSON, or @path / @- to read a file or stdin). | +| `--reason ` | No | Why the acting user needs this access. | + + + +### sim organizations access-requests discover + +Discover Organization Access Requests (OAuth login or personal API key required) + +```bash +sim organizations access-requests discover [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--search ` | No | Case-insensitive substring match against the access item label. | +| `--target-kind ` | No | Category of access to discover. Accepted values: `feature`, `integration`, `provider`, `model`, `tool`, `knowledge_connector`, `file_share_auth`, `chat_deploy_auth`, `usage_limit`. | +| `--state ` | No | Filter by the acting user’s current access. Requestable items can be submitted for review. Accepted values: `allowed`, `requestable`, `unavailable`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `label`. | +| `--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 access-requests settings get + +Get Organization Access Request Settings (OAuth login or personal API key required) + +```bash +sim organizations access-requests settings get [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations access-requests settings update + +Update Organization Access Request Settings (OAuth login or personal API key required) + +```bash +sim organizations access-requests settings update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--allow-requests ` | Yes | Allow new requests and approvals. Disabling requests preserves history and still allows cancellation and decline. Accepted values: `true`, `false`. | + + + +### sim organizations access-requests mine + +List My Organization Access Requests (OAuth login or personal API key required) + +```bash +sim organizations access-requests mine [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--status ` | No | Filter by request status; omit to include all statuses. Accepted values: `pending`, `fulfilled`, `declined`, `cancelled`, `closed`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`, `targetLabel`. | +| `--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 access-requests list + +List Organization Access Requests (OAuth login or personal API key required) + +```bash +sim organizations access-requests list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--status ` | No | Filter by request status; omit to include all statuses. Accepted values: `pending`, `fulfilled`, `declined`, `cancelled`, `closed`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`, `targetLabel`. | +| `--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. | +| `--search ` | No | Case-insensitive substring match against the target label or requester name or email. | + + + +### sim organizations access-requests preview + +Preview Organization Access Request (OAuth login or personal API key required) + +```bash +sim organizations access-requests preview [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations access-requests resolve + +Resolve Organization Access Request (OAuth login or personal API key required) + +```bash +sim organizations access-requests resolve [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--action ` | Yes | apply: Apply the reviewed change to the governing group or member credit cap. decline: Decline the request without changing permissions or credit limits. Accepted values: `apply`, `decline`. | +| `--expected-fingerprint ` | No | Fingerprint from Preview Organization Access Request. Review its changes and impact before applying; a stale preview returns a conflict. Available when action is apply. Required when action is apply. | +| `--new-limit-credits ` | No | Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests. Available when action is apply. | +| `--reason ` | No | Required explanation for declining this request. Available when action is decline. Required when action is decline. | + + + ### sim organizations invitations create Create Organization Invitation (OAuth login or personal API key required) @@ -3070,6 +3287,39 @@ sim organizations invitations list [options] +### sim organizations invitations workspaces + +List Organization Invitation Workspaces (OAuth login or personal API key required) + +```bash +sim organizations invitations workspaces [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `invitationId` | Yes | Invitation identifier. | + + + +**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 `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) @@ -3145,6 +3395,63 @@ sim organizations get +### sim organizations members usage-limit get + +Get Organization Member Credit Limit (OAuth login or personal API key required) + +```bash +sim organizations members usage-limit get [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations members usage-limit update + +Update Organization Member Credit Limit (OAuth login or personal API key required) + +```bash +sim organizations members usage-limit update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--credit-limit ` | Yes | Credit cap for this person. Send null to clear the cap or 0 to prevent further credit-consuming usage. Organization limits still apply. | + + + ### sim organizations members list List Organization Members (OAuth login or personal API key required) @@ -3225,6 +3532,79 @@ sim organizations members update [options] +### sim organizations usage breakdown + +Get Organization Usage Breakdown (OAuth login or personal API key required) + +```bash +sim organizations usage breakdown [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--preset ` | No | Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days. Accepted values: `current-period`, `previous-period`, `7d`, `30d`, `custom`. | +| `--start-date ` | No | First calendar date included, in the selected timezone. Requires preset=custom. | +| `--end-date ` | No | Last calendar date included, in the selected timezone. Requires preset=custom. | +| `--timezone ` | No | IANA timezone for calendar boundaries; defaults to UTC. | +| `--dimension ` | Yes | Usage grouping dimension. Accepted values: `member`, `workspace`, `workflow`, `model`, `byok`, `source`. | +| `--limit ` | No | Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50. | + + + +### sim organizations usage summary + +Get Organization Usage Summary (OAuth login or personal API key required) + +```bash +sim organizations usage summary [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--preset ` | No | Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days. Accepted values: `current-period`, `previous-period`, `7d`, `30d`, `custom`. | +| `--start-date ` | No | First calendar date included, in the selected timezone. Requires preset=custom. | +| `--end-date ` | No | Last calendar date included, in the selected timezone. Requires preset=custom. | +| `--timezone ` | No | IANA timezone for calendar boundaries; defaults to UTC. | + + + +### sim organizations usage events + +List Organization Usage Events (OAuth login or personal API key required) + +```bash +sim organizations usage events [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--preset ` | No | Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days. Accepted values: `current-period`, `previous-period`, `7d`, `30d`, `custom`. | +| `--start-date ` | No | First calendar date included, in the selected timezone. Requires preset=custom. | +| `--end-date ` | No | Last calendar date included, in the selected timezone. Requires preset=custom. | +| `--timezone ` | No | IANA timezone for calendar boundaries; defaults to UTC. | +| `--source ` | No | Restrict events to one product surface. Accepted values: `workflow`, `wand`, `sim-chat`, `mcp_copilot`, `mothership_block`, `knowledge-base`, `voice-input`, `enrichment`, `voice-output`, `api-tool`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + ### sim organizations list List Organizations (OAuth login or personal API key required) @@ -6714,6 +7094,109 @@ sim workflows mkdir Also spelled `sim workspace`. +### sim workspaces access-requests cancel + +Cancel Workspace Access Request (OAuth login or personal API key required) + +```bash +sim workspaces access-requests cancel +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +### sim workspaces access-requests create + +Create Workspace Access Request (OAuth login or personal API key required) + +```bash +sim workspaces access-requests create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--target ` | Yes | Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable. (JSON, or @path / @- to read a file or stdin). | +| `--reason ` | No | Why the acting user needs this access. | + + + +### sim workspaces access-requests discover + +Discover Workspace Access Requests (OAuth login or personal API key required) + +```bash +sim workspaces access-requests discover [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the access item label. | +| `--target-kind ` | No | Category of access to discover. Accepted values: `feature`, `integration`, `provider`, `model`, `tool`, `knowledge_connector`, `file_share_auth`, `chat_deploy_auth`, `usage_limit`. | +| `--state ` | No | Filter by the acting user’s current access. Requestable items can be submitted for review. Accepted values: `allowed`, `requestable`, `unavailable`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `label`. | +| `--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 workspaces access-requests mine + +List My Workspace Access Requests (OAuth login or personal API key required) + +```bash +sim workspaces access-requests mine [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--status ` | No | Filter by request status; omit to include all statuses. Accepted values: `pending`, `fulfilled`, `declined`, `cancelled`, `closed`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`, `targetLabel`. | +| `--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 workspaces invitations create + +Create Workspace Invitations (OAuth login or personal API key required) + +```bash +sim workspaces invitations create [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--emails ` | Yes | Email addresses to invite. Each address is processed separately; inspect failed for unsuccessful recipients. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--permission ` | No | Workspace permission to grant. Existing workspace access is preserved. Accepted values: `admin`, `write`, `read`. | +| `--membership ` | No | Organization membership: member or admin uses a seat when billing is enabled. External grants workspace access only and requires an eligible paid account when billing is enabled. Existing members of another organization remain external. Accepted values: `member`, `admin`, `external`. | + + + ### sim workspaces fork Fork Workspace (OAuth login or personal API key required) @@ -6870,6 +7353,14 @@ sim workspaces operations wait [options] +### sim workspaces permission-config + +Get Workspace Permission Config (OAuth login or personal API key required) + +```bash +sim workspaces permission-config +``` + ### sim workspaces children List Workspace Fork Children (OAuth login or personal API key required) diff --git a/apps/docs/content/docs/cli/scripting.mdx b/apps/docs/content/docs/cli/scripting.mdx index c6f4fbfe10a..e0d111736cf 100644 --- a/apps/docs/content/docs/cli/scripting.mdx +++ b/apps/docs/content/docs/cli/scripting.mdx @@ -112,6 +112,55 @@ sim tables rows list tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 100 --cursor " billing and audit logs), workflow run/version histories, and knowledge document/chunk lists all support this continuation. +## Organization access and invitations + +Organization commands use `--organization`; workspace commands use `--workspace` +or the active profile. Use `userId` from `organizations members list` or +`workspaces members list` for member credit limits, including external collaborators. + +`access-requests mine` shows your requests in the selected scope. Organization +`access-requests list` is the administrator inbox across all organization workspaces. +Use discovery to find the exact target to submit: + +```bash +sim workspaces access-requests discover --workspace "$workspace_id" --state requestable --output json +sim workspaces access-requests create --workspace "$workspace_id" \ + --target '{"kind":"feature","configKey":"hideTablesTab"}' --reason 'Maintain team data' +sim workspaces access-requests mine --workspace "$workspace_id" +``` + +Administrators preview a request before approving it. Inspect `canApply`, `changes`, +and `impact`: permission approvals change the governing group for all affected +members. Pass the reviewed fingerprint explicitly; a conflict means you need a +new preview. + +```bash +sim organizations access-requests preview "$request_id" --organization "$organization_id" --output json > preview.json +sim organizations access-requests resolve "$request_id" --organization "$organization_id" \ + --action apply --expected-fingerprint "$(jq -r '.fingerprint' preview.json)" +``` + +For a credit-cap request, applying also requires `--new-limit-credits` greater than +the current cap. To decline instead, use `--action decline --reason 'Explanation'`. + +Administrators can also set a cap directly. `0` prevents further credit-consuming +usage; `null` removes only the per-person cap, so organization limits still apply: + +```bash +sim organizations members usage-limit update "$user_id" --organization "$organization_id" --credit-limit 5000 +sim organizations members usage-limit update "$user_id" --organization "$organization_id" --credit-limit null +``` + +Workspace invitations accept a list of up to 50 emails. Recipients are processed +independently. If any fail, the CLI prints the full result and exits `1`; successful +invitations and grants remain committed. Inspect `failed` and current invitations +before retrying, especially when delivery could not be confirmed. + +```bash +sim workspaces invitations create --workspace "$workspace_id" \ + --emails alex@example.com sam@example.com --permission write --membership member --output json +``` + ## Destructive commands Deletions require an explicit selector **and** `--yes`. There is no "delete diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index b64bdbc3e72..3125fe12195 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -9,6 +9,109 @@ import { CommandTable } from '@/components/ui/command-table' Every command below also accepts the [global options](/cli/commands#global-options). +## Cancel workspace access request + +```bash +sim workspaces access-requests cancel +``` + +Cancel Workspace Access Request (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `requestId` | Yes | Access request identifier. | + + + +## Create workspace access request + +```bash +sim workspaces access-requests create [options] +``` + +Create Workspace Access Request (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--target ` | Yes | Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable. (JSON, or @path / @- to read a file or stdin). | +| `--reason ` | No | Why the acting user needs this access. | + + + +## Discover workspace access requests + +```bash +sim workspaces access-requests discover [options] +``` + +Discover Workspace Access Requests (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the access item label. | +| `--target-kind ` | No | Category of access to discover. Accepted values: `feature`, `integration`, `provider`, `model`, `tool`, `knowledge_connector`, `file_share_auth`, `chat_deploy_auth`, `usage_limit`. | +| `--state ` | No | Filter by the acting user’s current access. Requestable items can be submitted for review. Accepted values: `allowed`, `requestable`, `unavailable`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `label`. | +| `--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. | + + + +## List my workspace access requests + +```bash +sim workspaces access-requests mine [options] +``` + +List My Workspace Access Requests (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--status ` | No | Filter by request status; omit to include all statuses. Accepted values: `pending`, `fulfilled`, `declined`, `cancelled`, `closed`. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `createdAt`, `targetLabel`. | +| `--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. | + + + +## Create workspace invitations + +```bash +sim workspaces invitations create [options] +``` + +Create Workspace Invitations (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--emails ` | Yes | Email addresses to invite. Each address is processed separately; inspect failed for unsuccessful recipients. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--permission ` | No | Workspace permission to grant. Existing workspace access is preserved. Accepted values: `admin`, `write`, `read`. | +| `--membership ` | No | Organization membership: member or admin uses a seat when billing is enabled. External grants workspace access only and requires an eligible paid account when billing is enabled. Existing members of another organization remain external. Accepted values: `member`, `admin`, `external`. | + + + ## Fork workspace ```bash @@ -157,6 +260,14 @@ sim workspaces operations wait [options] +## Get workspace permission config + +```bash +sim workspaces permission-config +``` + +Get Workspace Permission Config (OAuth login or personal API key required) + ## List workspace fork children ```bash diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 6cc51ca68d5..3a379291afa 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(170) + expect(Object.keys(paths)).toHaveLength(187) expect(tags.map((tag) => tag.name)).toEqual([ 'Workspace Sync', 'Workflows', @@ -44,6 +44,7 @@ describe('OpenAPI download', () => { 'Tables', 'Knowledge Bases', 'Billing', + 'Access Requests', 'Organizations', 'Permission Groups', 'Meta', diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 97d0ce13726..a531def03f3 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -477,6 +477,8 @@ "ENTERPRISE_PLAN_REQUIRED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", "SKILL_EDITOR_ACCESS_REQUIRED", "SECRET_ADMIN_ACCESS_REQUIRED", "WORKSPACE_RESOURCE_LIMIT_REACHED", diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 77844ff2cbc..376508187b7 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -3839,6 +3839,8 @@ "ENTERPRISE_PLAN_REQUIRED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", "SKILL_EDITOR_ACCESS_REQUIRED", "SECRET_ADMIN_ACCESS_REQUIRED", "WORKSPACE_RESOURCE_LIMIT_REACHED", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index e36b58becc8..008c29f44a8 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -4734,6 +4734,8 @@ "ENTERPRISE_PLAN_REQUIRED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", "SKILL_EDITOR_ACCESS_REQUIRED", "SECRET_ADMIN_ACCESS_REQUIRED", "WORKSPACE_RESOURCE_LIMIT_REACHED", diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 1f6b7dbe211..b3d910c3cb3 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -813,6 +813,8 @@ "ENTERPRISE_PLAN_REQUIRED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", "SKILL_EDITOR_ACCESS_REQUIRED", "SECRET_ADMIN_ACCESS_REQUIRED", "WORKSPACE_RESOURCE_LIMIT_REACHED", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index de991e52ce0..90874352841 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Access Requests", + "description": "Request access and review changes to organization permissions and member credit limits." + }, { "name": "Organizations", "description": "Discover organizations and manage their members and invitations." @@ -251,7 +255,7 @@ "get": { "operationId": "listWorkspaceMembers", "summary": "List Workspace Members", - "description": "List workspace members by email, including explicit grants and inherited organization admin access.\n\nOAuth scope: `api:read`.", + "description": "List workspace members by email, including explicit grants and inherited organization admin access. Each member includes a stable user ID for member administration.\n\nOAuth scope: `api:read`.", "x-sim-operation": "workspaces.members.list_public", "x-oauth-scope": "api:read", "tags": ["Workspaces"], @@ -6463,7 +6467,7 @@ "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`.", + "description": "Get an invitation owned by the organization. Requires organization administrator access. Use List Organization Invitation Workspaces to inspect its workspace grants. 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"], @@ -6616,6 +6620,144 @@ } } }, + "/api/v2/organizations/{organizationId}/invitations/{invitationId}/workspaces": { + "get": { + "operationId": "listOrganizationInvitationWorkspaces", + "summary": "List Organization Invitation Workspaces", + "description": "List workspace grants attached to an invitation of any status. Includes archived workspaces still owned by the organization; workspaces moved to another organization are omitted. Requires organization administrator access. These grants describe the invitation, not the invitee's current access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.invitations.workspaces.list", + "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." + } + }, + { + "name": "invitationId", + "in": "path", + "required": true, + "description": "Invitation identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Invitation identifier." + } + }, + { + "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 + } + }, + { + "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"] + } + }, + { + "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 workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "A page of workspace grants attached to the invitation.", + "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/ListOrganizationInvitationWorkspacesResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/organizations/{organizationId}/invitations/{invitationId}/resend": { "post": { "operationId": "resendOrganizationInvitation", @@ -6713,4078 +6855,3442 @@ } } } - } - }, - "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/workspaces/{workspaceId}/permission-config": { + "get": { + "operationId": "getWorkspacePermissionConfig", + "summary": "Get Workspace Permission Config", + "description": "Get the acting user's governing permission group and configuration for a workspace they can access. This describes permission-group restrictions, not the user's workspace role. Group and config are null when no group governs the caller; entitled indicates whether organization permission governance is active. 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_user_config", + "x-oauth-scope": "api:read", + "tags": ["Workspaces"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Unique workspace identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." } } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + ], + "responses": { + "200": { + "description": "The caller’s effective permission-group configuration.", + "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": "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" - } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspacePermissionConfigResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + } + }, + "/api/v2/workspaces/{workspaceId}/invitations": { + "post": { + "operationId": "createWorkspaceInvitations", + "summary": "Create Workspace Invitations", + "description": "Invite people to a workspace or grant access immediately to existing organization members. Requires workspace administrator access and current invitation eligibility; organization administrator invitations also require organization administrator access. Recipients are processed independently: inspect failed even after HTTP 200, and inspect invitation status before retrying a delivery failure. Existing access is preserved. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "invitations.send_batch", + "x-oauth-scope": "api:write", + "tags": ["Workspaces"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Unique workspace identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." } } - } - }, - "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" + ], + "requestBody": { + "required": true, + "description": "Recipients and the access to grant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceInvitationsBody" } } } - } - }, - "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" + }, + "responses": { + "200": { + "description": "Per-recipient invitation and direct-grant outcomes.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceInvitationsResponse" + } } } + }, + "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" } } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { + } + }, + "/api/v2/organizations/{organizationId}/members/{userId}/usage-limit": { + "get": { + "operationId": "getOrganizationMemberUsageLimit", + "summary": "Get Organization Member Credit Limit", + "description": "Read a person’s credit cap and credits consumed in the organization billing period. Hosted only. The userId identifies an organization member or external collaborator with workspace access in this organization; it is not a membership record ID. Null means no per-person cap, while organization limits still apply. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_member_usage_limits.read", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" - } + "type": "string", + "minLength": 1, + "description": "Organization identifier." } - } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.", "schema": { - "$ref": "#/components/schemas/V2Error" + "type": "string", + "minLength": 1, + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it." + } + } + ], + "responses": { + "200": { + "description": "Get Organization Member Credit Limit 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/GetOrganizationMemberUsageLimitResponse" } } } + }, + "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" } } }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { + "patch": { + "operationId": "updateOrganizationMemberUsageLimit", + "summary": "Update Organization Member Credit Limit", + "description": "Set or clear a person’s credit cap. Hosted only. The userId must identify an organization member or external collaborator with workspace access in this organization. The cap is a nonnegative whole number of credits, not dollars: 0 prevents further credit-consuming usage; null removes the per-person cap. Organization limits continue to apply. Retrying the same value is safe. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organization_member_usage_limits.update", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.", + "schema": { + "type": "string", + "minLength": 1, + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it." + } + } + ], + "requestBody": { + "required": true, + "description": "Credit cap in whole credits; null clears the cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationMemberUsageLimitBody" + } } - } - } - }, - "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" + "responses": { + "200": { + "description": "Update Organization Member Credit Limit 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/UpdateOrganizationMemberUsageLimitResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "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" + "/api/v2/organizations/{organizationId}/usage/summary": { + "get": { + "operationId": "getOrganizationUsageSummary", + "summary": "Get Organization Usage Summary", + "description": "Read pooled credits, a usage series, and an exact previous-period comparison when available. Requires organization administrator access and Usage Monitoring (Enterprise on hosted; enabled on self-hosted). Defaults to 30 days. Custom dates include both dates in the selected timezone and cannot exceed 92 days. Billing windows exceeding 366 days are rejected. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_usage.summary.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." + } + }, + { + "name": "preset", + "in": "query", + "required": false, + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "schema": { + "default": "30d", + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "type": "string", + "enum": ["current-period", "previous-period", "7d", "30d", "custom"] + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "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])))$" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "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])))$" + } + }, + { + "name": "timezone", + "in": "query", + "required": false, + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "schema": { + "default": "UTC", + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Restrict usage to one workspace owned by the organization.", + "schema": { + "description": "Restrict usage to one workspace owned by the organization.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } } - }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." - }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." - }, - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." + "responses": { + "200": { + "description": "Get Organization Usage Summary 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/GetOrganizationUsageSummaryResponse" + } + } } - } - ] - }, - "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." + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List workspaces response", - "description": "Public metadata for workspaces available to the credential.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/usage/breakdown": { + "get": { + "operationId": "getOrganizationUsageBreakdown", + "summary": "Get Organization Usage Breakdown", + "description": "Read ranked organization usage by member, workspace, workflow, model, BYOK provider, or source. Requires organization administrator access and Usage Monitoring. Omitted usage is summarized in other. BYOK ranks tokens; other dimensions rank cost. More than 10,000 underlying groups returns 413; narrow the window or workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_usage.breakdown.read", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ { - "data": [ - { - "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Engineering", - "color": "#33C482", - "logoUrl": null, - "memberCount": 14, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "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": [ + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + }, { - "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": "preset", + "in": "query", + "required": false, + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "schema": { + "default": "30d", + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "type": "string", + "enum": ["current-period", "previous-period", "7d", "30d", "custom"] } - } - ] - }, - "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": "startDate", + "in": "query", + "required": false, + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "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])))$" + } }, - "image": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Member profile image URL, or null when absent." + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "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])))$" + } }, - "role": { - "type": "string", - "enum": ["admin", "write", "read"], - "description": "Effective role in the workspace." + { + "name": "timezone", + "in": "query", + "required": false, + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "schema": { + "default": "UTC", + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "type": "string", + "minLength": 1 + } }, - "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": "workspaceId", + "in": "query", + "required": false, + "description": "Restrict usage to one workspace owned by the organization.", + "schema": { + "description": "Restrict usage to one workspace owned by the organization.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } }, - "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." + { + "name": "dimension", + "in": "query", + "required": true, + "description": "Usage grouping dimension.", + "schema": { + "type": "string", + "enum": ["member", "workspace", "workflow", "model", "byok", "source"], + "description": "Usage grouping dimension." + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Get Organization Usage Breakdown 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/GetOrganizationUsageBreakdownResponse" + } } - ], - "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" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "enabled": { - "type": "boolean", - "description": "Whether the server tools are available to workflows." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "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"] + "500": { + "$ref": "#/components/responses/InternalError" }, - "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" - } - ] + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/usage/events": { + "get": { + "operationId": "listOrganizationUsageEvents", + "summary": "List Organization Usage Events", + "description": "Page through usage events, including zero-cost reporting. Requires organization administrator access and Usage Monitoring. Defaults to 30 days. Cursors retain the initial reporting window; keep filters and sort unchanged while paging. The sim-chat source covers both chat surfaces. Per-event rounding can produce credits=0 with hasCost=true. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_usage.events.list", + "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." + } }, - "toolCount": { - "description": "Number of tools discovered on the server.", - "type": "number" + { + "name": "preset", + "in": "query", + "required": false, + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "schema": { + "default": "30d", + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "type": "string", + "enum": ["current-period", "previous-period", "7d", "30d", "custom"] + } }, - "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))$" + { + "name": "startDate", + "in": "query", + "required": false, + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "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])))$" + } }, - "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))$" + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "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])))$" + } }, - "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))$" + { + "name": "timezone", + "in": "query", + "required": false, + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "schema": { + "default": "UTC", + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "type": "string", + "minLength": 1 + } }, - "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))$" + { + "name": "source", + "in": "query", + "required": false, + "description": "Restrict events to one product surface.", + "schema": { + "description": "Restrict events to one product surface.", + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + } }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier, when configured.", - "type": "string" + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "hasHeaders": { - "type": "boolean", - "description": "Whether any request headers are configured." + { + "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 + } }, - "headerNames": { - "type": "array", - "items": { + { + "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", - "description": "Configured header name." - }, - "description": "Names of configured request headers. Header values are never returned." + "enum": ["createdAt"] + } }, - "hasOauthClientSecret": { - "type": "boolean", - "description": "Whether an OAuth client secret is stored. The value is never returned." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } } - }, - "required": [ - "id", - "name", - "transport", - "enabled", - "createdAt", - "updatedAt", - "hasHeaders", - "headerNames", - "hasOauthClientSecret" ], - "additionalProperties": false, - "title": "MCP server", - "description": "Public MCP server configuration without write-only credential values." - }, - "ListMcpServersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpServer" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "responses": { + "200": { + "description": "List Organization Usage Events 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/ListOrganizationUsageEventsResponse" + } } - ], - "nextCursor": null - } - ] - }, - "CreateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create MCP server response", - "description": "The registered MCP server without write-only credentials.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "disconnected", - "lastError": null, - "toolCount": 0, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false } - } - ] - }, - "CreateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to register the server." }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "transport": { - "description": "Transport protocol. Defaults to `streamable-http` on creation.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + "404": { + "$ref": "#/components/responses/NotFound" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "429": { + "$ref": "#/components/responses/RateLimited" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1 - }, - "additionalProperties": { + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/access-requests/discovery": { + "get": { + "operationId": "discoverWorkspaceAccessRequests", + "summary": "Discover Workspace Access Requests", + "description": "Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.discover", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { "type": "string", - "description": "Header value sent to the MCP server." + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the access item label.", + "schema": { + "description": "Case-insensitive substring match against the access item label.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "retries": { - "description": "Number of retries per request. Defaults to 3 on creation.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + { + "name": "targetKind", + "in": "query", + "required": false, + "description": "Category of access to discover.", + "schema": { + "description": "Category of access to discover.", + "type": "string", + "enum": [ + "feature", + "integration", + "provider", + "model", + "tool", + "knowledge_connector", + "file_share_auth", + "chat_deploy_auth", + "usage_limit" + ] + } }, - "enabled": { - "description": "Whether workflows can use the server's tools. Defaults to true on creation.", - "default": true, - "type": "boolean" + { + "name": "state", + "in": "query", + "required": false, + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "schema": { + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "type": "string", + "enum": ["allowed", "requestable", "unavailable"] + } }, - "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" - } - ] + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "label", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["label"] + } }, - "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": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } - } - ] - }, - "GetMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get MCP server response", - "description": "One MCP server without write-only credentials.", - "examples": [ + }, { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 } - } - ] - }, - "UpdateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update MCP server response", - "description": "The updated MCP server.", - "examples": [ + }, { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": false, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "name": "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 } } - ] - }, - "UpdateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the MCP server." + ], + "responses": { + "200": { + "description": "Discover Workspace Access Requests 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/DiscoverWorkspaceAccessRequestsResponse" + } + } + } }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "transport": { - "description": "Transport protocol. Defaults to `streamable-http` on creation.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", - "type": "string", - "minLength": 1, - "maxLength": 2048 + "404": { + "$ref": "#/components/responses/NotFound" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "409": { + "$ref": "#/components/responses/Conflict" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/access-requests": { + "get": { + "operationId": "listMyWorkspaceAccessRequests", + "summary": "List My Workspace Access Requests", + "description": "List only the acting user’s requests in this workspace, including resolved history and organization-wide member credit-limit requests. History remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.list_mine", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { "type": "string", - "minLength": 1 - }, - "additionalProperties": { + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by request status; omit to include all statuses.", + "schema": { + "description": "Filter by request status; omit to include all statuses.", "type": "string", - "description": "Header value sent to the MCP server." + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + { + "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": ["createdAt", "targetLabel"] + } }, - "retries": { - "description": "Number of retries per request. Defaults to 3 on creation.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "enabled": { - "description": "Whether workflows can use the server's tools. Defaults to true on creation.", - "default": true, - "type": "boolean" + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 + { + "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 My Workspace Access Requests result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "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 + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - { - "type": "null" + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ] - } - }, - "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." + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMyWorkspaceAccessRequestsResponse" + } + } + } + }, + "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": ["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": [ + "post": { + "operationId": "createWorkspaceAccessRequest", + "summary": "Create Workspace Access Request", + "description": "Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.create", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ { - "data": { - "id": "mcp-3f7a9c21", - "deleted": true + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." } } - ] - }, - "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`." + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceAccessRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Create Workspace Access Request 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." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceAccessRequestResponse" + } + } + } }, - "serverId": { - "type": "string", - "description": "Identifier of the MCP server exposing the tool." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "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." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "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`." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "name": { - "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + "404": { + "$ref": "#/components/responses/NotFound" }, - "description": { - "type": "string", - "description": "One-line summary of when the skill applies." + "409": { + "$ref": "#/components/responses/Conflict" }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "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." + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, - "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." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "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." + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List skills response", - "description": "Skill summaries available in the workspace.", - "examples": [ + } + } + }, + "/api/v2/workspaces/{workspaceId}/access-requests/{requestId}/cancel": { + "post": { + "operationId": "cancelWorkspaceAccessRequest", + "summary": "Cancel Workspace Access Request", + "description": "Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.cancel", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "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" - } - ], - "nextCursor": null + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." + } + }, + { + "name": "requestId", + "in": "path", + "required": true, + "description": "Access request identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + } } - ] - }, - "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`." + ], + "responses": { + "200": { + "description": "Cancel Workspace Access Request 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/CancelWorkspaceAccessRequestResponse" + } + } + } }, - "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." + "409": { + "$ref": "#/components/responses/Conflict" }, - "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" + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "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}/access-requests/discovery": { + "get": { + "operationId": "discoverOrganizationAccessRequests", + "summary": "Discover Organization Access Requests", + "description": "Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. 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": "access_requests.discover", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "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 that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." } - } - ] - }, - "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." + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the access item label.", + "schema": { + "description": "Case-insensitive substring match against the access item label.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "One-line summary of when the skill applies." + { + "name": "targetKind", + "in": "query", + "required": false, + "description": "Category of access to discover.", + "schema": { + "description": "Category of access to discover.", + "type": "string", + "enum": [ + "feature", + "integration", + "provider", + "model", + "tool", + "knowledge_connector", + "file_share_auth", + "chat_deploy_auth", + "usage_limit" + ] + } }, - "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": "state", + "in": "query", + "required": false, + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "schema": { + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "type": "string", + "enum": ["allowed", "requestable", "unavailable"] + } + }, { - "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.", + "schema": { + "default": "label", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["label"] } - } - ] - }, - "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 access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access items 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": "Discover Organization Access Requests 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/DiscoverOrganizationAccessRequestsResponse" + } } - ], - "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." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "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" + "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": ["data"], - "additionalProperties": false, - "title": "Revoke skill editor response", - "description": "Acknowledgement that the explicit editor grant was revoked.", - "examples": [ + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/mine": { + "get": { + "operationId": "listMyOrganizationAccessRequests", + "summary": "List My Organization Access Requests", + "description": "List the acting user’s organization-level requests and member credit-limit requests, including resolved history. For workspace-scoped requests, use List My Workspace Access Requests. History remains available while requests are disabled. 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": "access_requests.list_mine", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ { - "data": { - "email": "jane@example.com", - "revoked": true + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." } - } - ] - }, - "V2CustomTool": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique custom tool identifier." }, - "title": { - "type": "string", - "description": "Display title, unique within the workspace." + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by request status; omit to include all statuses.", + "schema": { + "description": "Filter by request status; omit to include all statuses.", + "type": "string", + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] + } }, - "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." + { + "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": ["createdAt", "targetLabel"] + } }, - "code": { - "type": "string", - "description": "Tool implementation executed in the sandboxed function runtime." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "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." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "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." + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } } - }, - "required": ["id", "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" + ], + "responses": { + "200": { + "description": "List My Organization Access Requests 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 custom tools response", - "description": "Custom tools defined in the workspace.", - "examples": [ - { - "data": [ - { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", + }, + "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: 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"] - } + "$ref": "#/components/schemas/ListMyOrganizationAccessRequestsResponse" } - }, - "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." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "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." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "code": { - "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "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}/access-requests": { + "post": { + "operationId": "createOrganizationAccessRequest", + "summary": "Create Organization Access Request", + "description": "Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.create", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", "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" + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get custom tool response", - "description": "One custom tool.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "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: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "$ref": "#/components/schemas/CreateOrganizationAccessRequestBody" + } } } - ] - }, - "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"] - } - } + "responses": { + "200": { + "description": "Create Organization Access Request result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "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." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "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-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/CreateOrganizationAccessRequestResponse" + } + } } }, - "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." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the custom tool was deleted." + "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": ["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": [ + "get": { + "operationId": "listOrganizationAccessRequests", + "summary": "List Organization Access Requests", + "description": "List requests across the organization for administrator review. Includes requests from organization members and external workspace collaborators; history remains available while requests are disabled. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.list_organization", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." } - } - ] - }, - "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." + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by request status; omit to include all statuses.", + "schema": { + "description": "Filter by request status; omit to include all statuses.", + "type": "string", + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] + } }, - "dependencies": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Package specifiers installed into the sandbox, one per entry." + { + "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": ["createdAt", "targetLabel"] + } }, - "cliTools": { - "type": "array", - "items": { + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", "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." + "enum": ["asc", "desc"] + } }, - "systemPackages": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Debian packages installed into the sandbox, one per entry." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "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." + { + "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 + } }, - "errorCode": { - "anyOf": [ - { - "type": "string" + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the target label or requester name or email.", + "schema": { + "description": "Case-insensitive substring match against the target label or requester name or email.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + } + ], + "responses": { + "200": { + "description": "List Organization Access Requests result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" - } - ], - "description": "Classified build failure code, or `null`." - }, - "errorMessage": { - "anyOf": [ - { - "type": "string" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - { - "type": "null" + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Human-readable build failure summary, or `null`." - }, - "errorDetail": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationAccessRequestsResponse" + } } - ], - "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`." + "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 sandbox 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 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." + "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" + }, + "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}/access-requests/{requestId}/cancel": { + "post": { + "operationId": "cancelOrganizationAccessRequest", + "summary": "Cancel Organization Access Request", + "description": "Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.cancel", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "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 that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + }, { - "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": "requestId", + "in": "path", + "required": true, + "description": "Access request identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." } } - ] - }, - "CreateSandboxRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the sandbox." + ], + "responses": { + "200": { + "description": "Cancel Organization Access Request 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/CancelOrganizationAccessRequestResponse" + } + } + } }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "description": "Display name, unique within the workspace; 1 to 64 characters." + "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": { - "default": [], - "description": "Package specifiers installed into the sandbox, one per entry.", - "maxItems": 1000, - "type": "array", - "items": { + "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" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/{requestId}/preview": { + "get": { + "operationId": "previewOrganizationAccessRequest", + "summary": "Preview Organization Access Request", + "description": "Preview the current permission changes, affected group and audience, or member credit cap. Review canApply, changes, impact, and fingerprint before resolving. Permission changes affect the entire governing group, not only the requester. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.preview", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { "type": "string", - "maxLength": 2000 + "minLength": 1, + "description": "Organization that owns the access requests." } }, - "cliTools": { - "default": [], - "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.", - "maxItems": 10, - "type": "array", - "items": { + { + "name": "requestId", + "in": "path", + "required": true, + "description": "Access request 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" - ] + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + } + } + ], + "responses": { + "200": { + "description": "Preview Organization Access Request 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/PreviewOrganizationAccessRequestResponse" + } + } } }, - "systemPackages": { - "default": [], - "description": "Debian packages installed into the sandbox, one per entry.", - "maxItems": 1000, - "type": "array", - "items": { + "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" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/{requestId}/resolve": { + "post": { + "operationId": "resolveOrganizationAccessRequest", + "summary": "Resolve Organization Access Request", + "description": "Apply a reviewed request or decline it with a reason. Applying requires the preview fingerprint; changed policy or membership returns a conflict. Credit requests also require a higher newLimitCredits. Already resolved requests are returned unchanged. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.resolve", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { "type": "string", - "maxLength": 2000 + "minLength": 1, + "description": "Organization that owns the access requests." } - } - }, - "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"] + "name": "requestId", + "in": "path", + "required": true, + "description": "Access request identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + } } - ] - }, - "GetSandboxResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Sandbox" + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveOrganizationAccessRequestBody" + } + } } }, - "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" + "responses": { + "200": { + "description": "Resolve Organization Access Request 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/ResolveOrganizationAccessRequestResponse" + } + } } + }, + "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" } - ] - }, - "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": [ + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/settings": { + "get": { + "operationId": "getOrganizationAccessRequestSettings", + "summary": "Get Organization Access Request Settings", + "description": "Get whether the organization allows new access requests and approvals. This preference does not enable features unavailable in the deployment or subscription. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.get_settings", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ { - "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" + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." } } - ] - }, - "UpdateSandboxRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the sandbox." + ], + "responses": { + "200": { + "description": "Get Organization Access Request Settings 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/GetOrganizationAccessRequestSettingsResponse" + } + } + } }, - "name": { - "description": "New display name, unique within the workspace; 1 to 64 characters.", - "type": "string", - "minLength": 1, - "maxLength": 64 + "400": { + "$ref": "#/components/responses/BadRequest" }, - "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"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "dependencies": { - "description": "Replacement package list; replaces the whole list.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "maxLength": 2000 - } + "403": { + "$ref": "#/components/responses/Forbidden" }, - "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" - ] - } + "404": { + "$ref": "#/components/responses/NotFound" }, - "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." + "409": { + "$ref": "#/components/responses/Conflict" }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the sandbox was deleted." + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "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": [ + "patch": { + "operationId": "updateOrganizationAccessRequestSettings", + "summary": "Update Organization Access Request Settings", + "description": "Allow or pause new access requests and approvals. Pausing preserves history, cancellation, and decline, and does not revoke previously granted access. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.update_settings", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." } } - ] - }, - "V2Credential": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique credential identifier." + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationAccessRequestSettingsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Update Organization Access Request Settings 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/UpdateOrganizationAccessRequestSettingsResponse" + } + } + } }, - "type": { - "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "displayName": { - "type": "string", - "description": "Credential display name." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional credential description." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Integration provider authenticated by this credential." + "404": { + "$ref": "#/components/responses/NotFound" }, - "accountId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Linked account identifier for OAuth credentials." + "409": { + "$ref": "#/components/responses/Conflict" }, - "hasServiceAccountKey": { - "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "role": { - "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, - "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." + "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 credential was last updated." + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": [ - "id", - "type", - "displayName", - "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + } + } + } + }, + "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." }, - "ListCredentialsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2Credential" + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List 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" + } + }, + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" } - ], - "nextCursor": null + } } - ] + } }, - "V2CredentialProvider": { - "oneOf": [ - { + "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", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", + "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": { - "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": { + "code": { "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Credential provider description." + "description": "Stable machine-readable error code." }, - "providerFamily": { + "message": { "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." + "description": "Human-readable explanation of the error." }, - "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 - } + "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" }, - "required": ["id", "label", "placeholder", "required", "secret", "multiline"], - "additionalProperties": false - }, - "description": "Write-only setup fields required before starting this OAuth flow." + { + "description": "Other structured context defined by the specific error." + } + ] } }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "supportsReconnect", - "authorizationOptions", - "fields" - ], - "additionalProperties": false - }, + "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": [ { - "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 + "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" }, - "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": "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": "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 credential providers response", - "description": "OAuth and service-account connection methods.", + "title": "List workspaces response", + "description": "Public metadata for workspaces available to the credential.", "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 - } - ] + "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 } ] }, - "CreateServiceAccountCredentialResponse": { + "GetWorkspaceResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/V2Workspace" } }, "required": ["data"], "additionalProperties": false, - "title": "Create service-account credential response", - "description": "Verified credential metadata without secret material.", + "title": "Get workspace response", + "description": "Public metadata for one workspace.", "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", + "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" } } ] }, - "CreateServiceAccountCredentialRequest": { + "V2WorkspaceMember": { "type": "object", "properties": { - "workspaceId": { + "userId": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." + "description": "User identifier; use this identifier for member administration." }, - "type": { + "email": { "type": "string", - "const": "service_account", - "description": "Service-account credential discriminator." + "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." }, - "providerId": { + "name": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact service-account provider ID returned by provider discovery." + "description": "Member display name." }, - "displayName": { - "description": "Optional name; providers may derive one from the verified account identity.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member profile image URL, or null when absent." }, - "description": { - "description": "Optional credential description.", + "role": { "type": "string", - "maxLength": 500 + "enum": ["admin", "write", "read"], + "description": "Effective role in the workspace." }, - "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)$" + "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." }, - "credentials": { + "joinedAt": { "type": "string", - "minLength": 1, - "maxLength": 131072, - "description": "Write-only JSON object string containing the fields declared by credential-provider discovery.", - "writeOnly": true + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when access was granted." } }, - "required": ["workspaceId", "type", "providerId", "credentials"], + "required": ["userId", "email", "name", "image", "role", "isExternal", "joinedAt"], "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": "Workspace member", + "description": "An effective workspace member and their public access role." }, - "V2CredentialConnectionAuthorization": { + "ListWorkspaceMembersResponse": { "type": "object", "properties": { - "authorizationUrl": { + "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": [ + { + "userId": "user-123", + "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", - "format": "uri", - "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + "description": "Unique server identifier derived from the workspace and endpoint URL." }, - "expiresAt": { + "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))$", - "description": "ISO 8601 timestamp when the connection link expires." + "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": ["authorizationUrl", "expiresAt"], + "required": [ + "id", + "name", + "transport", + "enabled", + "createdAt", + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" + ], "additionalProperties": false, - "title": "Credential Connection Authorization", - "description": "A short-lived browser entrypoint for an OAuth connection flow." + "title": "MCP server", + "description": "Public MCP server configuration without write-only credential values." }, - "CreateCredentialConnectionResponse": { + "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/V2CredentialConnectionAuthorization" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Create credential connection response", - "description": "Short-lived Sim browser entrypoint and its expiry.", + "title": "Create MCP server response", + "description": "The registered MCP server without write-only credentials.", "examples": [ { "data": { - "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", - "expiresAt": "2026-06-20T14:17:11.000Z" + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "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 } } ] }, - "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": { + "CreateMcpServerRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", "minLength": 1, - "description": "Disconnected credential identifier." + "maxLength": 128, + "description": "Workspace in which to register the server." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the credential was disconnected." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete credential data", - "description": "Credential disconnection acknowledgement." - }, - "DeleteCredentialResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialDeleteData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Disconnect credential response", - "description": "Acknowledgement that the credential was disconnected.", - "examples": [ - { - "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "deleted": true - } - } - ] - }, - "V2SecretWithValue": { - "type": "object", - "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "description": "Server display name." }, - "scope": { + "description": { + "description": "Optional server 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." + "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." - }, - "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": { + "transport": { + "description": "Transport protocol. Defaults to `streamable-http` on creation.", + "default": "streamable-http", "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the secret." + "enum": ["streamable-http"] }, - "createdAt": { + "url": { "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." + "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." }, - "updatedAt": { + "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", - "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." + "enum": ["none", "headers", "oauth"] }, - "value": { - "description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.", - "type": "string" - } - }, - "required": [ - "name", - "scope", - "description", - "unredacted", - "role", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Secret metadata with visible value", - "description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)." - }, - "ListSecretsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2SecretWithValue" + "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 }, - "description": "Items in the current page." + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." + } }, - "nextCursor": { + "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" + "type": "string", + "maxLength": 512 }, { "type": "null" } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List secrets response", - "description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.", - "examples": [ - { - "data": [ - { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "unredacted": false, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - }, - { - "name": "STAGING_BASE_URL", - "scope": "workspace", - "description": "Staging environment base URL.", - "unredacted": true, - "role": "member", - "createdAt": "2026-06-03T11:30:00.000Z", - "updatedAt": "2026-06-21T08:45:09.000Z", - "value": "https://staging.example.com" - } - ], - "nextCursor": null - } - ] - }, - "V2Secret": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." - }, - "scope": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + ] }, - "description": { + "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" + "type": "string", + "maxLength": 2048 }, { "type": "null" } - ], - "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." - }, - "unredacted": { - "type": "boolean", - "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." - }, - "role": { - "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the secret." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was last updated." + ] } }, - "required": [ - "name", - "scope", - "description", - "unredacted", - "role", - "createdAt", - "updatedAt" - ], + "required": ["workspaceId", "name", "url"], "additionalProperties": false, - "title": "Secret metadata", - "description": "Public secret metadata without the stored secret value." + "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" + } + } + ] }, - "SetSecretResponse": { + "GetMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Secret" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Set secret response", - "description": "Metadata for the created or replaced secret without its value.", + "title": "Get MCP server response", + "description": "One MCP server without write-only credentials.", "examples": [ { "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "unredacted": false, - "role": "admin", + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "SetSecretRequest": { + "UpdateMcpServerResponse": { "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." - }, - "scope": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." - }, - "value": { - "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 65536 - }, - "description": { - "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", - "anyOf": [ - { - "type": "string", - "maxLength": 500 + "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" } ] }, - "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" + "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", "scope"], + "required": ["workspaceId"], "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 MCP server request", + "description": "MCP server fields to change; omitted fields retain their stored values.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "value": "YOUR_SECRET_VALUE" - }, - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "unredacted": false + "enabled": false } ] }, - "V2SecretDeleteData": { + "V2McpServerDeleteData": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." - }, - "scope": { + "id": { "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": "Identifier of the deleted MCP server." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the secret was deleted." + "description": "Whether the server was deleted." } }, - "required": ["name", "scope", "deleted"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "Delete secret data", - "description": "Secret deletion acknowledgement without the stored value." + "title": "Delete MCP server data", + "description": "MCP server deletion acknowledgement." }, - "DeleteSecretResponse": { + "DeleteMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2SecretDeleteData" + "$ref": "#/components/schemas/V2McpServerDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete secret response", - "description": "Acknowledgement that the secret was deleted.", + "title": "Delete MCP server response", + "description": "Acknowledgement that the MCP server was deleted.", "examples": [ { "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", + "id": "mcp-3f7a9c21", "deleted": true } } ] }, - "V2Meta": { + "V2McpTool": { "type": "object", "properties": { - "v2Enabled": { - "type": "boolean", - "description": "Whether this API version is available. This is true when the endpoint is served." + "name": { + "type": "string", + "description": "Tool name, as the MCP server reports it." }, - "keyType": { + "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", - "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": "Identifier of the MCP server exposing the tool." }, - "expiresAt": { + "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", - "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." + "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": ["v2Enabled", "keyType", "expiresAt"], + "required": ["data", "nextCursor"], "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.", + "title": "List MCP server tools response", + "description": "Tools exposed by the MCP server.", "examples": [ { - "data": { - "v2Enabled": true, - "keyType": "personal", - "expiresAt": null - } + "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 } ] }, - "WorkflowMcpServerListItem": { + "V2SkillSummary": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique workflow-MCP server identifier." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." }, "name": { "type": "string", - "description": "Server display name, shown to connecting MCP clients." + "description": "Kebab-case name that agents use to reference the skill." }, "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional server description, or null when unset." + "type": "string", + "description": "One-line summary of when the skill applies." }, - "isPublic": { + "readOnly": { "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"] + "description": "Whether this is a built-in skill that cannot be modified or deleted." }, "createdAt": { "type": "string", - "description": "ISO 8601 timestamp when the server was created.", - "format": "date-time" + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." }, "updatedAt": { "type": "string", - "description": "ISO 8601 timestamp when the server was last modified.", - "format": "date-time" - }, - "toolCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of workflows published as tools." - }, - "toolNames": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Tool names this server publishes, alphabetically ordered." + "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", - "isPublic", - "mcpServerUrl", - "createdAt", - "updatedAt", - "toolCount", - "toolNames" - ], + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Workflow MCP server list item", - "description": "A published MCP server together with the tool names it exposes." + "title": "Skill summary", + "description": "Public summary metadata for a workspace or built-in skill." }, - "ListWorkflowMcpServersResponse": { + "ListSkillsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowMcpServerListItem" + "$ref": "#/components/schemas/V2SkillSummary" }, "description": "Items in the current page." }, @@ -10798,424 +10304,289 @@ } ], "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"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List workflow MCP servers response", - "description": "A cursor-paginated page of published MCP servers.", + "title": "List skills response", + "description": "Skill summaries available in the workspace.", "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"] + "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, - "toolNamesTruncated": false + "nextCursor": null } ] }, - "WorkflowMcpServer": { + "V2Skill": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique workflow-MCP server identifier." + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." }, "name": { "type": "string", - "description": "Server display name, shown to connecting MCP clients." + "description": "Kebab-case name that agents use to reference the skill." }, "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional server description, or null when unset." + "type": "string", + "description": "One-line summary of when the skill applies." }, - "isPublic": { + "readOnly": { "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"] + "description": "Whether this is a built-in skill that cannot be modified or deleted." }, "createdAt": { "type": "string", - "description": "ISO 8601 timestamp when the server was created.", - "format": "date-time" + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." }, "updatedAt": { "type": "string", - "description": "ISO 8601 timestamp when the server was last modified.", - "format": "date-time" + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the 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", - "isPublic", - "mcpServerUrl", - "createdAt", - "updatedAt" - ], + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], "additionalProperties": false, - "title": "Workflow MCP server", - "description": "A workspace-published MCP server exposing deployed workflows as tools." + "title": "Skill", + "description": "A workspace or built-in skill including its instruction body." }, - "CreateWorkflowMcpServerResponse": { + "CreateSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpServer" + "$ref": "#/components/schemas/V2Skill" } }, "required": ["data"], "additionalProperties": false, - "title": "Create workflow MCP server response", - "description": "The published MCP server.", + "title": "Create skill response", + "description": "The created skill including its content.", "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" + "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." } } ] }, - "CreateWorkflowMcpServerRequest": { + "CreateSkillRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to publish the server." + "description": "Workspace in which to create the skill." }, "name": { "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Server display name, shown to connecting MCP clients." + "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": { - "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" + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." }, - "workflowIds": { - "description": "Deployed workflows to publish as tools on the new server.", - "maxItems": 100, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "Skill body containing the instructions given to the agent." } }, - "required": ["workspaceId", "name"], + "required": ["workspaceId", "name", "description", "content"], "additionalProperties": false, - "title": "Create workflow MCP server request", - "description": "A new workspace-published MCP server and the workflows it exposes.", + "title": "Create skill request", + "description": "Definition of a new skill.", "examples": [ { - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "name": "Support agents", - "workflowIds": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "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." } ] }, - "GetWorkflowMcpServerResponse": { + "GetSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpServer" + "$ref": "#/components/schemas/V2Skill" } }, "required": ["data"], "additionalProperties": false, - "title": "Get workflow MCP server response", - "description": "A single published MCP server.", + "title": "Get skill response", + "description": "One skill including its full content.", "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" + "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." } } ] }, - "WorkflowMcpToolListItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique tool identifier." - }, - "serverId": { - "type": "string", - "description": "Server that publishes this tool." - }, - "workflowId": { - "type": "string", - "description": "Workflow this tool executes." - }, - "toolName": { - "type": "string", - "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." - }, - "toolDescription": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Description shown to MCP clients." - }, - "mcpServerUrl": { - "type": "string", - "description": "Endpoint an MCP client connects to." - }, - "apiEndpoint": { - "type": "string", - "description": "Sim execution endpoint this tool calls through." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was last modified.", - "format": "date-time" - } - }, - "required": [ - "id", - "serverId", - "workflowId", - "toolName", - "toolDescription", - "mcpServerUrl", - "apiEndpoint", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Workflow MCP tool list item", - "description": "A tool a server publishes, as returned by a read." - }, - "ListWorkflowMcpToolsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowMcpToolListItem" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - }, - "truncated": { - "type": "boolean", - "description": "Whether the tool limit left this inventory incomplete. The list is unpaginated and `nextCursor` remains null even when truncated. Do not treat a truncated inventory as the complete set of published tools." - } - }, - "required": ["data", "nextCursor", "truncated"], - "additionalProperties": false, - "title": "List workflow MCP tools response", - "description": "The tools a published MCP server exposes.", - "examples": [ - { - "data": [ - { - "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", - "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "toolName": "triage_ticket", - "toolDescription": "Execute Ticket triage workflow", - "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", - "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - ], - "nextCursor": null, - "truncated": false - } - ] - }, - "UpdateWorkflowMcpServerResponse": { + "UpdateSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpServer" + "$ref": "#/components/schemas/V2Skill" } }, "required": ["data"], "additionalProperties": false, - "title": "Update workflow MCP server response", - "description": "The updated MCP server.", + "title": "Update skill response", + "description": "The updated skill including its full content.", "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" + "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." } } ] }, - "UpdateWorkflowMcpServerRequest": { + "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": 255, - "description": "Server display name, shown to connecting MCP clients." + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, "description": { - "description": "New server description, or null to clear it.", - "anyOf": [ - { - "type": "string", - "maxLength": 2000 - }, - { - "type": "null" - } - ] + "description": "New one-line summary of when the skill applies.", + "type": "string", + "minLength": 1, + "maxLength": 1024 }, - "isPublic": { - "description": "Whether the server answers MCP clients without a Sim API key.", - "type": "boolean" + "content": { + "description": "Replacement skill body.", + "type": "string", + "minLength": 1, + "maxLength": 50000 } }, + "required": ["workspaceId"], "additionalProperties": false, - "title": "Update workflow MCP server request", - "description": "Merge-patch body for a published MCP server.", + "title": "Update skill request", + "description": "Skill fields to change; at least one editable field is required.", "examples": [ { - "isPublic": true + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" } ] }, - "DeleteWorkflowMcpServerResult": { + "V2SkillDeleteData": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the unpublished server." + "description": "Identifier of the deleted skill." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the server was unpublished." + "description": "Whether the skill was deleted." } }, "required": ["id", "deleted"], "additionalProperties": false, - "title": "Delete workflow MCP server result", - "description": "Unpublish acknowledgement." + "title": "Delete skill data", + "description": "Skill deletion acknowledgement." }, - "DeleteWorkflowMcpServerResponse": { + "DeleteSkillResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeleteWorkflowMcpServerResult" + "$ref": "#/components/schemas/V2SkillDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete workflow MCP server response", - "description": "Acknowledgement that the MCP server was unpublished.", + "title": "Delete skill response", + "description": "Acknowledgement that the skill was deleted.", "examples": [ { "data": { - "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } ] }, - "WorkflowMcpTool": { + "V2SkillEditor": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique tool identifier." - }, - "serverId": { - "type": "string", - "description": "Server that publishes this tool." - }, - "workflowId": { + "email": { "type": "string", - "description": "Workflow this tool executes." + "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." }, - "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." + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the skill editor." }, - "toolDescription": { + "image": { "anyOf": [ { "type": "string" @@ -11224,1574 +10595,1651 @@ "type": "null" } ], - "description": "Description shown to MCP clients." + "description": "Profile image URL of the skill editor." }, - "mcpServerUrl": { - "type": "string", - "description": "Endpoint an MCP client connects to." - }, - "apiEndpoint": { - "type": "string", - "description": "Sim execution endpoint this tool calls through." - }, - "updated": { + "isWorkspaceAdmin": { "type": "boolean", - "description": "False when the workflow was newly published on this server, true when an existing tool was replaced. Publishing is idempotent per workflow, so a repeat call answers 200 with true rather than conflicting." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was created.", - "format": "date-time" + "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." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the tool was last modified.", - "format": "date-time" + "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": [ - "id", - "serverId", - "workflowId", - "toolName", - "toolDescription", - "mcpServerUrl", - "apiEndpoint", - "updated", - "createdAt", - "updatedAt" - ], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Workflow MCP tool", - "description": "A deployed workflow published as a tool on a workflow-MCP server." + "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 + } + ] }, - "DeployWorkflowMcpToolResponse": { + "GrantSkillEditorResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowMcpTool" + "$ref": "#/components/schemas/V2SkillEditor" } }, "required": ["data"], "additionalProperties": false, - "title": "Publish workflow as MCP tool response", - "description": "The published tool.", + "title": "Grant skill editor response", + "description": "Public identity fields for the editor.", "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" + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "isWorkspaceAdmin": false } } ] }, - "DeployWorkflowMcpToolRequest": { + "GrantSkillEditorRequest": { "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.", + "workspaceId": { "type": "string", "minLength": 1, - "maxLength": 128 + "maxLength": 128, + "description": "Workspace that owns the skill." }, - "toolDescription": { - "description": "Description shown to MCP clients. Derived from the workflow name when omitted.", + "email": { "type": "string", - "maxLength": 2000 - }, - "parameterDescriptions": { - "description": "Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored.", - "maxItems": 100, - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Input field of the deployed workflow to describe." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 2000, - "description": "Text MCP clients see for that field." - } - }, - "required": ["name", "description"], - "additionalProperties": false - } + "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": ["workflowId"], + "required": ["workspaceId", "email"], "additionalProperties": false, - "title": "Publish workflow as MCP tool request", - "description": "The workflow to publish and the tool metadata MCP clients see.", + "title": "Grant skill editor request", + "description": "Workspace scope and email of the member to grant.", "examples": [ { - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "toolName": "triage_ticket" + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "email": "jane@example.com" } ] }, - "UndeployWorkflowMcpToolResult": { + "V2SkillEditorDeleteData": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Identifier of the removed tool." - }, - "serverId": { - "type": "string", - "description": "Server the tool was removed from." - }, - "workflowId": { + "email": { "type": "string", - "description": "Workflow that is no longer published." + "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." }, - "deleted": { + "revoked": { "type": "boolean", "const": true, - "description": "Whether the tool was removed." - } - }, - "required": ["id", "serverId", "workflowId", "deleted"], - "additionalProperties": false, - "title": "Unpublish workflow MCP tool result", - "description": "Tool removal acknowledgement." - }, - "UndeployWorkflowMcpToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/UndeployWorkflowMcpToolResult" + "description": "Whether the explicit editor grant was revoked." } }, - "required": ["data"], + "required": ["email", "revoked"], "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 - } - } - ] + "title": "Revoke skill editor data", + "description": "Skill editor revocation acknowledgement." }, - "UpdateCredentialResponse": { + "RevokeSkillEditorResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "$ref": "#/components/schemas/V2SkillEditorDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Update credential response", - "description": "Updated credential metadata without secret material.", + "title": "Revoke skill editor response", + "description": "Acknowledgement that the explicit editor grant was revoked.", "examples": [ { "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "email": "jane@example.com", + "revoked": true } } ] }, - "UpdateCredentialRequest": { + "V2CustomTool": { "type": "object", "properties": { - "displayName": { - "description": "New name shown for the credential in Sim.", - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "description": { - "description": "New credential description. Send null to clear the stored one.", - "anyOf": [ - { - "type": "string", - "maxLength": 500 - }, - { - "type": "null" - } - ] - }, - "serviceAccountJson": { - "description": "Write-only Google service-account JSON key.", - "writeOnly": true, + "id": { "type": "string", - "minLength": 1, - "maxLength": 65536 + "description": "Unique custom tool identifier." }, - "apiToken": { - "description": "Write-only provider API token.", - "writeOnly": true, + "title": { "type": "string", - "minLength": 1, - "maxLength": 8192 + "description": "Display title, unique within the workspace." }, - "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 + "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." }, - "dataCenter": { - "description": "Provider data center.", + "code": { "type": "string", - "minLength": 1, - "maxLength": 32 + "description": "Tool implementation executed in the sandboxed function runtime." }, - "authMethod": { - "description": "Provider authentication method.", + "createdAt": { "type": "string", - "minLength": 1, - "maxLength": 64 + "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." }, - "privateKey": { - "description": "Write-only PEM private key.", - "writeOnly": true, + "updatedAt": { "type": "string", - "minLength": 1, - "maxLength": 8192 + "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." }, - "username": { - "description": "Provider run-as username.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "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": "Update credential request", - "description": "Replacement display metadata and the write-only fields declared by provider discovery.", + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", "examples": [ { - "clientSecret": "YOUR_ROTATED_CLIENT_SECRET" + "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 } ] }, - "V2BlockSummary": { + "CreateCustomToolResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Block type identifier, used as a workflow block’s `type`." - }, - "name": { + "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", - "description": "Display name." + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the custom tool." }, - "description": { + "title": { "type": "string", - "description": "One-line summary of what the block does." + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." }, - "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.", + "schema": { "type": "object", "properties": { - "status": { + "type": { "type": "string", - "enum": ["legacy", "deprecated"], - "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." + "const": "function", + "description": "Function declaration discriminator." }, - "replacedBy": { - "description": "Block type to migrate to, when one exists.", - "type": "string" + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." } }, - "required": ["status"], - "additionalProperties": false - }, - "docsLink": { - "description": "Sim documentation page for the integration.", - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." }, - "description": "Catalog tags, e.g. `messaging`, `version-control`." + "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": [ - "id", - "name", - "description", - "category", - "source", - "triggerAllowed", - "triggerCapable", - "triggerIds", - "toolIds", - "operationIds", - "preview", - "tags" - ], + "required": ["workspaceId", "title", "schema", "code"], "additionalProperties": false, - "title": "Block summary", - "description": "List view of a block: what it is and what it references, by id." + "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 }" + } + ] }, - "ListBlocksResponse": { + "GetCustomToolResponse": { "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." + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List blocks response", - "description": "Blocks available in the workspace.", + "title": "Get custom tool response", + "description": "One custom tool.", "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 + "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" + } } ] }, - "V2BlockField": { + "UpdateCustomToolResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Field identifier, and the key its value is stored under." - }, - "type": { - "type": "string", - "description": "Editor control the field renders as, e.g. `short-input`." - }, - "title": { - "description": "Human-readable label.", - "type": "string" - }, - "required": { - "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", - "type": "boolean" - }, - "requiredWhen": { - "description": "Condition under which the field is required.", - "$ref": "#/components/schemas/V2CatalogCondition" - }, - "description": { - "description": "Authored explanation of the field.", - "type": "string" - }, - "placeholder": { - "description": "Placeholder shown in the editor.", - "type": "string" - }, - "mode": { - "description": "Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.", - "type": "string" - }, - "hidden": { - "description": "Whether the field is hidden in the editor.", - "type": "boolean" - }, - "condition": { - "description": "Condition under which the field applies at all.", - "$ref": "#/components/schemas/V2CatalogCondition" - }, - "options": { - "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time.", - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Value stored when this option is selected." - }, - "label": { - "description": "Human-readable option label.", - "type": "string" - }, - "hasIcon": { - "description": "Whether the option renders with an icon. The icon itself is not published.", - "type": "boolean" + "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"] + } } }, - "required": ["id"], - "additionalProperties": false - } - }, - "min": { - "description": "Minimum accepted numeric value.", - "type": "number" - }, - "max": { - "description": "Maximum accepted numeric value.", - "type": "number" - }, - "step": { - "description": "Increment for numeric controls.", - "type": "number" - }, - "integer": { - "description": "Whether the numeric value must be a whole number.", - "type": "boolean" - }, - "rows": { - "description": "Visible row count for multi-line text.", - "type": "number" - }, - "password": { - "description": "Whether the stored value is masked in the editor.", - "type": "boolean" - }, - "multiSelect": { - "description": "Whether more than one option may be selected.", - "type": "boolean" - }, - "language": { - "description": "Language of a code field.", - "type": "string" - }, - "generationType": { - "description": "Kind of content AI assistance generates here.", - "type": "string" - }, - "serviceId": { - "description": "OAuth service this credential field authenticates.", - "type": "string" - }, - "requiredScopes": { - "description": "OAuth scopes the credential selected here must carry.", - "type": "array", - "items": { - "type": "string" + "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." }, - "mimeType": { - "description": "MIME type filter applied to a file picker.", - "type": "string" - }, - "acceptedTypes": { - "description": "Accepted file extensions for an upload field.", - "type": "string" - }, - "multiple": { - "description": "Whether more than one file may be supplied.", - "type": "boolean" - }, - "maxSize": { - "description": "Maximum upload size in megabytes.", - "type": "number" - }, - "connectionDroppable": { - "description": "Whether another block’s output can be dropped onto this field.", - "type": "boolean" - }, - "columns": { - "description": "Column headings for a table field.", - "type": "array", - "items": { - "type": "string" - } + "title": { + "description": "New display title for the tool.", + "type": "string", + "minLength": 1, + "maxLength": 200 }, - "dependsOn": { - "description": "Sibling fields this field is cleared by when they change.", - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } + "schema": { + "description": "Replacement function declaration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." }, - { + "function": { "type": "object", "properties": { - "all": { - "description": "Every listed field must hold a value.", - "type": "array", - "items": { - "type": "string" - } + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." }, - "any": { - "description": "At least one listed field must hold a value.", - "type": "array", - "items": { - "type": "string" - } + "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." } }, - "additionalProperties": false + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." } - ] - }, - "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" + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + } }, - "defaultValue": { - "description": "Value used when the field is left unset.", - "anyOf": [ - { - "type": "string" - }, - { - "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." - } - } - ] - }, - "hasComputedDefault": { - "description": "Whether the field derives its value from the block’s other values. The deriving function is not published.", - "type": "boolean" + "code": { + "description": "Replacement tool implementation.", + "type": "string", + "maxLength": 100000 } }, - "required": ["id", "type"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Block field", - "description": "One configuration field on a block." + "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 }" + } + ] }, - "V2CatalogCondition": { + "V2CustomToolDeleteData": { "type": "object", "properties": { - "field": { + "id": { "type": "string", - "description": "Sibling field id whose value decides this condition." - }, - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "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" + "description": "Identifier of the deleted custom tool." }, - "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 + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the custom tool was deleted." } }, - "required": ["field", "value"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "Catalog condition", - "description": "When a configuration field applies, expressed against a sibling field." + "title": "Delete custom tool data", + "description": "Custom tool deletion acknowledgement." }, - "V2OperationInput": { + "DeleteCustomToolResponse": { "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/V2CustomToolDeleteData" } }, - "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": "Delete custom tool response", + "description": "Acknowledgement that the custom tool was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] }, - "V2ToolOutput": { + "V2Sandbox": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "description": "Value type of the output field." + "description": "Unique sandbox identifier." }, - "description": { - "description": "What the field holds.", - "type": "string" + "name": { + "type": "string", + "description": "Display name, unique within the workspace." }, - "optional": { - "description": "Whether the field may be absent.", - "type": "boolean" + "language": { + "type": "string", + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." }, - "nullable": { - "description": "Whether the field may be null.", - "type": "boolean" + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Package specifiers installed into the sandbox, one per entry." }, - "properties": { - "description": "Members of an object-typed output, keyed by field name.", - "type": "object", - "propertyNames": { + "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" }, - "additionalProperties": { - "description": "Nested output field, in this same shape." - } + "description": "Debian packages installed into the sandbox, one per entry." }, - "items": { - "description": "Element shape of an array-typed output.", - "type": "object", - "properties": { - "type": { + "buildStatus": { + "anyOf": [ + { "type": "string", - "description": "Element value type." + "enum": ["pending", "building", "ready", "failed"] }, - "description": { - "description": "What an element holds.", + { + "type": "null" + } + ], + "description": "Image build state. `null` when the deployment installs dependencies at run time and has nothing to build." + }, + "errorCode": { + "anyOf": [ + { "type": "string" }, - "properties": { - "description": "Members of an object-typed element, keyed by field name.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Nested output field, in this same shape." - } + { + "type": "null" } - }, - "required": ["type"], - "additionalProperties": false + ], + "description": "Classified build failure code, or `null`." }, - "fileConfig": { - "description": "File metadata for a file-typed output.", - "type": "object", - "properties": { - "mimeType": { - "description": "MIME type of the produced file.", + "errorMessage": { + "anyOf": [ + { "type": "string" }, - "extension": { - "description": "File extension of the produced file.", - "type": "string" + { + "type": "null" } - }, - "additionalProperties": false - } - }, - "required": ["type"], - "additionalProperties": false, - "title": "Tool output", - "description": "One declared output field of a built-in tool." - }, - "V2ToolDetail": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Registered tool identifier, including its version suffix." - }, - "name": { - "type": "string", - "description": "Display name." - }, - "description": { - "type": "string", - "description": "What the tool does." - }, - "version": { - "description": "Tool version.", - "type": "string" - }, - "hostedApiKey": { - "type": "string", - "enum": ["always", "conditional", "none"], - "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + ], + "description": "Human-readable build failure summary, or `null`." }, - "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." + "errorDetail": { + "anyOf": [ + { + "type": "string" }, - "provider": { + { + "type": "null" + } + ], + "description": "Tail of the installer log for a failed build, or `null`." + }, + "builtAt": { + "anyOf": [ + { "type": "string", - "description": "OAuth service the credential must authenticate." + "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))$" }, - "requiredScopes": { - "description": "Scopes the credential must carry.", - "type": "array", - "items": { - "type": "string" - } + { + "type": "null" } - }, - "required": ["required", "provider"], - "additionalProperties": false + ], + "description": "ISO 8601 timestamp when the current image finished building, or `null`." }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/V2ToolParam" - }, - "description": "Parameters the tool accepts." + "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." }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/V2ToolOutput" - }, - "description": "Fields the tool produces." + "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", "description", "hostedApiKey", "params", "outputs"], + "required": [ + "id", + "name", + "language", + "dependencies", + "cliTools", + "systemPackages", + "buildStatus", + "errorCode", + "errorMessage", + "errorDetail", + "builtAt", + "createdAt", + "updatedAt" + ], "additionalProperties": false, - "title": "Tool", - "description": "A built-in tool with its declared parameters and outputs." + "title": "Sandbox", + "description": "A workspace sandbox: a reusable dependency set that Function blocks execute against." }, - "V2ToolParam": { + "ListSandboxesResponse": { "type": "object", "properties": { - "type": { - "type": "string", - "description": "Parameter value type." - }, - "required": { - "description": "Whether the parameter must be supplied.", - "type": "boolean" - }, - "visibility": { - "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", - "type": "string" - }, - "description": { - "description": "What the parameter means.", - "type": "string" - }, - "default": { - "description": "Value used when the parameter is omitted." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Sandbox" + }, + "description": "Items in the current page." }, - "items": { - "description": "JSON-Schema-shaped constraints for structured params." + "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": ["type"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Tool parameter", - "description": "One declared parameter of a built-in tool." + "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 + } + ] }, - "V2BlockDetail": { + "CreateSandboxResponse": { "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." - }, - "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": { + "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", - "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" + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the sandbox." }, - "triggerAllowed": { - "type": "boolean", - "description": "Whether the block declares itself usable as a trigger." + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Display name, unique within the workspace; 1 to 64 characters." }, - "triggerCapable": { - "type": "boolean", - "description": "Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields." + "language": { + "type": "string", + "enum": ["javascript", "python"], + "description": "Dependency ecosystem: `javascript` installs from npm, `python` from PyPI." }, - "triggerIds": { + "dependencies": { + "default": [], + "description": "Package specifiers installed into the sandbox, one per entry.", + "maxItems": 1000, "type": "array", "items": { - "type": "string" - }, - "description": "Identifiers of the triggers this block supports." + "type": "string", + "maxLength": 2000 + } }, - "toolIds": { + "cliTools": { + "default": [], + "description": "Pinned managed CLI ids installed into the sandbox, at most 10, no duplicates.", + "maxItems": 10, "type": "array", "items": { - "type": "string" - }, - "description": "Built-in tools this block can run. Read a tool by its id for the full definition." + "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" + ] + } }, - "operationIds": { + "systemPackages": { + "default": [], + "description": "Debian packages installed into the sandbox, one per entry.", + "maxItems": 1000, "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." + "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." }, - "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 + "name": { + "description": "New display name, unique within the workspace; 1 to 64 characters.", + "type": "string", + "minLength": 1, + "maxLength": 64 }, - "docsLink": { - "description": "Sim documentation page for the integration.", - "type": "string" + "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"] }, - "tags": { + "dependencies": { + "description": "Replacement package list; replaces the whole list.", + "maxItems": 1000, "type": "array", "items": { - "type": "string" - }, - "description": "Catalog tags, e.g. `messaging`, `version-control`." - }, - "bestPractices": { - "description": "Authored guidance on using the block correctly.", - "type": "string" + "type": "string", + "maxLength": 2000 + } }, - "inputSchema": { + "cliTools": { + "description": "Replacement managed CLI list; replaces the whole list.", + "maxItems": 10, "type": "array", "items": { - "$ref": "#/components/schemas/V2BlockField" - }, - "description": "Configuration fields that apply regardless of the selected operation." + "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" + ] + } }, - "operationInputSchema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2BlockField" + "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": "Configuration fields keyed by the operation that reveals them." + ], + "description": "Optional credential description." }, - "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." - } + "providerId": { + "anyOf": [ + { + "type": "string" }, - "required": ["type"], - "additionalProperties": false - }, - "description": "Block-level input definitions, keyed by parameter name." + { + "type": "null" + } + ], + "description": "Integration provider authenticated by this credential." }, - "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." - } + "accountId": { + "anyOf": [ + { + "type": "string" }, - "required": ["inputs", "outputs", "inputSchema"], - "additionalProperties": false - }, - "description": "Operations the block exposes, keyed by operation id." + { + "type": "null" + } + ], + "description": "Linked account identifier for OAuth credentials." }, - "tools": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2ToolDetail" - }, - "description": "Every built-in tool the block can run, with parameters and outputs." + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account payload is stored. Its contents are never returned." }, - "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." + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the credential." }, - "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." + "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", - "name", + "type", + "displayName", "description", - "category", - "source", - "triggerAllowed", - "triggerCapable", - "triggerIds", - "toolIds", - "operationIds", - "preview", - "tags", - "inputSchema", - "operationInputSchema", - "inputDefinitions", - "operations", - "tools", - "triggers", - "outputs" + "providerId", + "accountId", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" ], "additionalProperties": false, - "title": "Block", - "description": "A block with its configuration fields, operations, tools, and triggers." + "title": "Credential", + "description": "Public authenticated-connection metadata without secret material." }, - "GetBlockResponse": { + "ListCredentialsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2BlockDetail" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Credential" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Get block response", - "description": "One block with its fields, operations, tools, and triggers.", + "title": "List credentials response", + "description": "Credential metadata visible to the caller.", "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 - } - ] + "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." }, - "inputDefinitions": { - "channel": { - "type": "string", - "description": "Channel to post into." - } + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." }, - "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": { + "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", - "description": "Message timestamp." + "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." } }, - "inputSchema": [ - { - "id": "text", - "type": "long-input", - "title": "Message", - "required": true - } - ] - } + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." }, - "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": { + "fields": { + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", - "required": true, - "description": "Message body." - } - }, - "outputs": { - "ts": { + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { "type": "string", - "description": "Message timestamp." - } - } - } - ], - "triggers": [ - { - "id": "slack_webhook", - "outputs": { - "text": { + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { "type": "string", - "description": "Message text." + "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 } }, - "configFields": { - "channels": { - "type": "short-input", - "required": false, - "title": "Channels" - } - } - } - ], - "outputs": { - "ts": { - "type": "string", - "description": "Message timestamp." - } + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Write-only setup fields required before starting this OAuth flow." } - } - } - ] - }, - "V2ToolSummary": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Registered tool identifier, including its version suffix." - }, - "name": { - "type": "string", - "description": "Display name." - }, - "description": { - "type": "string", - "description": "What the tool does." - }, - "version": { - "description": "Tool version.", - "type": "string" - }, - "hostedApiKey": { - "type": "string", - "enum": ["always", "conditional", "none"], - "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions", + "fields" + ], + "additionalProperties": false }, - "oauth": { - "description": "OAuth requirement, when the tool has one.", + { "type": "object", "properties": { - "required": { - "type": "boolean", - "description": "Whether the tool cannot run without an OAuth credential." + "type": { + "type": "string", + "const": "service_account", + "description": "Direct service-account credential method." }, - "provider": { + "serviceId": { "type": "string", - "description": "OAuth service the credential must authenticate." + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." }, - "requiredScopes": { - "description": "Scopes the credential must carry.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["required", "provider"], - "additionalProperties": false - } - }, - "required": ["id", "name", "description", "hostedApiKey"], - "additionalProperties": false, - "title": "Tool summary", - "description": "List view of a built-in tool: identity, auth, and key hosting." - }, - "ListToolsResponse": { - "type": "object", - "properties": { + "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/V2ToolSummary" + "$ref": "#/components/schemas/V2CredentialProvider" }, "description": "Items in the current page." }, @@ -12804,426 +12252,512 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List tools response", - "description": "Built-in tools available in the workspace.", + "title": "List credential providers response", + "description": "OAuth and service-account connection methods.", "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"] - } + "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 } ] }, - "GetToolResponse": { + "CreateServiceAccountCredentialResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2ToolDetail" + "$ref": "#/components/schemas/V2Credential" } }, "required": ["data"], "additionalProperties": false, - "title": "Get tool response", - "description": "One built-in tool with its parameters and outputs.", + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", "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." - } - } + "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" } } ] }, - "V2ToolExecution": { + "CreateServiceAccountCredentialRequest": { "type": "object", "properties": { - "toolId": { + "workspaceId": { "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." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." }, - "status": { + "type": { "type": "string", - "enum": ["succeeded", "failed"], - "description": "Whether the tool reported success. A failed tool call is still a 200." + "const": "service_account", + "description": "Service-account credential discriminator." }, - "output": { - "description": "Whatever the tool produced, shaped by its declared outputs." + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." }, - "error": { - "anyOf": [ - { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Why the tool call did not succeed." - } - }, - "required": ["message"], - "additionalProperties": false - }, - { - "type": "null" - } - ], - "description": "Populated only when `status` is `failed`." + "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": ["toolId", "status", "output", "error"], + "required": ["workspaceId", "type", "providerId", "credentials"], "additionalProperties": false, - "title": "Tool execution", - "description": "The result of running one built-in tool." + "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." }, - "ExecuteToolResponse": { + "V2CredentialConnectionAuthorization": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2ToolExecution" + "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": ["data"], + "required": ["authorizationUrl", "expiresAt"], "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 - } - } - ] + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." }, - "ExecuteToolRequest": { + "CreateCredentialConnectionResponse": { "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.", - "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 + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" } }, - "required": ["workspaceId"], + "required": ["data"], "additionalProperties": false, - "title": "Run tool request", - "description": "Workspace, arguments, and the credential to authenticate with.", + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "input": { - "channel": "C0123456789", - "text": "Deploy finished." - }, - "credentialId": "cred_01J8ZK3QW4M6X2R9T7B5C0V2" + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } } ] }, - "V2ConnectorType": { - "type": "object", - "properties": { - "connectorType": { - "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": [ + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "anyOf": [ { "type": "object", "properties": { - "mode": { + "workspaceId": { "type": "string", - "const": "oauth", - "description": "Authenticates with an OAuth credential." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." }, - "provider": { + "displayName": { "type": "string", - "description": "OAuth service the credential must authenticate." + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." }, - "requiredScopes": { - "description": "Scopes the credential must carry.", - "type": "array", - "items": { - "type": "string" - } + "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": ["mode", "provider"], + "required": ["workspaceId", "displayName", "providerId", "oauthClientConfig"], "additionalProperties": false }, { "type": "object", "properties": { - "mode": { + "workspaceId": { "type": "string", - "const": "apiKey", - "description": "Authenticates with a stored API key." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." }, - "label": { - "description": "Label shown above the key field.", - "type": "string" - }, - "placeholder": { - "description": "Placeholder shown in the key field.", - "type": "string" + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." }, - "optional": { - "type": "boolean", - "description": "Whether the key may be left blank, for a source reachable without authentication." + "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": ["mode", "optional"], + "required": ["workspaceId", "displayName", "providerId"], "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`." - }, - "supportsIncrementalSync": { - "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." - } + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." }, - "required": ["id", "displayName", "fieldType"], - "additionalProperties": false + "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 + } }, - "description": "Tags this connector writes onto the documents it syncs." + "required": ["workspaceId", "credentialId"], + "additionalProperties": false } - }, - "required": [ - "connectorType", - "name", - "description", - "version", - "auth", - "configFields", - "supportsIncrementalSync", - "tagDefinitions" ], - "additionalProperties": false, - "title": "Connector type", - "description": "A knowledge-base connector type and the configuration it accepts." + "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." }, - "V2ConnectorConfigField": { + "V2CredentialDeleteData": { "type": "object", "properties": { "id": { "type": "string", - "description": "Field identifier." + "minLength": 1, + "description": "Disconnected credential identifier." }, - "title": { + "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", - "description": "Human-readable label." + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." }, - "type": { + "scope": { "type": "string", - "enum": ["short-input", "dropdown", "selector"], - "description": "Control the field renders as. A `selector` fetches its options from the connected account." + "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." }, - "placeholder": { - "description": "Placeholder shown in the editor.", - "type": "string" + "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." }, - "required": { - "description": "Whether a value must be supplied.", - "type": "boolean" + "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." }, - "description": { - "description": "Authored explanation of the field.", - "type": "string" + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the secret." }, - "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" - }, - "mimeType": { - "description": "MIME type filter applied to the picker.", - "type": "string" - }, - "dependsOn": { - "description": "Sibling fields this field is cleared by when they change.", - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object", - "properties": { - "all": { - "description": "Every listed field must hold a value.", - "type": "array", - "items": { - "type": "string" - } - }, - "any": { - "description": "At least one listed field must hold a value.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - } - ] + "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." }, - "mode": { - "description": "Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.", + "updatedAt": { "type": "string", - "enum": ["basic", "advanced"] + "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." }, - "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`.", + "value": { + "description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.", "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"], + "required": [ + "name", + "scope", + "description", + "unredacted", + "role", + "createdAt", + "updatedAt" + ], "additionalProperties": false, - "title": "Connector config field", - "description": "One field of a knowledge-base connector’s source configuration." + "title": "Secret metadata with visible value", + "description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)." }, - "ListConnectorTypesResponse": { + "ListSecretsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2ConnectorType" + "$ref": "#/components/schemas/V2SecretWithValue" }, "description": "Items in the current page." }, @@ -13236,74 +12770,54 @@ "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 secrets response", + "description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.", "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" - } - ] + "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 } ] }, - "V2PermissionGroup": { + "V2Secret": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Permission group identifier." - }, - "organizationId": { + "name": { "type": "string", - "description": "Organization that owns the group." + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." }, - "name": { + "scope": { "type": "string", - "description": "Group name, unique within the organization." + "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": [ @@ -13314,119 +12828,3872 @@ "type": "null" } ], - "description": "Optional description of the group." + "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." }, - "config": { - "type": "object", - "properties": { - "allowedIntegrations": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." - }, - "allowedModelProviders": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." - }, - "deniedModels": { - "default": [], - "type": "array", - "items": { - "type": "string" - }, - "description": "Models listed in this list are blocked." - }, - "deniedTools": { - "default": [], - "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." + "unredacted": { + "type": "boolean", + "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the secret." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was last updated." + } + }, + "required": [ + "name", + "scope", + "description", + "unredacted", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Secret metadata", + "description": "Public secret metadata without the stored secret value." + }, + "SetSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Secret" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Set secret response", + "description": "Metadata for the created or replaced secret without its value.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "description": "Production billing key — rotate quarterly.", + "unredacted": false, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "SetSecretRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "value": { + "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 }, - "disablePublicApi": { - "type": "boolean", - "description": "Revoke public API access. Calls to a deployed workflow are refused." + { + "type": "null" + } + ] + }, + "unredacted": { + "description": "Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched.", + "type": "boolean" + } + }, + "required": ["workspaceId", "scope"], + "additionalProperties": false, + "title": "Set secret request", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "value": "YOUR_SECRET_VALUE" + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "unredacted": false + } + ] + }, + "V2SecretDeleteData": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the secret was deleted." + } + }, + "required": ["name", "scope", "deleted"], + "additionalProperties": false, + "title": "Delete secret data", + "description": "Secret deletion acknowledgement without the stored value." + }, + "DeleteSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SecretDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete secret response", + "description": "Acknowledgement that the secret was deleted.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "deleted": true + } + } + ] + }, + "V2Meta": { + "type": "object", + "properties": { + "v2Enabled": { + "type": "boolean", + "description": "Whether this API version is available. This is true when the endpoint is served." + }, + "keyType": { + "type": "string", + "enum": ["personal", "workspace", "oauth_access_token"], + "description": "Whether the calling credential is a personal API key carrying the full authority of its owner across their workspaces, a key scoped to one workspace, or an OAuth access token acting for its user within the scopes it was granted." + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "disablePublicFileSharing": { - "type": "boolean", - "description": "Revoke public file sharing. Members cannot create a share link." + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the calling credential expires, or null when it does not." + } + }, + "required": ["v2Enabled", "keyType", "expiresAt"], + "additionalProperties": false, + "title": "API capabilities", + "description": "API availability and lifecycle facts about the calling credential." + }, + "GetApiMetaResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Meta" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "API capabilities response", + "description": "API availability, credential type, and expiry for the caller.", + "examples": [ + { + "data": { + "v2Enabled": true, + "keyType": "personal", + "expiresAt": null + } + } + ] + }, + "WorkflowMcpServerListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-MCP server identifier." + }, + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "allowedFileShareAuthTypes": { - "anyOf": [ - { - "type": "array", + { + "type": "null" + } + ], + "description": "Optional server description, or null when unset." + }, + "isPublic": { + "type": "boolean", + "description": "Whether the server answers MCP clients without a Sim API key." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to. Published here so callers never build it.", + "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was last modified.", + "format": "date-time" + }, + "toolCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of workflows published as tools." + }, + "toolNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names this server publishes, alphabetically ordered." + } + }, + "required": [ + "id", + "name", + "description", + "isPublic", + "mcpServerUrl", + "createdAt", + "updatedAt", + "toolCount", + "toolNames" + ], + "additionalProperties": false, + "title": "Workflow MCP server list item", + "description": "A published MCP server together with the tool names it exposes." + }, + "ListWorkflowMcpServersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowMcpServerListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + }, + "toolNamesTruncated": { + "type": "boolean", + "description": "Whether the page-wide tool-name limit left some inventories incomplete. Use List Workflow MCP Tools for one server and check its `truncated` flag before treating the inventory as complete. `nextCursor` paginates servers, not tool names." + } + }, + "required": ["data", "nextCursor", "toolNamesTruncated"], + "additionalProperties": false, + "title": "List workflow MCP servers response", + "description": "A cursor-paginated page of published MCP servers.", + "examples": [ + { + "data": [ + { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z", + "toolCount": 1, + "toolNames": ["triage_ticket"] + } + ], + "nextCursor": null, + "toolNamesTruncated": false + } + ] + }, + "WorkflowMcpServer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow-MCP server identifier." + }, + "name": { + "type": "string", + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional server description, or null when unset." + }, + "isPublic": { + "type": "boolean", + "description": "Whether the server answers MCP clients without a Sim API key." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to. Published here so callers never build it.", + "examples": ["https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2"] + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the server was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "isPublic", + "mcpServerUrl", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP server", + "description": "A workspace-published MCP server exposing deployed workflows as tools." + }, + "CreateWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workflow MCP server response", + "description": "The published MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "CreateWorkflowMcpServerRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to publish the server." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "description": "Optional server description.", + "type": "string", + "maxLength": 2000 + }, + "isPublic": { + "description": "Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL.", + "default": false, + "type": "boolean" + }, + "workflowIds": { + "description": "Deployed workflows to publish as tools on the new server.", + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create workflow MCP server request", + "description": "A new workspace-published MCP server and the workflows it exposes.", + "examples": [ + { + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "name": "Support agents", + "workflowIds": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + ] + }, + "GetWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get workflow MCP server response", + "description": "A single published MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": false, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "WorkflowMcpToolListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tool identifier." + }, + "serverId": { + "type": "string", + "description": "Server that publishes this tool." + }, + "workflowId": { + "type": "string", + "description": "Workflow this tool executes." + }, + "toolName": { + "type": "string", + "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + }, + "toolDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description shown to MCP clients." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to." + }, + "apiEndpoint": { + "type": "string", + "description": "Sim execution endpoint this tool calls through." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP tool list item", + "description": "A tool a server publishes, as returned by a read." + }, + "ListWorkflowMcpToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowMcpToolListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + }, + "truncated": { + "type": "boolean", + "description": "Whether the tool limit left this inventory incomplete. The list is unpaginated and `nextCursor` remains null even when truncated. Do not treat a truncated inventory as the complete set of published tools." + } + }, + "required": ["data", "nextCursor", "truncated"], + "additionalProperties": false, + "title": "List workflow MCP tools response", + "description": "The tools a published MCP server exposes.", + "examples": [ + { + "data": [ + { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket", + "toolDescription": "Execute Ticket triage workflow", + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + ], + "nextCursor": null, + "truncated": false + } + ] + }, + "UpdateWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow MCP server response", + "description": "The updated MCP server.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "name": "Support agents", + "description": "Ticket triage and escalation workflows.", + "isPublic": true, + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "UpdateWorkflowMcpServerRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Server display name, shown to connecting MCP clients." + }, + "description": { + "description": "New server description, or null to clear it.", + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ] + }, + "isPublic": { + "description": "Whether the server answers MCP clients without a Sim API key.", + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "Update workflow MCP server request", + "description": "Merge-patch body for a published MCP server.", + "examples": [ + { + "isPublic": true + } + ] + }, + "DeleteWorkflowMcpServerResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the unpublished server." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the server was unpublished." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete workflow MCP server result", + "description": "Unpublish acknowledgement." + }, + "DeleteWorkflowMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeleteWorkflowMcpServerResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete workflow MCP server response", + "description": "Acknowledgement that the MCP server was unpublished.", + "examples": [ + { + "data": { + "id": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "deleted": true + } + } + ] + }, + "WorkflowMcpTool": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique tool identifier." + }, + "serverId": { + "type": "string", + "description": "Server that publishes this tool." + }, + "workflowId": { + "type": "string", + "description": "Workflow this tool executes." + }, + "toolName": { + "type": "string", + "description": "Name an MCP client calls. Derived from the supplied name or the workflow name, normalized to the MCP tool-name grammar." + }, + "toolDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description shown to MCP clients." + }, + "mcpServerUrl": { + "type": "string", + "description": "Endpoint an MCP client connects to." + }, + "apiEndpoint": { + "type": "string", + "description": "Sim execution endpoint this tool calls through." + }, + "updated": { + "type": "boolean", + "description": "False when the workflow was newly published on this server, true when an existing tool was replaced. Publishing is idempotent per workflow, so a repeat call answers 200 with true rather than conflicting." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the tool was last modified.", + "format": "date-time" + } + }, + "required": [ + "id", + "serverId", + "workflowId", + "toolName", + "toolDescription", + "mcpServerUrl", + "apiEndpoint", + "updated", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow MCP tool", + "description": "A deployed workflow published as a tool on a workflow-MCP server." + }, + "DeployWorkflowMcpToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowMcpTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Publish workflow as MCP tool response", + "description": "The published tool.", + "examples": [ + { + "data": { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket", + "toolDescription": "Execute Ticket triage workflow", + "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", + "updated": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "DeployWorkflowMcpToolRequest": { + "type": "object", + "properties": { + "workflowId": { + "type": "string", + "minLength": 1, + "description": "Deployed workflow to publish. The workflow must already be deployed." + }, + "toolName": { + "description": "Name MCP clients call. Normalized to the MCP tool-name grammar, and derived from the workflow name when omitted.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "toolDescription": { + "description": "Description shown to MCP clients. Derived from the workflow name when omitted.", + "type": "string", + "maxLength": 2000 + }, + "parameterDescriptions": { + "description": "Per-field description overrides applied to the schema generated from the deployed workflow inputs. A name matching no input field is ignored.", + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Input field of the deployed workflow to describe." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2000, + "description": "Text MCP clients see for that field." + } + }, + "required": ["name", "description"], + "additionalProperties": false + } + } + }, + "required": ["workflowId"], + "additionalProperties": false, + "title": "Publish workflow as MCP tool request", + "description": "The workflow to publish and the tool metadata MCP clients see.", + "examples": [ + { + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "toolName": "triage_ticket" + } + ] + }, + "UndeployWorkflowMcpToolResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the removed tool." + }, + "serverId": { + "type": "string", + "description": "Server the tool was removed from." + }, + "workflowId": { + "type": "string", + "description": "Workflow that is no longer published." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the tool was removed." + } + }, + "required": ["id", "serverId", "workflowId", "deleted"], + "additionalProperties": false, + "title": "Unpublish workflow MCP tool result", + "description": "Tool removal acknowledgement." + }, + "UndeployWorkflowMcpToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UndeployWorkflowMcpToolResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Unpublish workflow MCP tool response", + "description": "Acknowledgement that the tool was removed.", + "examples": [ + { + "data": { + "id": "wfmcptool_01J8ZK3QW4M6X2R9T7B5C0V3", + "serverId": "wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true + } + } + ] + }, + "UpdateCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update credential response", + "description": "Updated credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateCredentialRequest": { + "type": "object", + "properties": { + "displayName": { + "description": "New name shown for the credential in Sim.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "New credential description. Send null to clear the stored one.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "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 + } + }, + "additionalProperties": false, + "title": "Update credential request", + "description": "Replacement display metadata and the write-only fields declared by provider discovery.", + "examples": [ + { + "clientSecret": "YOUR_ROTATED_CLIENT_SECRET" + } + ] + }, + "V2BlockSummary": { + "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." + }, + "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`." + } + }, + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags" + ], + "additionalProperties": false, + "title": "Block summary", + "description": "List view of a block: what it is and what it references, by id." + }, + "ListBlocksResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockSummary" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List blocks response", + "description": "Blocks available in the workspace.", + "examples": [ + { + "data": [ + { + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"] + } + ], + "nextCursor": null + } + ] + }, + "V2BlockField": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Field identifier, and the key its value is stored under." + }, + "type": { + "type": "string", + "description": "Editor control the field renders as, e.g. `short-input`." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "required": { + "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", + "type": "boolean" + }, + "requiredWhen": { + "description": "Condition under which the field is required.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "mode": { + "description": "Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.", + "type": "string" + }, + "hidden": { + "description": "Whether the field is hidden in the editor.", + "type": "boolean" + }, + "condition": { + "description": "Condition under which the field applies at all.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "options": { + "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "description": "Human-readable option label.", + "type": "string" + }, + "hasIcon": { + "description": "Whether the option renders with an icon. The icon itself is not published.", + "type": "boolean" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "min": { + "description": "Minimum accepted numeric value.", + "type": "number" + }, + "max": { + "description": "Maximum accepted numeric value.", + "type": "number" + }, + "step": { + "description": "Increment for numeric controls.", + "type": "number" + }, + "integer": { + "description": "Whether the numeric value must be a whole number.", + "type": "boolean" + }, + "rows": { + "description": "Visible row count for multi-line text.", + "type": "number" + }, + "password": { + "description": "Whether the stored value is masked in the editor.", + "type": "boolean" + }, + "multiSelect": { + "description": "Whether more than one option may be selected.", + "type": "boolean" + }, + "language": { + "description": "Language of a code field.", + "type": "string" + }, + "generationType": { + "description": "Kind of content AI assistance generates here.", + "type": "string" + }, + "serviceId": { + "description": "OAuth service this credential field authenticates.", + "type": "string" + }, + "requiredScopes": { + "description": "OAuth scopes the credential selected here must carry.", + "type": "array", + "items": { + "type": "string" + } + }, + "mimeType": { + "description": "MIME type filter applied to a file picker.", + "type": "string" + }, + "acceptedTypes": { + "description": "Accepted file extensions for an upload field.", + "type": "string" + }, + "multiple": { + "description": "Whether more than one file may be supplied.", + "type": "boolean" + }, + "maxSize": { + "description": "Maximum upload size in megabytes.", + "type": "number" + }, + "connectionDroppable": { + "description": "Whether another block’s output can be dropped onto this field.", + "type": "boolean" + }, + "columns": { + "description": "Column headings for a table field.", + "type": "array", + "items": { + "type": "string" + } + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + }, + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "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" + }, + { + "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." + } + } + ] + }, + "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": "number" + }, + { + "type": "boolean" + }, + { + "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." + }, + "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": "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." + } + }, + "required": ["type"], + "additionalProperties": false, + "title": "Operation input", + "description": "One value a block operation needs, from its tool or its block-level inputs." + }, + "V2ToolOutput": { + "type": "object", + "properties": { + "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" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." + } + }, + "items": { + "description": "Element shape of an array-typed output.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Element value type." + }, + "description": { + "description": "What an element holds.", + "type": "string" + }, + "properties": { + "description": "Members of an object-typed element, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." + } + } + }, + "required": ["type"], + "additionalProperties": false + }, + "fileConfig": { + "description": "File metadata for a file-typed output.", + "type": "object", + "properties": { + "mimeType": { + "description": "MIME type of the produced file.", + "type": "string" + }, + "extension": { + "description": "File extension of the produced file.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["type"], + "additionalProperties": false, + "title": "Tool output", + "description": "One declared output field of a built-in tool." + }, + "V2ToolDetail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Registered tool identifier, including its version suffix." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the tool does." + }, + "version": { + "description": "Tool version.", + "type": "string" + }, + "hostedApiKey": { + "type": "string", + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + }, + "oauth": { + "description": "OAuth requirement, when the tool has one.", + "type": "object", + "properties": { + "required": { + "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 + }, + "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": "Tool", + "description": "A built-in tool with its declared parameters and outputs." + }, + "V2ToolParam": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Parameter value type." + }, + "required": { + "description": "Whether the parameter must be supplied.", + "type": "boolean" + }, + "visibility": { + "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", + "type": "string" + }, + "description": { + "description": "What the parameter means.", + "type": "string" + }, + "default": { + "description": "Value used when the parameter is omitted." + }, + "items": { + "description": "JSON-Schema-shaped constraints for structured params." + } + }, + "required": ["type"], + "additionalProperties": false, + "title": "Tool parameter", + "description": "One declared parameter of a built-in tool." + }, + "V2BlockDetail": { + "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." + }, + "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" + }, + "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." + }, + "inputDefinitions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type: `string`, `number`, `boolean`, `json`, `array`, or `file`." + }, + "description": { + "description": "What the input means.", + "type": "string" + }, + "schema": { + "description": "JSON-Schema-shaped structure for object and array inputs." + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Block-level input definitions, keyed by parameter name." + }, + "operations": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "toolId": { + "description": "Built-in tool that performs this operation.", + "type": "string" + }, + "toolName": { + "description": "Display name of that tool.", + "type": "string" + }, + "description": { + "description": "What the operation does.", + "type": "string" + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2OperationInput" + }, + "description": "Values this operation needs, excluding the ones the block supplies from its own block-level inputs." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolOutput" + }, + "description": "Fields the operation produces." + }, + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that appear when this operation is selected." + } + }, + "required": ["inputs", "outputs", "inputSchema"], + "additionalProperties": false + }, + "description": "Operations the block exposes, keyed by operation id." + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolDetail" + }, + "description": "Every built-in tool the block can run, with parameters and outputs." + }, + "triggers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Trigger identifier." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type of the output." + }, + "description": { + "description": "What the output holds.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Top-level fields the trigger event delivers." + }, + "configFields": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Editor control the field renders as." + }, + "required": { + "type": "boolean", + "description": "Whether a value must be supplied." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "default": { + "description": "Value used when the field is left unset." + }, + "options": { + "description": "Selectable options.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "condition": { + "description": "Condition under which the field applies.", + "$ref": "#/components/schemas/V2CatalogCondition" + } + }, + "required": ["type", "required"], + "additionalProperties": false + }, + "description": "Fields that configure the trigger, keyed by field id." + } + }, + "required": ["id", "outputs", "configFields"], + "additionalProperties": false + }, + "description": "Triggers the block can run on." + }, + "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", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags", + "inputSchema", + "operationInputSchema", + "inputDefinitions", + "operations", + "tools", + "triggers", + "outputs" + ], + "additionalProperties": false, + "title": "Block", + "description": "A block with its configuration fields, operations, tools, and triggers." + }, + "GetBlockResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2BlockDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get block response", + "description": "One block with its fields, operations, tools, and triggers.", + "examples": [ + { + "data": { + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"], + "inputSchema": [ + { + "id": "operation", + "type": "dropdown", + "title": "Operation", + "required": true, + "options": [ + { + "id": "send", + "label": "Send message" + }, + { + "id": "read", + "label": "Read messages" + } + ] + } + ], + "operationInputSchema": { + "send": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] + }, + "inputDefinitions": { + "channel": { + "type": "string", + "description": "Channel to post into." + } + }, + "operations": { + "send": { + "toolId": "slack_message", + "toolName": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "inputs": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + }, + "inputSchema": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] + } + }, + "tools": [ + { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + }, + "params": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } + } + ], + "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." + } + } + } + } + ] + }, + "V2ToolSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Registered tool identifier, including its version suffix." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the tool does." + }, + "version": { + "description": "Tool version.", + "type": "string" + }, + "hostedApiKey": { + "type": "string", + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key on THIS deployment: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own). Self-hosted deployments supply no hosted keys, so every tool reports `none` there regardless of what it declares." + }, + "oauth": { + "description": "OAuth requirement, when the tool has one.", + "type": "object", + "properties": { + "required": { + "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", "name", "description", "hostedApiKey"], + "additionalProperties": false, + "title": "Tool summary", + "description": "List view of a built-in tool: identity, auth, and key hosting." + }, + "ListToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolSummary" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List tools response", + "description": "Built-in tools available in the workspace.", + "examples": [ + { + "data": [ + { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + } + } + ], + "nextCursor": null + } + ] + }, + "GetToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ToolDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get tool response", + "description": "One built-in tool with its parameters and outputs.", + "examples": [ + { + "data": { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + }, + "params": { + "channel": { + "type": "string", + "required": true, + "description": "Channel ID to post into." + }, + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } + } + } + ] + }, + "V2ToolExecution": { + "type": "object", + "properties": { + "toolId": { + "type": "string", + "description": "Tool that ran. An unversioned name resolves to the newest version visible in the workspace, so this can differ from the id in the path." + }, + "status": { + "type": "string", + "enum": ["succeeded", "failed"], + "description": "Whether the tool reported success. A failed tool call is still a 200." + }, + "output": { + "description": "Whatever the tool produced, shaped by its declared outputs." + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Why the tool call did not succeed." + } + }, + "required": ["message"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "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": 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.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "timeoutSeconds": { + "description": "How long to wait for the tool before abandoning the call.", + "type": "integer", + "minimum": 1, + "maximum": 300 + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Run tool request", + "description": "Workspace, arguments, and the credential to authenticate with.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "input": { + "channel": "C0123456789", + "text": "Deploy finished." + }, + "credentialId": "cred_01J8ZK3QW4M6X2R9T7B5C0V2" + } + ] + }, + "V2ConnectorType": { + "type": "object", + "properties": { + "connectorType": { + "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`." + }, + "supportsIncrementalSync": { + "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." + } + }, + "required": [ + "connectorType", + "name", + "description", + "version", + "auth", + "configFields", + "supportsIncrementalSync", + "tagDefinitions" + ], + "additionalProperties": false, + "title": "Connector type", + "description": "A knowledge-base connector type and the configuration it accepts." + }, + "V2ConnectorConfigField": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Field identifier." + }, + "title": { + "type": "string", + "description": "Human-readable label." + }, + "type": { + "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": { + "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" + }, + "mimeType": { + "description": "MIME type filter applied to the picker.", + "type": "string" + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + }, + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "mode": { + "description": "Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.", + "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" + } + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." + }, + "deniedModels": { + "default": [], + "type": "array", + "items": { + "type": "string" + }, + "description": "Models listed in this list are blocked." + }, + "deniedTools": { + "default": [], + "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": [ + "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" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Permission Groups response", + "description": "List Permission Groups result.", + "examples": [ + { + "data": [ + { + "id": "group-123", + "organizationId": "org-123", + "name": "Restricted", + "description": null, + "config": { + "allowedIntegrations": null, + "allowedModelProviders": null, + "deniedModels": [], + "deniedTools": [], + "hideTraceSpans": false, + "hideKnowledgeBaseTab": false, + "hideTablesTab": false, + "hideCopilot": false, + "hideIntegrationsTab": false, + "hideSecretsTab": false, + "hideApiKeysTab": false, + "hideInboxTab": false, + "hideFilesTab": false, + "disableMcpTools": false, + "disableCustomTools": false, + "disableSkills": false, + "disableInvitations": false, + "disablePublicApi": false, + "disablePublicFileSharing": false, + "allowedFileShareAuthTypes": null, + "hideDeployApi": false, + "hideDeployMcp": false, + "hideDeployChatbot": false, + "allowedChatDeployAuthTypes": null, + "disablePersonalApiKeys": false, + "disableLogExport": false, + "hideCostInfo": false, + "disableKnowledgeBaseCreation": false, + "disableKnowledgeBaseFileUpload": false, + "allowedKnowledgeConnectors": null, + "disableTableCreation": false, + "disableTableExport": false, + "disableBulkFileDownload": false, + "disablePersonalCredentials": false, + "disableWorkspaceCreation": false, + "hideOrgMemberDirectory": false, + "disableCliAccess": false, + "disableWebhookTriggers": false, + "disableToolAutoApproval": false, + "hideSandboxesTab": false, + "disableOAuthAppAccess": false, + "disableKnowledgeBaseExport": false + }, + "isDefault": false, + "membershipMode": "inherit", + "workspaceIds": ["workspace-123"], + "createdBy": "admin-123", + "createdAt": "2026-06-01T09:00:00.000Z", + "updatedAt": "2026-06-01T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreatePermissionGroupResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroup" + } + }, + "required": ["data"], + "additionalProperties": false, + "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" + } + } + ] + }, + "CreatePermissionGroupRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Group name, unique within the organization." + }, + "description": { + "description": "Optional group description.", + "type": "string", + "maxLength": 500 + }, + "config": { + "type": "object", + "properties": { + "allowedIntegrations": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." + }, + "allowedModelProviders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." + }, + "deniedModels": { + "type": "array", + "items": { + "type": "string" + }, + "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." + } + }, + "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": ["name"], + "additionalProperties": false, + "title": "Create Permission Group request", + "description": "Create Permission Group inputs.", + "examples": [ + { + "name": "Restricted", + "workspaceIds": ["workspace-123"] + } + ] + }, + "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": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Group name, unique within the organization." + }, + "description": { + "description": "Group description. Null or an empty string clears it; omission leaves it unchanged.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "config": { + "type": "object", + "properties": { + "allowedIntegrations": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." + }, + "allowedModelProviders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." + }, + "deniedModels": { + "type": "array", + "items": { + "type": "string" + }, + "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"] @@ -13535,122 +16802,927 @@ "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." + "hideSandboxesTab": { + "type": "boolean", + "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + }, + "disableOAuthAppAccess": { + "type": "boolean", + "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + }, + "disableKnowledgeBaseExport": { + "type": "boolean", + "description": "Prevent downloading a whole knowledge base as an archive." + } + }, + "additionalProperties": false, + "description": "Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list." + }, + "isDefault": { + "description": "Whether the group is the organization default. Only one group can be the default.", + "type": "boolean" + }, + "workspaceIds": { + "description": "Workspace identifiers for a non-default group. Required on creation; an empty update makes the group inactive.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false, + "title": "Update Permission Group request", + "description": "Update Permission Group inputs.", + "examples": [ + { + "description": "Restricted workspace access" + } + ] + }, + "V2PermissionGroupDeletion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Deleted permission group identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the group was permanently deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Permission group deletion", + "description": "Acknowledges permanent group deletion." + }, + "DeletePermissionGroupResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete Permission Group response", + "description": "Delete Permission Group result.", + "examples": [ + { + "data": { + "id": "group-123", + "deleted": true + } + } + ] + }, + "V2PermissionGroupMember": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Membership assignment identifier." + }, + "userId": { + "type": "string", + "description": "Organization member assigned to the group." + }, + "assignedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the member was assigned." + }, + "userName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member display name." + }, + "userEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member email address." + }, + "userImage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member avatar URL." + } + }, + "required": ["id", "userId", "assignedAt", "userName", "userEmail", "userImage"], + "additionalProperties": false, + "title": "Permission group member", + "description": "An explicit permission-group membership assignment." + }, + "ListPermissionGroupMembersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2PermissionGroupMember" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Permission Group Members response", + "description": "List Permission Group Members result.", + "examples": [ + { + "data": [ + { + "id": "assignment-123", + "userId": "user-123", + "assignedAt": "2026-06-01T09:00:00.000Z", + "userName": "Example Member", + "userEmail": "member@example.com", + "userImage": null + } + ], + "nextCursor": null + } + ] + }, + "V2PermissionGroupAssignment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Membership assignment identifier." + }, + "permissionGroupId": { + "type": "string", + "description": "Group receiving the member." + }, + "organizationId": { + "type": "string", + "description": "Organization that owns the group." + }, + "userId": { + "type": "string", + "description": "User assigned to the group." + }, + "assignedBy": { + "type": "string", + "description": "User who made the assignment." + }, + "assignedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the assignment was created." + } + }, + "required": [ + "id", + "permissionGroupId", + "organizationId", + "userId", + "assignedBy", + "assignedAt" + ], + "additionalProperties": false, + "title": "Permission group assignment", + "description": "The newly created membership assignment." + }, + "AddPermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupAssignment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Add Permission Group Member response", + "description": "Add Permission Group Member result.", + "examples": [ + { + "data": { + "id": "assignment-123", + "permissionGroupId": "group-123", + "organizationId": "org-123", + "userId": "user-123", + "assignedBy": "admin-123", + "assignedAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "AddPermissionGroupMemberRequest": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "minLength": 1, + "description": "Existing organization member to add." + } + }, + "required": ["userId"], + "additionalProperties": false, + "title": "Add Permission Group Member request", + "description": "Add Permission Group Member inputs.", + "examples": [ + { + "userId": "user-123" + } + ] + }, + "V2PermissionGroupMemberDeletion": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User whose membership assignment was removed." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the assignment was removed." + } + }, + "required": ["userId", "deleted"], + "additionalProperties": false, + "title": "Permission group member deletion", + "description": "Acknowledges membership removal." + }, + "RemovePermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupMemberDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Remove Permission Group Member response", + "description": "Remove Permission Group Member result.", + "examples": [ + { + "data": { + "userId": "user-123", + "deleted": true + } + } + ] + }, + "V2PermissionGroupBulkAdd": { + "type": "object", + "properties": { + "added": { + "type": "number", + "description": "Number of members added." + }, + "skipped": { + "type": "number", + "description": "Number of selected organization members already in the group." + } + }, + "required": ["added", "skipped"], + "additionalProperties": false, + "title": "Permission group bulk addition", + "description": "Counts of added and already assigned organization members." + }, + "BulkAddPermissionGroupMembersResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupBulkAdd" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Bulk Add Permission Group Members response", + "description": "Bulk Add Permission Group Members result.", + "examples": [ + { + "data": { + "added": 1, + "skipped": 0 + } + } + ] + }, + "BulkAddPermissionGroupMembersRequest": { + "type": "object", + "properties": { + "userIds": { + "description": "Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.", + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "addAllOrganizationMembers": { + "description": "Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.", + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "Bulk Add Permission Group Members request", + "description": "Bulk Add Permission Group Members inputs.", + "examples": [ + { + "userIds": ["user-123"] + } + ] + }, + "V2Organization": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Organization identifier." + }, + "name": { + "type": "string", + "description": "Organization display name." + }, + "slug": { + "type": "string", + "description": "Organization slug." + }, + "logo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Organization logo URL, or null when unset." + }, + "role": { + "type": "string", + "enum": ["owner", "admin", "member"], + "description": "The acting user’s role in this organization." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the organization was created." + } + }, + "required": ["id", "name", "slug", "logo", "role", "createdAt"], + "additionalProperties": false, + "title": "Organization", + "description": "An organization the acting user belongs to." + }, + "ListOrganizationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Organization" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "disableOAuthAppAccess": { - "type": "boolean", - "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Organizations response", + "description": "List Organizations result.", + "examples": [ + { + "data": [ + { + "id": "org-123", + "name": "Example Organization", + "slug": "example", + "logo": null, + "role": "admin", + "createdAt": "2026-06-01T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "GetOrganizationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Organization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Organization response", + "description": "Get Organization result.", + "examples": [ + { + "data": { + "id": "org-123", + "name": "Example Organization", + "slug": "example", + "logo": null, + "role": "admin", + "createdAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "V2OrganizationWorkspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Workspace identifier." + }, + "name": { + "type": "string", + "description": "Workspace display name." + } + }, + "required": ["id", "name"], + "additionalProperties": false, + "title": "Organization workspace", + "description": "A workspace owned by the organization." + }, + "ListOrganizationWorkspacesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2OrganizationWorkspace" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "disableKnowledgeBaseExport": { - "type": "boolean", - "description": "Prevent downloading a whole knowledge base as an archive." + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Organization Workspaces response", + "description": "List Organization Workspaces result.", + "examples": [ + { + "data": [ + { + "id": "workspace-123", + "name": "Engineering" } + ], + "nextCursor": null + } + ] + }, + "V2OrganizationMember": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier; use this identifier to update or remove the member." + }, + "name": { + "type": "string", + "description": "Member display name." + }, + "email": { + "type": "string", + "description": "Member email address." + }, + "role": { + "type": "string", + "enum": ["owner", "admin", "member"], + "description": "Organization role; separate from workspace permissions." + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the user joined the organization." + } + }, + "required": ["userId", "name", "email", "role", "joinedAt"], + "additionalProperties": false, + "title": "Organization member", + "description": "An organization membership identified by user ID." + }, + "ListOrganizationMembersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2OrganizationMember" }, - "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" + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "additionalProperties": false, - "description": "Resolved restrictions. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Organization Members response", + "description": "List Organization Members result.", + "examples": [ + { + "data": [ + { + "userId": "user-123", + "name": "Example Member", + "email": "member@example.com", + "role": "member", + "joinedAt": "2026-06-01T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "UpdateOrganizationMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationMember" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update Organization Member response", + "description": "Update Organization Member result.", + "examples": [ + { + "data": { + "userId": "user-123", + "name": "Example Member", + "email": "member@example.com", + "role": "admin", + "joinedAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "UpdateOrganizationMemberBody": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["member", "admin"], + "description": "New organization role. Ownership transfers use a separate operation." + } + }, + "required": ["role"], + "additionalProperties": false, + "title": "Update Organization Member body", + "description": "Update Organization Member input.", + "examples": [ + { + "role": "admin" + } + ] + }, + "V2OrganizationMemberDeletion": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User removed from the organization." }, - "isDefault": { + "deleted": { "type": "boolean", - "description": "Whether this is the organization default, which applies to everyone across all its workspaces regardless of member assignments." + "const": true, + "description": "Whether membership and organization workspace access were removed." + } + }, + "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": "Remove Organization Member response", + "description": "Remove Organization Member result.", + "examples": [ + { + "data": { + "userId": "user-123", + "deleted": true + } + } + ] + }, + "V2OrganizationInvitation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Invitation identifier." }, - "membershipMode": { + "organizationId": { "type": "string", - "description": "An empty inherit group governs everyone in its workspaces; an empty explicit group governs nobody." + "description": "Organization that owns the invitation." }, - "workspaceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Workspaces governed by a non-default group. Empty for the default group." + "email": { + "type": "string", + "description": "Email address of the invitee." }, - "createdBy": { + "role": { "type": "string", - "description": "User who created the group." + "enum": ["member", "admin"], + "description": "Organization role offered to an internal invitee." + }, + "kind": { + "type": "string", + "enum": ["organization", "workspace"], + "description": "Whether the invitation originated from organization or workspace administration." + }, + "membershipIntent": { + "type": "string", + "enum": ["internal", "external"], + "description": "Whether acceptance joins the organization or grants workspace access only." + }, + "status": { + "type": "string", + "enum": ["pending", "accepted", "rejected", "cancelled", "expired"], + "description": "Current invitation status; elapsed pending invitations are reported as expired." }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the group was created." + "description": "When the invitation was created." }, - "updatedAt": { + "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 group was last updated." + "description": "When the invitation expires." } }, "required": [ "id", "organizationId", - "name", - "description", - "config", - "isDefault", - "membershipMode", - "workspaceIds", - "createdBy", + "email", + "role", + "kind", + "membershipIntent", + "status", "createdAt", - "updatedAt" + "expiresAt" ], "additionalProperties": false, - "title": "Permission group", - "description": "An organization permission group and its resolved restrictions." + "title": "Organization invitation", + "description": "Invitation metadata without its acceptance token." + }, + "ListOrganizationInvitationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2OrganizationInvitation" + }, + "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 Invitations response", + "description": "List Organization Invitations 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" + } + ], + "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" + } + } + ] + }, + "V2OrganizationInvitationWorkspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Workspace identifier." + }, + "name": { + "type": "string", + "description": "Workspace display name." + }, + "permission": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "Workspace permission offered by the invitation." + }, + "archivedAt": { + "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": "When the workspace was archived, or null while active." + } + }, + "required": ["id", "name", "permission", "archivedAt"], + "additionalProperties": false, + "title": "Organization invitation workspace", + "description": "A workspace grant attached to an invitation, separate from organization membership." }, - "ListPermissionGroupsResponse": { + "ListOrganizationInvitationWorkspacesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2PermissionGroup" + "$ref": "#/components/schemas/V2OrganizationInvitationWorkspace" }, "description": "Items in the current page." }, @@ -13668,919 +17740,1671 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Permission Groups response", - "description": "List Permission Groups result.", + "title": "List Organization Invitation Workspaces response", + "description": "Workspace grants retained on an invitation.", "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" + "id": "workspace-123", + "name": "Engineering", + "permission": "write", + "archivedAt": null } ], "nextCursor": null } ] }, - "CreatePermissionGroupResponse": { + "ResendOrganizationInvitationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroup" + "$ref": "#/components/schemas/V2OrganizationInvitation" } }, "required": ["data"], "additionalProperties": false, - "title": "Create Permission Group response", - "description": "Create Permission Group result.", + "title": "Resend Organization Invitation response", + "description": "Resend Organization Invitation result.", "examples": [ { "data": { - "id": "group-123", + "id": "invitation-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", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", "createdAt": "2026-06-01T09:00:00.000Z", - "updatedAt": "2026-06-01T09:00:00.000Z" + "expiresAt": "2026-06-08T09:00:00.000Z" } } ] }, - "CreatePermissionGroupRequest": { + "ResendOrganizationInvitationBody": { + "default": {}, + "title": "Resend Organization Invitation body", + "description": "Resend Organization Invitation input.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "V2OrganizationInvitationRevocation": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Group name, unique within the organization." + "description": "Revoked invitation identifier." }, - "description": { - "description": "Optional group description.", + "status": { "type": "string", - "maxLength": 500 - }, - "config": { - "type": "object", - "properties": { - "allowedIntegrations": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." - }, - "allowedModelProviders": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." - }, - "deniedModels": { - "type": "array", - "items": { - "type": "string" - }, - "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." + "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" + } + } + ] + }, + "V2WorkspacePermissionConfig": { + "type": "object", + "properties": { + "permissionGroupId": { + "anyOf": [ + { + "type": "string" }, - "disablePublicFileSharing": { - "type": "boolean", - "description": "Revoke public file sharing. Members cannot create a share link." + { + "type": "null" + } + ], + "description": "Identifier of the group governing the caller; null when no group applies." + }, + "groupName": { + "anyOf": [ + { + "type": "string" }, - "allowedFileShareAuthTypes": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string", - "enum": ["public", "password", "email", "sso"] - } + { + "type": "null" + } + ], + "description": "Name of the governing permission group." + }, + "config": { + "anyOf": [ + { + "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." }, - { - "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": [ - { + "allowedModelProviders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." + }, + "deniedModels": { + "default": [], "type": "array", "items": { - "type": "string", - "enum": ["public", "password", "email", "sso"] - } + "type": "string" + }, + "description": "Models listed in this list are blocked." }, - { - "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": [ - { + "deniedTools": { + "default": [], "type": "array", "items": { "type": "string" - } + }, + "description": "Integration tools listed in this list are blocked." }, - { - "type": "null" + "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": [ + "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" ], - "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." + "additionalProperties": false }, - "disablePersonalCredentials": { - "type": "boolean", - "description": "Prevent connecting personal credentials, leaving only workspace-shared ones." + { + "type": "null" + } + ], + "description": "Effective group restrictions. True disables a boolean capability; null allowlists allow all values and empty allowlists allow none. Null config means no group applies." + }, + "entitled": { + "type": "boolean", + "description": "Whether organization permission governance is active." + }, + "organizationId": { + "anyOf": [ + { + "type": "string" }, - "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." + { + "type": "null" + } + ], + "description": "Organization that owns the workspace; null for a personal workspace." + }, + "isOrgAdmin": { + "type": "boolean", + "description": "Whether the caller is an owner or administrator of the workspace’s organization." + } + }, + "required": [ + "permissionGroupId", + "groupName", + "config", + "entitled", + "organizationId", + "isOrgAdmin" + ], + "additionalProperties": false + }, + "GetWorkspacePermissionConfigResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2WorkspacePermissionConfig" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Workspace Permission Config response", + "description": "Configuration for the acting caller only.", + "examples": [ + { + "data": { + "permissionGroupId": null, + "groupName": null, + "config": null, + "entitled": false, + "organizationId": null, + "isOrgAdmin": false + } + } + ] + }, + "V2WorkspaceInvitationBatch": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether every recipient succeeded. Inspect failed even when the HTTP response is successful." + }, + "successful": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Email addresses that received a pending invitation." + }, + "added": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Existing organization members granted workspace access immediately." + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Recipient whose operation failed." + }, + "error": { + "type": "string", + "description": "Reason the invitation or access grant could not be completed." + } }, - "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." + "required": ["email", "error"], + "additionalProperties": false + }, + "description": "Failures for individual recipients. Earlier successful recipients remain committed." + }, + "invitations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Invitation identifier, or direct-grant result identifier when instantAdd is true." + }, + "email": { + "type": "string", + "description": "Recipient email address." + }, + "workspaceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspaces included in this invitation or direct grant." + }, + "permission": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "Workspace permission offered or granted." + }, + "membershipIntent": { + "type": "string", + "enum": ["internal", "external"], + "description": "Whether the recipient joins the organization or receives only workspace access." + }, + "instantAdd": { + "description": "Whether access was granted immediately without a pending invitation.", + "type": "boolean" + }, + "outcome": { + "description": "Result when reconciling access for an existing user.", + "type": "string", + "enum": ["added", "updated", "unchanged"] + } }, - "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." + "required": ["id", "email", "workspaceIds", "permission", "membershipIntent"], + "additionalProperties": false + }, + "description": "Invitation and immediate-access results for successful recipients." + } + }, + "required": ["success", "successful", "added", "failed", "invitations"], + "additionalProperties": false + }, + "CreateWorkspaceInvitationsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2WorkspaceInvitationBatch" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create Workspace Invitations response", + "description": "Successful recipients remain committed when later recipients fail.", + "examples": [ + { + "data": { + "success": true, + "successful": ["member@example.com"], + "added": [], + "failed": [], + "invitations": [ + { + "id": "invitation-123", + "email": "member@example.com", + "workspaceIds": ["workspace-123"], + "permission": "write", + "membershipIntent": "internal" + } + ] + } + } + ] + }, + "CreateWorkspaceInvitationsBody": { + "type": "object", + "properties": { + "emails": { + "minItems": 1, + "maxItems": 50, + "type": "array", + "items": { + "type": "string", + "maxLength": 320, + "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 addresses to invite. Each address is processed separately; inspect failed for unsuccessful recipients." + }, + "permission": { + "default": "read", + "description": "Workspace permission to grant. Existing workspace access is preserved.", + "type": "string", + "enum": ["admin", "write", "read"] + }, + "membership": { + "default": "member", + "description": "Organization membership: member or admin uses a seat when billing is enabled. External grants workspace access only and requires an eligible paid account when billing is enabled. Existing members of another organization remain external.", + "type": "string", + "enum": ["member", "admin", "external"] + } + }, + "required": ["emails"], + "additionalProperties": false, + "title": "Create Workspace Invitations body", + "description": "Recipients and the access to grant.", + "examples": [ + { + "emails": ["member@example.com"], + "permission": "write", + "membership": "member" + } + ] + }, + "V2OrganizationMemberUsageLimit": { + "type": "object", + "properties": { + "creditsUsed": { + "type": "number", + "description": "Credits used by this person during the organization billing period." + }, + "creditLimit": { + "anyOf": [ + { + "type": "number" }, - "disableWebhookTriggers": { - "type": "boolean", - "description": "Prevent making a workflow reachable from an inbound webhook." + { + "type": "null" + } + ], + "description": "Per-person credit cap. Null means no per-person cap; organization limits still apply. Zero prevents further credit-consuming usage." + }, + "billingInterval": { + "type": "string", + "enum": ["month", "year"], + "description": "Organization billing interval used for the credit cap." + } + }, + "required": ["creditsUsed", "creditLimit", "billingInterval"], + "additionalProperties": false + }, + "GetOrganizationMemberUsageLimitResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationMemberUsageLimit" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Organization Member Credit Limit response", + "description": "Get Organization Member Credit Limit result.", + "examples": [ + { + "data": { + "creditsUsed": 200, + "creditLimit": 10000, + "billingInterval": "month" + } + } + ] + }, + "V2OrganizationMemberUsageLimitUpdate": { + "type": "object", + "properties": { + "creditLimit": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, - "disableToolAutoApproval": { - "type": "boolean", - "description": "Prevent silencing a tool confirmation, so every call is confirmed again." + { + "type": "null" + } + ], + "description": "Credit cap for this person. Send null to clear the cap or 0 to prevent further credit-consuming usage. Organization limits still apply." + } + }, + "required": ["creditLimit"], + "additionalProperties": false + }, + "UpdateOrganizationMemberUsageLimitResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationMemberUsageLimitUpdate" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update Organization Member Credit Limit response", + "description": "Update Organization Member Credit Limit result.", + "examples": [ + { + "data": { + "creditLimit": 10000 + } + } + ] + }, + "UpdateOrganizationMemberUsageLimitBody": { + "type": "object", + "properties": { + "creditLimit": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, - "hideSandboxesTab": { - "type": "boolean", - "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + { + "type": "null" + } + ], + "description": "Credit cap for this person. Send null to clear the cap or 0 to prevent further credit-consuming usage. Organization limits still apply." + } + }, + "required": ["creditLimit"], + "additionalProperties": false, + "title": "Update Organization Member Credit Limit body", + "description": "Credit cap in whole credits; null clears the cap.", + "examples": [ + { + "creditLimit": 10000 + }, + { + "creditLimit": null + } + ] + }, + "V2OrganizationUsageSummary": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "start": { + "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": "Inclusive reporting-window start." }, - "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." + "end": { + "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": "Exclusive reporting-window end." }, - "disableKnowledgeBaseExport": { - "type": "boolean", - "description": "Prevent downloading a whole knowledge base as an archive." + "source": { + "type": "string", + "enum": ["reporting", "stripe", "default", "range"], + "description": "How the reporting window was resolved." } }, + "required": ["start", "end", "source"], "additionalProperties": false, - "description": "Permission restrictions to set. Omitted keys use the default permission configuration." + "description": "Resolved reporting window." }, - "isDefault": { - "description": "Whether the group is the organization default. Only one group can be the default.", - "type": "boolean" + "bucket": { + "type": "string", + "enum": ["day", "week", "month"], + "description": "Calendar granularity of the usage series." + }, + "totals": { + "type": "object", + "properties": { + "credits": { + "type": "number", + "description": "Whole credits attributed to this total or group." + } + }, + "required": ["credits"], + "additionalProperties": false, + "description": "Total usage for the selected window." + }, + "previousTotals": { + "anyOf": [ + { + "type": "object", + "properties": { + "credits": { + "type": "number", + "description": "Whole credits attributed to this total or group." + } + }, + "required": ["credits"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Exact previous-period total, or null when no exact comparison is available." }, - "workspaceIds": { - "description": "Workspace IDs targeted by a non-default group. Required when creating a non-default group; omit for a default group.", - "maxItems": 500, + "series": { "type": "array", "items": { - "type": "string", - "minLength": 1 - } + "type": "object", + "properties": { + "timestamp": { + "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": "Start of this calendar bucket." + }, + "credits": { + "type": "number", + "description": "Whole credits attributed to this total or group." + }, + "events": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Number of usage events." + } + }, + "required": ["timestamp", "credits", "events"], + "additionalProperties": false + }, + "description": "Chronological usage buckets, including buckets with no usage." } }, - "required": ["name"], - "additionalProperties": false, - "title": "Create Permission Group request", - "description": "Create Permission Group inputs.", - "examples": [ - { - "name": "Restricted", - "workspaceIds": ["workspace-123"] - } - ] + "required": ["window", "bucket", "totals", "previousTotals", "series"], + "additionalProperties": false }, - "GetPermissionGroupResponse": { + "GetOrganizationUsageSummaryResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroup" + "$ref": "#/components/schemas/V2OrganizationUsageSummary" } }, "required": ["data"], "additionalProperties": false, - "title": "Get Permission Group response", - "description": "Get Permission Group result.", + "title": "Get Organization Usage Summary response", + "description": "Get Organization Usage Summary 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 + "window": { + "start": "2026-06-01T00:00:00.000Z", + "end": "2026-07-01T00:00:00.000Z", + "source": "range" }, - "isDefault": false, - "membershipMode": "inherit", - "workspaceIds": ["workspace-123"], - "createdBy": "admin-123", - "createdAt": "2026-06-01T09:00:00.000Z", - "updatedAt": "2026-06-01T09:00:00.000Z" + "bucket": "day", + "totals": { + "credits": 200 + }, + "previousTotals": null, + "series": [ + { + "timestamp": "2026-06-01T00:00:00.000Z", + "credits": 200, + "events": 1 + } + ] } } ] }, - "UpdatePermissionGroupResponse": { + "V2OrganizationUsageBreakdown": { + "type": "object", + "properties": { + "dimension": { + "type": "string", + "enum": ["member", "workspace", "workflow", "model", "byok", "source"], + "description": "Usage grouping dimension." + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Group identifier; an empty ID represents unattributed usage." + }, + "label": { + "type": "string", + "description": "Display label for the usage group." + }, + "credits": { + "type": "number", + "description": "Whole credits attributed to this total or group." + }, + "events": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Number of usage events." + }, + "share": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fraction of total cost, or total tokens for BYOK, between zero and one." + }, + "providerId": { + "description": "Model provider identifier, when applicable.", + "type": "string" + }, + "tokens": { + "description": "Input and output tokens for model or BYOK groups.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["id", "label", "credits", "events", "share"], + "additionalProperties": false + }, + "description": "Top usage groups ordered by cost, or tokens for BYOK." + }, + "other": { + "type": "object", + "properties": { + "credits": { + "type": "number", + "description": "Whole credits attributed to this total or group." + }, + "events": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Number of usage events." + }, + "rowCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Number of groups omitted from rows." + }, + "tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Input and output tokens attributed to omitted groups." + } + }, + "required": ["credits", "events", "rowCount", "tokens"], + "additionalProperties": false, + "description": "Combined usage for groups omitted by the limit." + }, + "totalCredits": { + "type": "number", + "description": "Whole credits represented by this breakdown; workflow includes only workflow-attributed usage." + } + }, + "required": ["dimension", "rows", "other", "totalCredits"], + "additionalProperties": false + }, + "GetOrganizationUsageBreakdownResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroup" + "$ref": "#/components/schemas/V2OrganizationUsageBreakdown" } }, "required": ["data"], "additionalProperties": false, - "title": "Update Permission Group response", - "description": "Update Permission Group result.", + "title": "Get Organization Usage Breakdown response", + "description": "Get Organization Usage Breakdown 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 + "dimension": "member", + "rows": [ + { + "id": "user-123", + "label": "Example Member", + "credits": 200, + "events": 1, + "share": 1 + } + ], + "other": { + "credits": 0, + "events": 0, + "rowCount": 0, + "tokens": 0 }, - "isDefault": false, - "membershipMode": "inherit", - "workspaceIds": ["workspace-123"], - "createdBy": "admin-123", - "createdAt": "2026-06-01T09:00:00.000Z", - "updatedAt": "2026-06-01T09:00:00.000Z" + "totalCredits": 200 } } ] }, - "UpdatePermissionGroupRequest": { + "V2OrganizationUsageEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Usage event identifier." + }, + "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 usage event was recorded." + }, + "source": { + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ], + "description": "Product surface that recorded the usage." + }, + "description": { + "type": "string", + "description": "Usage event description, such as a model name." + }, + "workflowName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow name, or null for non-workflow usage or a deleted workflow." + }, + "credits": { + "type": "number", + "description": "Credits consumed by this event, rounded to whole credits." + }, + "hasCost": { + "type": "boolean", + "description": "Whether the event consumed credits before rounding, including sub-credit usage." + } + }, + "required": [ + "id", + "createdAt", + "source", + "description", + "workflowName", + "credits", + "hasCost" + ], + "additionalProperties": false + }, + "ListOrganizationUsageEventsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2OrganizationUsageEvent" + }, + "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 Usage Events response", + "description": "List Organization Usage Events result.", + "examples": [ + { + "data": [ + { + "id": "event-123", + "createdAt": "2026-06-01T09:00:00.000Z", + "source": "sim-chat", + "description": "Model usage", + "workflowName": null, + "credits": 200, + "hasCost": true + } + ], + "nextCursor": null + } + ] + }, + "V2AccessRequestDiscoveryEntry": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Group name, unique within the organization." - }, - "description": { - "description": "Group description. Null or an empty string clears it; omission leaves it unchanged.", - "anyOf": [ - { - "type": "string", - "maxLength": 500 - }, + "target": { + "oneOf": [ { - "type": "null" - } - ] - }, - "config": { - "type": "object", - "properties": { - "allowedIntegrations": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "Integrations and blocks are limited to this list. Null permits every value; an empty list permits none." - }, - "allowedModelProviders": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "feature", + "description": "Kind of access being requested." }, - { - "type": "null" + "configKey": { + "type": "string", + "enum": [ + "hideTraceSpans", + "hideKnowledgeBaseTab", + "hideTablesTab", + "hideCopilot", + "hideIntegrationsTab", + "hideSecretsTab", + "hideApiKeysTab", + "hideInboxTab", + "hideFilesTab", + "disableMcpTools", + "disableCustomTools", + "disableSkills", + "disableInvitations", + "disablePublicApi", + "disablePublicFileSharing", + "hideDeployApi", + "hideDeployMcp", + "hideDeployChatbot", + "disablePersonalApiKeys", + "disableLogExport", + "hideCostInfo", + "disableKnowledgeBaseCreation", + "disableKnowledgeBaseFileUpload", + "disableTableCreation", + "disableTableExport", + "disableBulkFileDownload", + "disablePersonalCredentials", + "disableWorkspaceCreation", + "hideOrgMemberDirectory", + "disableCliAccess", + "disableWebhookTriggers", + "disableToolAutoApproval", + "hideSandboxesTab", + "disableOAuthAppAccess", + "disableKnowledgeBaseExport" + ], + "description": "Feature restriction key returned by access discovery." } - ], - "description": "Model providers are limited to this list. Null permits every value; an empty list permits none." - }, - "deniedModels": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Models listed in this list are blocked." - }, - "deniedTools": { - "type": "array", - "items": { - "type": "string" }, - "description": "Integration tools listed in this list are blocked." - }, - "hideTraceSpans": { - "type": "boolean", - "description": "Withhold per-block trace spans from logs and from the API." - }, - "hideKnowledgeBaseTab": { - "type": "boolean", - "description": "Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base." - }, - "hideTablesTab": { - "type": "boolean", - "description": "Revoke the Tables module. Members cannot read or write any table." - }, - "hideCopilot": { - "type": "boolean", - "description": "Revoke Chat. Members cannot ask Sim to build or edit anything." - }, - "hideIntegrationsTab": { - "type": "boolean", - "description": "Revoke integration connections. Members cannot view, add, or remove an OAuth connection." - }, - "hideSecretsTab": { - "type": "boolean", - "description": "Revoke secrets. Members cannot read, add, or change a workspace environment variable." - }, - "hideApiKeysTab": { - "type": "boolean", - "description": "Revoke workspace API keys. Members cannot list, create, or revoke one." - }, - "hideInboxTab": { - "type": "boolean", - "description": "Revoke the Sim Mailer inbox. Members cannot read or send mail." - }, - "hideFilesTab": { - "type": "boolean", - "description": "Revoke the Files module. Members cannot list, upload, or download workspace files." - }, - "disableMcpTools": { - "type": "boolean", - "description": "Block agents from calling MCP tools." - }, - "disableCustomTools": { - "type": "boolean", - "description": "Block agents from calling user-defined custom tools." - }, - "disableSkills": { - "type": "boolean", - "description": "Block agents from loading skills." - }, - "disableInvitations": { - "type": "boolean", - "description": "Prevent inviting anyone to a workspace or to the organization." - }, - "disablePublicApi": { - "type": "boolean", - "description": "Revoke public API access. Calls to a deployed workflow are refused." - }, - "disablePublicFileSharing": { - "type": "boolean", - "description": "Revoke public file sharing. Members cannot create a share link." + "required": ["kind", "configKey"], + "additionalProperties": false }, - "allowedFileShareAuthTypes": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string", - "enum": ["public", "password", "email", "sso"] - } + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integration", + "description": "Kind of access being requested." }, - { - "type": "null" + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." } - ], - "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." + }, + "required": ["kind", "id"], + "additionalProperties": false }, - "allowedChatDeployAuthTypes": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string", - "enum": ["public", "password", "email", "sso"] - } + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "provider", + "description": "Kind of access being requested." }, - { - "type": "null" + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." } - ], - "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." + }, + "required": ["kind", "id"], + "additionalProperties": false }, - "allowedKnowledgeConnectors": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "model", + "description": "Kind of access being requested." }, - { - "type": "null" + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." } - ], - "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." + }, + "required": ["kind", "id"], + "additionalProperties": false }, - "disableWebhookTriggers": { - "type": "boolean", - "description": "Prevent making a workflow reachable from an inbound webhook." + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "tool", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false }, - "disableToolAutoApproval": { - "type": "boolean", - "description": "Prevent silencing a tool confirmation, so every call is confirmed again." + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "knowledge_connector", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false }, - "hideSandboxesTab": { - "type": "boolean", - "description": "Revoke the Sandboxes module. Members cannot view, create, or change a workspace sandbox." + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "file_share_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false }, - "disableOAuthAppAccess": { - "type": "boolean", - "description": "Prevent OAuth apps from accessing this group's workspaces. The organization's default group also governs authorizing apps and refreshing their access." + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "chat_deploy_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false }, - "disableKnowledgeBaseExport": { - "type": "boolean", - "description": "Prevent downloading a whole knowledge base as an archive." + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "usage_limit", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "const": "member", + "description": "Request an increase to the acting user’s member credit cap." + } + }, + "required": ["kind", "id"], + "additionalProperties": false } - }, - "additionalProperties": false, - "description": "Patch of permission restrictions. Omitted keys remain unchanged; each supplied array replaces that entire list." + ], + "description": "Pass this target unchanged to Create Access Request." }, - "isDefault": { - "description": "Whether the group is the organization default. Only one group can be the default.", - "type": "boolean" + "label": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Human-readable access item name." }, - "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": { + "state": { "type": "string", - "description": "Deleted permission group identifier." + "enum": ["allowed", "requestable", "unavailable"], + "description": "Whether access is already allowed, can be requested, or is unavailable." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the group was permanently deleted." + "reason": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Why access is unavailable or restricted; null when no explanation is needed." + }, + "pendingRequestId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + }, + { + "type": "null" + } + ], + "description": "Existing pending request for this item; null when none exists." } }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Permission group deletion", - "description": "Acknowledges permanent group deletion." + "required": ["target", "label", "state", "reason", "pendingRequestId"], + "additionalProperties": false }, - "DeletePermissionGroupResponse": { + "DiscoverWorkspaceAccessRequestsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupDeletion" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2AccessRequestDiscoveryEntry" + }, + "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": "Delete Permission Group response", - "description": "Delete Permission Group result.", + "title": "Discover Workspace Access Requests response", + "description": "Discover Workspace Access Requests result.", "examples": [ { - "data": { - "id": "group-123", - "deleted": true - } + "data": [ + { + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "label": "Tables", + "state": "requestable", + "reason": null, + "pendingRequestId": null + } + ], + "nextCursor": null } ] }, - "V2PermissionGroupMember": { + "V2AccessRequest": { "type": "object", "properties": { "id": { "type": "string", - "description": "Membership assignment identifier." + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + }, + "organizationId": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the request." + }, + "workspaceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + { + "type": "null" + } + ], + "description": "Workspace where access was requested; null for an organization-level request." + }, + "target": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "feature", + "description": "Kind of access being requested." + }, + "configKey": { + "type": "string", + "enum": [ + "hideTraceSpans", + "hideKnowledgeBaseTab", + "hideTablesTab", + "hideCopilot", + "hideIntegrationsTab", + "hideSecretsTab", + "hideApiKeysTab", + "hideInboxTab", + "hideFilesTab", + "disableMcpTools", + "disableCustomTools", + "disableSkills", + "disableInvitations", + "disablePublicApi", + "disablePublicFileSharing", + "hideDeployApi", + "hideDeployMcp", + "hideDeployChatbot", + "disablePersonalApiKeys", + "disableLogExport", + "hideCostInfo", + "disableKnowledgeBaseCreation", + "disableKnowledgeBaseFileUpload", + "disableTableCreation", + "disableTableExport", + "disableBulkFileDownload", + "disablePersonalCredentials", + "disableWorkspaceCreation", + "hideOrgMemberDirectory", + "disableCliAccess", + "disableWebhookTriggers", + "disableToolAutoApproval", + "hideSandboxesTab", + "disableOAuthAppAccess", + "disableKnowledgeBaseExport" + ], + "description": "Feature restriction key returned by access discovery." + } + }, + "required": ["kind", "configKey"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integration", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "provider", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "model", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "tool", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "knowledge_connector", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "file_share_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "chat_deploy_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "usage_limit", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "const": "member", + "description": "Request an increase to the acting user’s member credit cap." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + } + ], + "description": "The requested feature, integration, model, tool, authentication mode, or member credit cap." + }, + "targetLabel": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Human-readable name of the requested access." }, - "userId": { + "reason": { "type": "string", - "description": "Organization member assigned to the group." + "maxLength": 1000, + "description": "Reason supplied by the requester." }, - "assignedAt": { + "status": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the member was assigned." + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"], + "description": "Current request status." }, - "userName": { + "decisionReason": { "anyOf": [ { - "type": "string" + "type": "string", + "maxLength": 1000 }, { "type": "null" } ], - "description": "Member display name." + "description": "Explanation for a declined or closed request; null when none was recorded." }, - "userEmail": { + "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 request was submitted." + }, + "decidedAt": { "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": "Member email address." + "description": "When the request was resolved; null while pending." }, - "userImage": { + "groupName": { "anyOf": [ { "type": "string" @@ -14589,21 +19413,62 @@ "type": "null" } ], - "description": "Member avatar URL." + "description": "Name of the governing group when the request was submitted; null for credit-cap requests." + }, + "requester": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Requester user identifier." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Requester display name." + }, + "email": { + "type": "string", + "maxLength": 320, + "description": "Requester email address." + } + }, + "required": ["id", "name", "email"], + "additionalProperties": false, + "description": "User who submitted the request." } }, - "required": ["id", "userId", "assignedAt", "userName", "userEmail", "userImage"], - "additionalProperties": false, - "title": "Permission group member", - "description": "An explicit permission-group membership assignment." + "required": [ + "id", + "organizationId", + "workspaceId", + "target", + "targetLabel", + "reason", + "status", + "decisionReason", + "createdAt", + "decidedAt", + "groupName", + "requester" + ], + "additionalProperties": false }, - "ListPermissionGroupMembersResponse": { + "ListMyWorkspaceAccessRequestsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2PermissionGroupMember" + "$ref": "#/components/schemas/V2AccessRequest" }, "description": "Items in the current page." }, @@ -14621,264 +19486,741 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Permission Group Members response", - "description": "List Permission Group Members result.", + "title": "List My Workspace Access Requests response", + "description": "List My Workspace Access Requests result.", "examples": [ { "data": [ { - "id": "assignment-123", - "userId": "user-123", - "assignedAt": "2026-06-01T09:00:00.000Z", - "userName": "Example Member", - "userEmail": "member@example.com", - "userImage": null + "id": "request-123", + "organizationId": "org-123", + "workspaceId": "workspace-123", + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "pending", + "decisionReason": null, + "createdAt": "2026-06-01T09:00:00.000Z", + "decidedAt": null, + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } } ], "nextCursor": null } ] }, - "V2PermissionGroupAssignment": { + "CreateWorkspaceAccessRequestResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Membership assignment identifier." - }, - "permissionGroupId": { - "type": "string", - "description": "Group receiving the member." - }, - "organizationId": { - "type": "string", - "description": "Organization that owns the group." - }, - "userId": { - "type": "string", - "description": "User assigned to the group." - }, - "assignedBy": { - "type": "string", - "description": "User who made the assignment." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2AccessRequest" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create Workspace Access Request response", + "description": "Create Workspace Access Request result.", + "examples": [ + { + "data": { + "id": "request-123", + "organizationId": "org-123", + "workspaceId": "workspace-123", + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "pending", + "decisionReason": null, + "createdAt": "2026-06-01T09:00:00.000Z", + "decidedAt": null, + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } + } + } + ] + }, + "CreateWorkspaceAccessRequestBody": { + "type": "object", + "properties": { + "target": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "feature", + "description": "Kind of access being requested." + }, + "configKey": { + "type": "string", + "enum": [ + "hideTraceSpans", + "hideKnowledgeBaseTab", + "hideTablesTab", + "hideCopilot", + "hideIntegrationsTab", + "hideSecretsTab", + "hideApiKeysTab", + "hideInboxTab", + "hideFilesTab", + "disableMcpTools", + "disableCustomTools", + "disableSkills", + "disableInvitations", + "disablePublicApi", + "disablePublicFileSharing", + "hideDeployApi", + "hideDeployMcp", + "hideDeployChatbot", + "disablePersonalApiKeys", + "disableLogExport", + "hideCostInfo", + "disableKnowledgeBaseCreation", + "disableKnowledgeBaseFileUpload", + "disableTableCreation", + "disableTableExport", + "disableBulkFileDownload", + "disablePersonalCredentials", + "disableWorkspaceCreation", + "hideOrgMemberDirectory", + "disableCliAccess", + "disableWebhookTriggers", + "disableToolAutoApproval", + "hideSandboxesTab", + "disableOAuthAppAccess", + "disableKnowledgeBaseExport" + ], + "description": "Feature restriction key returned by access discovery." + } + }, + "required": ["kind", "configKey"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integration", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "provider", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "model", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "tool", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "knowledge_connector", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "file_share_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "chat_deploy_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "usage_limit", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "const": "member", + "description": "Request an increase to the acting user’s member credit cap." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + } + ], + "description": "Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable." }, - "assignedAt": { + "reason": { + "default": "", + "description": "Why the acting user needs this access.", "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." + "maxLength": 1000 } }, - "required": [ - "id", - "permissionGroupId", - "organizationId", - "userId", - "assignedBy", - "assignedAt" - ], + "required": ["target"], "additionalProperties": false, - "title": "Permission group assignment", - "description": "The newly created membership assignment." + "title": "Create Workspace Access Request body", + "description": "Inputs for this operation.", + "examples": [ + { + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "reason": "Maintain team data" + } + ] }, - "AddPermissionGroupMemberResponse": { + "CancelWorkspaceAccessRequestResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupAssignment" + "$ref": "#/components/schemas/V2AccessRequest" } }, "required": ["data"], "additionalProperties": false, - "title": "Add Permission Group Member response", - "description": "Add Permission Group Member result.", + "title": "Cancel Workspace Access Request response", + "description": "Cancel Workspace Access Request result.", "examples": [ { "data": { - "id": "assignment-123", - "permissionGroupId": "group-123", + "id": "request-123", "organizationId": "org-123", - "userId": "user-123", - "assignedBy": "admin-123", - "assignedAt": "2026-06-01T09:00:00.000Z" + "workspaceId": "workspace-123", + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "cancelled", + "decisionReason": null, + "createdAt": "2026-06-01T09:00:00.000Z", + "decidedAt": "2026-06-01T10:00:00.000Z", + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } } } ] }, - "AddPermissionGroupMemberRequest": { + "DiscoverOrganizationAccessRequestsResponse": { "type": "object", "properties": { - "userId": { - "type": "string", - "minLength": 1, - "description": "Existing organization member to add." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2AccessRequestDiscoveryEntry" + }, + "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": ["userId"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Add Permission Group Member request", - "description": "Add Permission Group Member inputs.", + "title": "Discover Organization Access Requests response", + "description": "Discover Organization Access Requests result.", "examples": [ { - "userId": "user-123" + "data": [ + { + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "label": "Tables", + "state": "requestable", + "reason": null, + "pendingRequestId": null + } + ], + "nextCursor": null } ] }, - "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": { + "ListMyOrganizationAccessRequestsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupMemberDeletion" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2AccessRequest" + }, + "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": "Remove Permission Group Member response", - "description": "Remove Permission Group Member result.", + "title": "List My Organization Access Requests response", + "description": "List My Organization Access Requests result.", "examples": [ { - "data": { - "userId": "user-123", - "deleted": true - } + "data": [ + { + "id": "request-123", + "organizationId": "org-123", + "workspaceId": null, + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "pending", + "decisionReason": null, + "createdAt": "2026-06-01T09:00:00.000Z", + "decidedAt": null, + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } + } + ], + "nextCursor": null } ] }, - "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": { + "CreateOrganizationAccessRequestResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupBulkAdd" + "$ref": "#/components/schemas/V2AccessRequest" } }, "required": ["data"], "additionalProperties": false, - "title": "Bulk Add Permission Group Members response", - "description": "Bulk Add Permission Group Members result.", + "title": "Create Organization Access Request response", + "description": "Create Organization Access Request result.", "examples": [ { "data": { - "added": 1, - "skipped": 0 - } - } - ] - }, - "BulkAddPermissionGroupMembersRequest": { - "type": "object", - "properties": { - "userIds": { - "description": "Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.", - "minItems": 1, - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "minLength": 1 + "id": "request-123", + "organizationId": "org-123", + "workspaceId": null, + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "pending", + "decisionReason": null, + "createdAt": "2026-06-01T09:00:00.000Z", + "decidedAt": null, + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } } - }, - "addAllOrganizationMembers": { - "description": "Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.", - "type": "boolean" - } - }, - "additionalProperties": false, - "title": "Bulk Add Permission Group Members request", - "description": "Bulk Add Permission Group Members inputs.", - "examples": [ - { - "userIds": ["user-123"] } ] }, - "V2Organization": { + "CreateOrganizationAccessRequestBody": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Organization identifier." - }, - "name": { - "type": "string", - "description": "Organization display name." - }, - "slug": { - "type": "string", - "description": "Organization slug." - }, - "logo": { - "anyOf": [ + "target": { + "oneOf": [ { - "type": "string" + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "feature", + "description": "Kind of access being requested." + }, + "configKey": { + "type": "string", + "enum": [ + "hideTraceSpans", + "hideKnowledgeBaseTab", + "hideTablesTab", + "hideCopilot", + "hideIntegrationsTab", + "hideSecretsTab", + "hideApiKeysTab", + "hideInboxTab", + "hideFilesTab", + "disableMcpTools", + "disableCustomTools", + "disableSkills", + "disableInvitations", + "disablePublicApi", + "disablePublicFileSharing", + "hideDeployApi", + "hideDeployMcp", + "hideDeployChatbot", + "disablePersonalApiKeys", + "disableLogExport", + "hideCostInfo", + "disableKnowledgeBaseCreation", + "disableKnowledgeBaseFileUpload", + "disableTableCreation", + "disableTableExport", + "disableBulkFileDownload", + "disablePersonalCredentials", + "disableWorkspaceCreation", + "hideOrgMemberDirectory", + "disableCliAccess", + "disableWebhookTriggers", + "disableToolAutoApproval", + "hideSandboxesTab", + "disableOAuthAppAccess", + "disableKnowledgeBaseExport" + ], + "description": "Feature restriction key returned by access discovery." + } + }, + "required": ["kind", "configKey"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integration", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "provider", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "model", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "tool", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "knowledge_connector", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "file_share_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "chat_deploy_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false }, { - "type": "null" + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "usage_limit", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "const": "member", + "description": "Request an increase to the acting user’s member credit cap." + } + }, + "required": ["kind", "id"], + "additionalProperties": false } ], - "description": "Organization logo URL, or null when unset." - }, - "role": { - "type": "string", - "enum": ["owner", "admin", "member"], - "description": "The acting user’s role in this organization." + "description": "Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable." }, - "createdAt": { + "reason": { + "default": "", + "description": "Why the acting user needs this access.", "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." + "maxLength": 1000 } }, - "required": ["id", "name", "slug", "logo", "role", "createdAt"], + "required": ["target"], "additionalProperties": false, - "title": "Organization", - "description": "An organization the acting user belongs to." + "title": "Create Organization Access Request body", + "description": "Inputs for this operation.", + "examples": [ + { + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "reason": "Maintain team data" + } + ] }, - "ListOrganizationsResponse": { + "CancelOrganizationAccessRequestResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2AccessRequest" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Cancel Organization Access Request response", + "description": "Cancel Organization Access Request result.", + "examples": [ + { + "data": { + "id": "request-123", + "organizationId": "org-123", + "workspaceId": null, + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "cancelled", + "decisionReason": null, + "createdAt": "2026-06-01T09:00:00.000Z", + "decidedAt": "2026-06-01T10:00:00.000Z", + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } + } + } + ] + }, + "ListOrganizationAccessRequestsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Organization" + "$ref": "#/components/schemas/V2AccessRequest" }, "description": "Items in the current page." }, @@ -14896,525 +20238,1376 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Organizations response", - "description": "List Organizations result.", + "title": "List Organization Access Requests response", + "description": "List Organization Access Requests result.", "examples": [ { "data": [ { - "id": "org-123", - "name": "Example Organization", - "slug": "example", - "logo": null, - "role": "admin", - "createdAt": "2026-06-01T09:00:00.000Z" + "id": "request-123", + "organizationId": "org-123", + "workspaceId": "workspace-123", + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "pending", + "decisionReason": null, + "createdAt": "2026-06-01T09:00:00.000Z", + "decidedAt": null, + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } + } + ], + "nextCursor": null + } + ] + }, + "V2PermissionAccessRequestPreview": { + "type": "object", + "properties": { + "newLimitCredits": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ], + "description": "Applied credit cap for a fulfilled request; null before approval or for permission changes." + }, + "request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + }, + "organizationId": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the request." + }, + "workspaceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + { + "type": "null" + } + ], + "description": "Workspace where access was requested; null for an organization-level request." + }, + "target": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "feature", + "description": "Kind of access being requested." + }, + "configKey": { + "type": "string", + "enum": [ + "hideTraceSpans", + "hideKnowledgeBaseTab", + "hideTablesTab", + "hideCopilot", + "hideIntegrationsTab", + "hideSecretsTab", + "hideApiKeysTab", + "hideInboxTab", + "hideFilesTab", + "disableMcpTools", + "disableCustomTools", + "disableSkills", + "disableInvitations", + "disablePublicApi", + "disablePublicFileSharing", + "hideDeployApi", + "hideDeployMcp", + "hideDeployChatbot", + "disablePersonalApiKeys", + "disableLogExport", + "hideCostInfo", + "disableKnowledgeBaseCreation", + "disableKnowledgeBaseFileUpload", + "disableTableCreation", + "disableTableExport", + "disableBulkFileDownload", + "disablePersonalCredentials", + "disableWorkspaceCreation", + "hideOrgMemberDirectory", + "disableCliAccess", + "disableWebhookTriggers", + "disableToolAutoApproval", + "hideSandboxesTab", + "disableOAuthAppAccess", + "disableKnowledgeBaseExport" + ], + "description": "Feature restriction key returned by access discovery." + } + }, + "required": ["kind", "configKey"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integration", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "provider", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "model", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "tool", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "knowledge_connector", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "file_share_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "chat_deploy_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "usage_limit", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "const": "member", + "description": "Request an increase to the acting user’s member credit cap." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + } + ], + "description": "The requested feature, integration, model, tool, authentication mode, or member credit cap." + }, + "targetLabel": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Human-readable name of the requested access." + }, + "reason": { + "type": "string", + "maxLength": 1000, + "description": "Reason supplied by the requester." + }, + "status": { + "type": "string", + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"], + "description": "Current request status." + }, + "decisionReason": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Explanation for a declined or closed request; null when none was recorded." + }, + "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 request was submitted." + }, + "decidedAt": { + "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": "When the request was resolved; null while pending." + }, + "groupName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Name of the governing group when the request was submitted; null for credit-cap requests." + }, + "requester": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Requester user identifier." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Requester display name." + }, + "email": { + "type": "string", + "maxLength": 320, + "description": "Requester email address." + } + }, + "required": ["id", "name", "email"], + "additionalProperties": false, + "description": "User who submitted the request." } + }, + "required": [ + "id", + "organizationId", + "workspaceId", + "target", + "targetLabel", + "reason", + "status", + "decisionReason", + "createdAt", + "decidedAt", + "groupName", + "requester" ], - "nextCursor": null - } - ] - }, - "GetOrganizationResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Organization" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get Organization response", - "description": "Get Organization result.", - "examples": [ - { - "data": { - "id": "org-123", - "name": "Example Organization", - "slug": "example", - "logo": null, - "role": "admin", - "createdAt": "2026-06-01T09:00:00.000Z" - } - } - ] - }, - "V2OrganizationWorkspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Workspace identifier." + "additionalProperties": false, + "description": "Access request being reviewed." }, - "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": { + "changes": { + "maxItems": 42, "type": "array", "items": { - "$ref": "#/components/schemas/V2OrganizationWorkspace" + "type": "object", + "properties": { + "configKey": { + "type": "string", + "enum": [ + "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" + ], + "description": "Permission restriction changed by approval." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Human-readable permission name." + }, + "before": { + "anyOf": [ + { + "type": "boolean" + }, + { + "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": "Current value of the restriction." + }, + "after": { + "anyOf": [ + { + "type": "boolean" + }, + { + "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": "Value after applying the request." + } + }, + "required": ["configKey", "label", "before", "after"], + "additionalProperties": false }, - "description": "Items in the current page." + "description": "Permission changes proposed for the whole governing group." }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "impact": { + "type": "object", + "properties": { + "memberCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected members." + }, + "workspaceCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected workspaces." + }, + "workspaceNames": { + "maxItems": 100, + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of affected workspaces, capped at 100." }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List Organization Workspaces response", - "description": "List Organization Workspaces result.", - "examples": [ - { - "data": [ - { - "id": "workspace-123", - "name": "Engineering" + "truncated": { + "type": "boolean", + "description": "Whether the workspace-name list is truncated; counts include the full impact." } - ], - "nextCursor": null - } - ] - }, - "V2OrganizationMember": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "User identifier; use this identifier to update or remove the member." - }, - "name": { - "type": "string", - "description": "Member display name." - }, - "email": { - "type": "string", - "description": "Member email address." + }, + "required": ["memberCount", "workspaceCount", "workspaceNames", "truncated"], + "additionalProperties": false, + "description": "Members and workspaces affected by approval." }, - "role": { + "fingerprint": { "type": "string", - "enum": ["owner", "admin", "member"], - "description": "Organization role; separate from workspace permissions." + "minLength": 1, + "maxLength": 128, + "description": "Pass to Resolve Organization Access Request after reviewing the changes and impact." }, - "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." + "canApply": { + "type": "boolean", + "description": "Whether this request can currently be approved." }, - "nextCursor": { + "unavailableReason": { "anyOf": [ { - "type": "string" + "type": "string", + "maxLength": 1000 }, { "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List Organization Members response", - "description": "List Organization Members result.", - "examples": [ - { - "data": [ - { - "userId": "user-123", - "name": "Example Member", - "email": "member@example.com", - "role": "member", - "joinedAt": "2026-06-01T09:00:00.000Z" - } - ], - "nextCursor": null - } - ] - }, - "UpdateOrganizationMemberResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationMember" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update Organization Member response", - "description": "Update Organization Member result.", - "examples": [ - { - "data": { - "userId": "user-123", - "name": "Example Member", - "email": "member@example.com", - "role": "admin", - "joinedAt": "2026-06-01T09:00:00.000Z" - } - } - ] - }, - "UpdateOrganizationMemberBody": { - "type": "object", - "properties": { - "role": { - "type": "string", - "enum": ["member", "admin"], - "description": "New organization role. Ownership transfers use a separate operation." - } - }, - "required": ["role"], - "additionalProperties": false, - "title": "Update Organization Member body", - "description": "Update Organization Member input.", - "examples": [ - { - "role": "admin" - } - ] - }, - "V2OrganizationMemberDeletion": { - "type": "object", - "properties": { - "userId": { + "description": "Why approval is unavailable; null when canApply is true." + }, + "resolutionKind": { "type": "string", - "description": "User removed from the organization." + "const": "permission", + "description": "Approval changes the governing permission group." + }, + "group": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Governing group identifier." + }, + "name": { + "type": "string", + "description": "Governing group name." + } + }, + "required": ["id", "name"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Group affected by a permission approval; null for credit-cap changes." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether membership and organization workspace access were removed." - } - }, - "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" + "currentLimitCredits": { + "type": "null", + "description": "Not applicable to permission changes." } }, - "required": ["data"], - "additionalProperties": false, - "title": "Remove Organization Member response", - "description": "Remove Organization Member result.", - "examples": [ - { - "data": { - "userId": "user-123", - "deleted": true - } - } - ] + "required": [ + "newLimitCredits", + "request", + "changes", + "impact", + "fingerprint", + "canApply", + "unavailableReason", + "resolutionKind", + "group", + "currentLimitCredits" + ], + "additionalProperties": false }, - "V2OrganizationInvitation": { + "V2CreditLimitAccessRequestPreview": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Invitation identifier." + "newLimitCredits": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ], + "description": "Applied credit cap for a fulfilled request; null before approval or for permission changes." }, - "organizationId": { - "type": "string", - "description": "Organization that owns the invitation." + "request": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + }, + "organizationId": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the request." + }, + "workspaceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + { + "type": "null" + } + ], + "description": "Workspace where access was requested; null for an organization-level request." + }, + "target": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "feature", + "description": "Kind of access being requested." + }, + "configKey": { + "type": "string", + "enum": [ + "hideTraceSpans", + "hideKnowledgeBaseTab", + "hideTablesTab", + "hideCopilot", + "hideIntegrationsTab", + "hideSecretsTab", + "hideApiKeysTab", + "hideInboxTab", + "hideFilesTab", + "disableMcpTools", + "disableCustomTools", + "disableSkills", + "disableInvitations", + "disablePublicApi", + "disablePublicFileSharing", + "hideDeployApi", + "hideDeployMcp", + "hideDeployChatbot", + "disablePersonalApiKeys", + "disableLogExport", + "hideCostInfo", + "disableKnowledgeBaseCreation", + "disableKnowledgeBaseFileUpload", + "disableTableCreation", + "disableTableExport", + "disableBulkFileDownload", + "disablePersonalCredentials", + "disableWorkspaceCreation", + "hideOrgMemberDirectory", + "disableCliAccess", + "disableWebhookTriggers", + "disableToolAutoApproval", + "hideSandboxesTab", + "disableOAuthAppAccess", + "disableKnowledgeBaseExport" + ], + "description": "Feature restriction key returned by access discovery." + } + }, + "required": ["kind", "configKey"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "integration", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "provider", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "model", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "tool", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "knowledge_connector", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Identifier returned by access discovery." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "file_share_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "chat_deploy_auth", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "Authentication mode to request." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "usage_limit", + "description": "Kind of access being requested." + }, + "id": { + "type": "string", + "const": "member", + "description": "Request an increase to the acting user’s member credit cap." + } + }, + "required": ["kind", "id"], + "additionalProperties": false + } + ], + "description": "The requested feature, integration, model, tool, authentication mode, or member credit cap." + }, + "targetLabel": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Human-readable name of the requested access." + }, + "reason": { + "type": "string", + "maxLength": 1000, + "description": "Reason supplied by the requester." + }, + "status": { + "type": "string", + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"], + "description": "Current request status." + }, + "decisionReason": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Explanation for a declined or closed request; null when none was recorded." + }, + "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 request was submitted." + }, + "decidedAt": { + "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": "When the request was resolved; null while pending." + }, + "groupName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Name of the governing group when the request was submitted; null for credit-cap requests." + }, + "requester": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Requester user identifier." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Requester display name." + }, + "email": { + "type": "string", + "maxLength": 320, + "description": "Requester email address." + } + }, + "required": ["id", "name", "email"], + "additionalProperties": false, + "description": "User who submitted the request." + } + }, + "required": [ + "id", + "organizationId", + "workspaceId", + "target", + "targetLabel", + "reason", + "status", + "decisionReason", + "createdAt", + "decidedAt", + "groupName", + "requester" + ], + "additionalProperties": false, + "description": "Access request being reviewed." + }, + "changes": { + "maxItems": 42, + "type": "array", + "items": { + "type": "object", + "properties": { + "configKey": { + "type": "string", + "enum": [ + "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" + ], + "description": "Permission restriction changed by approval." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Human-readable permission name." + }, + "before": { + "anyOf": [ + { + "type": "boolean" + }, + { + "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": "Current value of the restriction." + }, + "after": { + "anyOf": [ + { + "type": "boolean" + }, + { + "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": "Value after applying the request." + } + }, + "required": ["configKey", "label", "before", "after"], + "additionalProperties": false + }, + "description": "Permission changes proposed for the whole governing group." }, - "email": { - "type": "string", - "description": "Email address of the invitee." + "impact": { + "type": "object", + "properties": { + "memberCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected members." + }, + "workspaceCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected workspaces." + }, + "workspaceNames": { + "maxItems": 100, + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of affected workspaces, capped at 100." + }, + "truncated": { + "type": "boolean", + "description": "Whether the workspace-name list is truncated; counts include the full impact." + } + }, + "required": ["memberCount", "workspaceCount", "workspaceNames", "truncated"], + "additionalProperties": false, + "description": "Members and workspaces affected by approval." }, - "role": { + "fingerprint": { "type": "string", - "enum": ["member", "admin"], - "description": "Organization role offered to an internal invitee." + "minLength": 1, + "maxLength": 128, + "description": "Pass to Resolve Organization Access Request after reviewing the changes and impact." }, - "kind": { - "type": "string", - "enum": ["organization", "workspace"], - "description": "Whether the invitation originated from organization or workspace administration." + "canApply": { + "type": "boolean", + "description": "Whether this request can currently be approved." }, - "membershipIntent": { - "type": "string", - "enum": ["internal", "external"], - "description": "Whether acceptance joins the organization or grants workspace access only." + "unavailableReason": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Why approval is unavailable; null when canApply is true." }, - "status": { + "resolutionKind": { "type": "string", - "enum": ["pending", "accepted", "rejected", "cancelled", "expired"], - "description": "Current invitation status; elapsed pending invitations are reported as expired." + "const": "usage_limit", + "description": "Approval raises the requester’s member credit cap." }, - "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." + "group": { + "type": "null", + "description": "Credit-cap requests do not change a permission group." }, - "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." + "currentLimitCredits": { + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Current member credit cap. Approval requires a higher newLimitCredits." } }, "required": [ - "id", - "organizationId", - "email", - "role", - "kind", - "membershipIntent", - "status", - "createdAt", - "expiresAt" + "newLimitCredits", + "request", + "changes", + "impact", + "fingerprint", + "canApply", + "unavailableReason", + "resolutionKind", + "group", + "currentLimitCredits" ], - "additionalProperties": false, - "title": "Organization invitation", - "description": "Invitation metadata without its acceptance token." + "additionalProperties": false }, - "ListOrganizationInvitationsResponse": { + "PreviewOrganizationAccessRequestResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2OrganizationInvitation" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ + "oneOf": [ { - "type": "string" + "$ref": "#/components/schemas/V2PermissionAccessRequestPreview" }, { - "type": "null" + "$ref": "#/components/schemas/V2CreditLimitAccessRequestPreview" } ], - "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." } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List Organization Invitations response", - "description": "List Organization Invitations result.", + "title": "Preview Organization Access Request response", + "description": "Preview Organization Access Request result.", "examples": [ { - "data": [ - { - "id": "invitation-123", + "data": { + "resolutionKind": "permission", + "request": { + "id": "request-123", "organizationId": "org-123", - "email": "member@example.com", - "role": "member", - "kind": "organization", - "membershipIntent": "internal", + "workspaceId": "workspace-123", + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", "status": "pending", + "decisionReason": null, "createdAt": "2026-06-01T09:00:00.000Z", - "expiresAt": "2026-06-08T09:00:00.000Z" - } - ], - "nextCursor": null + "decidedAt": null, + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } + }, + "group": { + "id": "group-123", + "name": "Engineering" + }, + "changes": [ + { + "configKey": "hideTablesTab", + "label": "Tables", + "before": true, + "after": false + } + ], + "impact": { + "memberCount": 2, + "workspaceCount": 1, + "workspaceNames": ["Engineering"], + "truncated": false + }, + "fingerprint": "current-preview-fingerprint", + "canApply": true, + "unavailableReason": null, + "currentLimitCredits": null, + "newLimitCredits": null + } } ] }, - "CreateOrganizationInvitationResponse": { + "ResolveOrganizationAccessRequestResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitation" + "$ref": "#/components/schemas/V2AccessRequest" } }, "required": ["data"], "additionalProperties": false, - "title": "Create Organization Invitation response", - "description": "Create Organization Invitation result.", + "title": "Resolve Organization Access Request response", + "description": "Resolve Organization Access Request result.", "examples": [ { "data": { - "id": "invitation-123", + "id": "request-123", "organizationId": "org-123", - "email": "member@example.com", - "role": "member", - "kind": "organization", - "membershipIntent": "internal", - "status": "pending", + "workspaceId": "workspace-123", + "target": { + "kind": "feature", + "configKey": "hideTablesTab" + }, + "targetLabel": "Tables", + "reason": "Maintain team data", + "status": "fulfilled", + "decisionReason": null, "createdAt": "2026-06-01T09:00:00.000Z", - "expiresAt": "2026-06-08T09:00:00.000Z" + "decidedAt": "2026-06-01T10:00:00.000Z", + "groupName": "Engineering", + "requester": { + "id": "user-123", + "name": "Alex Example", + "email": "alex@example.com" + } } } ] }, - "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." + "ResolveOrganizationAccessRequestBody": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "apply", + "description": "Apply the reviewed change to the governing group or member credit cap." + }, + "expectedFingerprint": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Fingerprint from Preview Organization Access Request. Review its changes and impact before applying; a stale preview returns a conflict." + }, + "newLimitCredits": { + "description": "Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["action", "expectedFingerprint"], + "additionalProperties": false }, - "role": { - "default": "member", - "description": "Organization role to offer. Defaults to member; grants no workspace-specific permissions.", - "type": "string", - "enum": ["member", "admin"] + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline", + "description": "Decline the request without changing permissions or credit limits." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Required explanation for declining this request." + } + }, + "required": ["action", "reason"], + "additionalProperties": false } - }, - "required": ["email"], - "additionalProperties": false, - "title": "Create Organization Invitation body", - "description": "Create Organization Invitation input.", + ], + "title": "Resolve Organization Access Request body", + "description": "Inputs for this operation.", "examples": [ { - "email": "member@example.com", - "role": "member" + "action": "apply", + "expectedFingerprint": "current-preview-fingerprint" } ] }, - "GetOrganizationInvitationResponse": { + "V2AccessRequestSettings": { + "type": "object", + "properties": { + "allowRequests": { + "type": "boolean", + "description": "Allow new requests and approvals. Disabling requests preserves history and still allows cancellation and decline." + } + }, + "required": ["allowRequests"], + "additionalProperties": false + }, + "GetOrganizationAccessRequestSettingsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitation" + "$ref": "#/components/schemas/V2AccessRequestSettings" } }, "required": ["data"], "additionalProperties": false, - "title": "Get Organization Invitation response", - "description": "Get Organization Invitation result.", + "title": "Get Organization Access Request Settings response", + "description": "Get Organization Access Request Settings 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" + "allowRequests": true } } ] }, - "ResendOrganizationInvitationResponse": { + "UpdateOrganizationAccessRequestSettingsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitation" + "$ref": "#/components/schemas/V2AccessRequestSettings" } }, "required": ["data"], "additionalProperties": false, - "title": "Resend Organization Invitation response", - "description": "Resend Organization Invitation result.", + "title": "Update Organization Access Request Settings response", + "description": "Update Organization Access Request Settings 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" + "allowRequests": false } } ] }, - "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": { + "UpdateOrganizationAccessRequestSettingsBody": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitationRevocation" + "allowRequests": { + "type": "boolean", + "description": "Allow new requests and approvals. Disabling requests preserves history and still allows cancellation and decline." } }, - "required": ["data"], + "required": ["allowRequests"], "additionalProperties": false, - "title": "Revoke Organization Invitation response", - "description": "Revoke Organization Invitation result.", + "title": "Update Organization Access Request Settings body", + "description": "Inputs for this operation.", "examples": [ { - "data": { - "id": "invitation-123", - "status": "cancelled" - } + "allowRequests": false } ] } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3a0008f48f6..14fe1679ca3 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -5023,6 +5023,8 @@ "ENTERPRISE_PLAN_REQUIRED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", "SKILL_EDITOR_ACCESS_REQUIRED", "SECRET_ADMIN_ACCESS_REQUIRED", "WORKSPACE_RESOURCE_LIMIT_REACHED", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 5b7c250dca8..c59b7fcfaa4 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -5619,6 +5619,8 @@ "ENTERPRISE_PLAN_REQUIRED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", "SKILL_EDITOR_ACCESS_REQUIRED", "SECRET_ADMIN_ACCESS_REQUIRED", "WORKSPACE_RESOURCE_LIMIT_REACHED", diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts index d2898b60821..b1895ca3b59 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts @@ -1,40 +1,45 @@ /** * @vitest-environment node */ + +import { db } from '@sim/db' +import { member } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, createSession, + queueTableRows, + resetDbChainMock, resetEnvFlagsMock, setEnvFlags, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockIsOrganizationOwnerOrAdmin, mockGetOrgMemberUsageLimit, mockGetOrgMemberUsageForCurrentPeriod, mockSetOrgMemberUsageLimit, mockGetOrganizationSubscription, + mockIsOrgMemberUsageLimitTarget, } = vi.hoisted(() => ({ - mockIsOrganizationOwnerOrAdmin: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), mockGetOrgMemberUsageForCurrentPeriod: vi.fn(), mockSetOrgMemberUsageLimit: vi.fn(), mockGetOrganizationSubscription: vi.fn(), + mockIsOrgMemberUsageLimitTarget: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/billing/core/organization', () => ({ - isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin, +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null), })) - vi.mock('@/lib/billing/organizations/member-limits', () => ({ getOrgMemberUsageForCurrentPeriod: mockGetOrgMemberUsageForCurrentPeriod, getOrgMemberUsageLimit: mockGetOrgMemberUsageLimit, setOrgMemberUsageLimit: mockSetOrgMemberUsageLimit, + isOrgMemberUsageLimitTarget: mockIsOrgMemberUsageLimitTarget, })) vi.mock('@/lib/billing/core/billing', () => ({ @@ -63,8 +68,13 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isHosted: true }) - mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) - mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) + mockGetSession.mockResolvedValue({ + ...createSession({ userId: 'admin-1' }), + session: { id: 'session-1' }, + }) + resetDbChainMock() + queueTableRows(member, [{ role: 'admin' }]) + mockIsOrgMemberUsageLimitTarget.mockResolvedValue(true) mockGetOrgMemberUsageForCurrentPeriod.mockResolvedValue(1) // $1 -> 200 credits mockGetOrgMemberUsageLimit.mockResolvedValue(2) // $2 -> 400 credits mockGetOrganizationSubscription.mockResolvedValue(null) @@ -83,7 +93,8 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { }) it('returns 403 for non-admin callers', async () => { - mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) + resetDbChainMock() + queueTableRows(member, [{ role: 'member' }]) const res = await GET(getRequest(), context()) expect(res.status).toBe(403) }) @@ -100,6 +111,16 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { }, }) expect(mockGetOrgMemberUsageForCurrentPeriod).toHaveBeenCalledWith('org-1', 'user-2', null) + expect(mockIsOrgMemberUsageLimitTarget).toHaveBeenCalledWith('org-1', 'user-2') + }) + + it('returns 404 before reading a target outside the organization', async () => { + mockIsOrgMemberUsageLimitTarget.mockResolvedValue(false) + const res = await GET(getRequest(), context()) + expect(res.status).toBe(404) + expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled() + expect(mockGetOrganizationSubscription).not.toHaveBeenCalled() + expect(mockGetOrgMemberUsageForCurrentPeriod).not.toHaveBeenCalled() }) it('reuses the fetched org subscription for the usage window', async () => { @@ -141,8 +162,13 @@ describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isHosted: true }) - mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) - mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) + mockGetSession.mockResolvedValue({ + ...createSession({ userId: 'admin-1' }), + session: { id: 'session-1' }, + }) + resetDbChainMock() + queueTableRows(member, [{ role: 'admin' }]) + mockIsOrgMemberUsageLimitTarget.mockResolvedValue(true) mockSetOrgMemberUsageLimit.mockResolvedValue(undefined) }) @@ -154,16 +180,18 @@ describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => { }) it('returns 403 for non-admin callers', async () => { - mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) + resetDbChainMock() + queueTableRows(member, [{ role: 'member' }]) const res = await PUT(putRequest({ creditLimit: 400 }), context()) expect(res.status).toBe(403) expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled() }) it('persists the limit as dollars (credits / 200) and audits', async () => { + queueTableRows(member, [{ role: 'admin' }]) const res = await PUT(putRequest({ creditLimit: 400 }), context()) expect(res.status).toBe(200) - expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', 2, 'admin-1') + expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', 2, 'admin-1', db) expect(auditMock.recordAudit).toHaveBeenCalledTimes(1) await expect(res.json()).resolves.toEqual({ success: true, @@ -173,11 +201,23 @@ describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => { }) it('clears the cap when creditLimit is null', async () => { + queueTableRows(member, [{ role: 'admin' }]) const res = await PUT(putRequest({ creditLimit: null }), context()) expect(res.status).toBe(200) - expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', null, 'admin-1') + expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', null, 'admin-1', db) }) + it.each([400, null])( + 'rejects cap %s for a target outside the organization', + async (creditLimit) => { + mockIsOrgMemberUsageLimitTarget.mockResolvedValue(false) + const res = await PUT(putRequest({ creditLimit }), context()) + expect(res.status).toBe(404) + expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled() + expect(auditMock.recordAudit).not.toHaveBeenCalled() + } + ) + it('rejects a negative credit limit with 400', async () => { const res = await PUT(putRequest({ creditLimit: -5 }), context()) expect(res.status).toBe(400) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts index 419bc2c9ef7..0a6dbcc53e9 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts @@ -1,135 +1,48 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getOrganizationMemberUsageLimitContract, updateOrganizationMemberUsageLimitContract, } from '@/lib/api/contracts/organization' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getOrganizationSubscription } from '@/lib/billing/core/billing' -import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' -import { resolveBillingInterval } from '@/lib/billing/core/subscription' -import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion' import { - getOrgMemberUsageForCurrentPeriod, - getOrgMemberUsageLimit, - setOrgMemberUsageLimit, -} from '@/lib/billing/organizations/member-limits' -import { isHosted } from '@/lib/core/config/env-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('OrgMemberUsageLimitAPI') - -/** - * GET /api/organizations/[id]/members/[memberId]/usage-limit - * - * Returns the member's current-period credits used against this organization - * and their per-member credit cap (both in credits), read through the same - * usage definition the cap enforcement uses. Owner/admin only and hosted-only - * (the feature is meaningless where Sim does not own the DB/billing). - * `memberId` is the target user id, so external members are supported. - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (!isHosted) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const parsed = await parseRequest(getOrganizationMemberUsageLimitContract, request, context) - if (!parsed.success) return parsed.response - - const { id: organizationId, memberId } = parsed.data.params - - const hasAccess = await isOrganizationOwnerOrAdmin(session.user.id, organizationId) - if (!hasAccess) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const [limitDollars, orgSubscription] = await Promise.all([ - getOrgMemberUsageLimit(organizationId, memberId), - getOrganizationSubscription(organizationId), - ]) - const usage = await getOrgMemberUsageForCurrentPeriod(organizationId, memberId, orgSubscription) - - return NextResponse.json({ - success: true, - data: { - creditsUsed: dollarsToCredits(usage), - creditLimit: limitDollars === null ? null : dollarsToCredits(limitDollars), - billingInterval: resolveBillingInterval(orgSubscription), - }, - }) - } -) - -/** - * PUT /api/organizations/[id]/members/[memberId]/usage-limit - * - * Sets (or clears, when `creditLimit` is null) the member's per-org credit cap. - * Owner/admin only and hosted-only. The target need not be an org `member` row, - * so external members are supported. - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (!isHosted) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const parsed = await parseRequest(updateOrganizationMemberUsageLimitContract, request, context) - if (!parsed.success) return parsed.response - - const { id: organizationId, memberId } = parsed.data.params - const { creditLimit } = parsed.data.body - - const hasAccess = await isOrganizationOwnerOrAdmin(session.user.id, organizationId) - if (!hasAccess) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const limitDollars = creditLimit === null ? null : creditsToDollars(creditLimit) - await setOrgMemberUsageLimit(organizationId, memberId, limitDollars, session.user.id) - - logger.info('Updated per-member usage limit', { - organizationId, - memberId, - creditLimit, - updatedBy: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: - creditLimit === null - ? `Cleared credit limit for member ${memberId}` - : `Set credit limit for member ${memberId} to ${creditLimit} credits`, - metadata: { - targetUserId: memberId, - creditLimit, - }, - request, - }) - - return NextResponse.json({ - success: true, - message: 'Member credit limit updated successfully', - data: { creditLimit }, - }) - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalMemberUsageLimitErrorPolicy } from '@/lib/api/server/routes/member-usage-limits' +import { memberUsageLimitOperations } from '@/lib/billing/application/member-usage-limits/operations' +import { + getOrganizationMemberUsageLimit, + requireHostedMemberUsageLimits, + updateOrganizationMemberUsageLimit, +} from '@/lib/billing/application/member-usage-limits/use-cases' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationMemberUsageLimitContract, + operation: memberUsageLimitOperations.read, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Preserve the existing authenticated organization admin read.', + }), + beforeParse: requireHostedMemberUsageLimits, + errorPolicy: internalMemberUsageLimitErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, userId: params.memberId }), + useCase: getOrganizationMemberUsageLimit, + present: (data) => ({ success: true, data }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationMemberUsageLimitContract, + operation: memberUsageLimitOperations.update, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Preserve the existing authenticated organization admin mutation.', + }), + beforeParse: requireHostedMemberUsageLimits, + errorPolicy: internalMemberUsageLimitErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + userId: params.memberId, + creditLimit: body.creditLimit, + }), + useCase: updateOrganizationMemberUsageLimit, + present: (data) => ({ success: true, message: 'Member credit limit updated successfully', data }), +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/error-policy.test.ts b/apps/sim/app/api/organizations/[id]/usage/error-policy.test.ts new file mode 100644 index 00000000000..7be86f025fd --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/error-policy.test.ts @@ -0,0 +1,34 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { + UsageWindowRangeInvertedError, + UsageWindowRangeTooLargeError, +} from '@/lib/billing/core/usage-analytics' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrganizationMembershipNotFoundError } from '@/lib/core/application/organization-authorization' +import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy' + +describe('internal organization usage error compatibility', () => { + it.each([ + new OrganizationMembershipNotFoundError(), + new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Organization administrator access is required' + ), + ])('preserves non-admin 403 responses after shared authorization migration', (error) => { + expect(organizationUsageErrorPolicy.project(error)).toEqual({ + status: 403, + body: { error: 'Organization admin or owner authority is required to read pooled usage' }, + }) + }) + + it.each([new UsageWindowRangeInvertedError(), new UsageWindowRangeTooLargeError(100)])( + 'preserves invalid custom range responses', + (error) => { + expect(organizationUsageErrorPolicy.project(error)).toEqual({ + status: 400, + body: { error: error.message }, + }) + } + ) +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/error-policy.ts b/apps/sim/app/api/organizations/[id]/usage/error-policy.ts index 862fc6dd554..33e86b28df5 100644 --- a/apps/sim/app/api/organizations/[id]/usage/error-policy.ts +++ b/apps/sim/app/api/organizations/[id]/usage/error-policy.ts @@ -7,21 +7,25 @@ import { UsageWindowRangeInvertedError, UsageWindowRangeTooLargeError, } from '@/lib/billing/core/usage-analytics' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrganizationMembershipNotFoundError } from '@/lib/core/application/organization-authorization' -/** - * The window resolver throws when a custom range exceeds its cap or ends before it -begins, both of which are - * caller-fixable input error rather than a fault. Without this it fell through to - * the orchestration policy's `unhandled` branch and every over-long range answered - * `500 Internal server error`, so the client could neither surface the real reason - * nor tell the two apart. - * - * Shared by all four usage routes so they cannot classify the same throw differently. - */ +/** Preserve internal organization-access refusals and classify invalid reporting windows. */ export const organizationUsageErrorPolicy = extendInternalErrorPolicy( internalOrchestrationErrorPolicy, - (error) => - error instanceof UsageWindowRangeTooLargeError || error instanceof UsageWindowRangeInvertedError + (error) => { + if ( + error instanceof OrganizationMembershipNotFoundError || + (error instanceof ForbiddenOperationError && + error.detailCode === 'ORGANIZATION_ADMIN_REQUIRED') + ) { + return internalErrorResponse(403, { + error: 'Organization admin or owner authority is required to read pooled usage', + }) + } + return error instanceof UsageWindowRangeTooLargeError || + error instanceof UsageWindowRangeInvertedError ? internalErrorResponse(400, { error: error.message }) : null + } ) diff --git a/apps/sim/app/api/permission-groups/user/route.test.ts b/apps/sim/app/api/permission-groups/user/route.test.ts index f58c021e342..ed710a8b3bd 100644 --- a/apps/sim/app/api/permission-groups/user/route.test.ts +++ b/apps/sim/app/api/permission-groups/user/route.test.ts @@ -179,13 +179,13 @@ describe('user permission policy shared read', () => { readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } }) ).rejects.toThrow('unavailable') }) - it('rejects API keys before canonical lookup on the shared server entry point', async () => { + it('rejects actorless workspace keys before canonical lookup on the shared server entry point', async () => { await expect( readUserPermissionConfig.execute({ - principal: { kind: 'personal_api_key', userId: 'viewer', keyId: 'key' }, + principal: { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, input: { workspaceId: 'workspace' }, }) - ).rejects.toThrow('cannot perform operation') + ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) expect(mocks.context).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/permission-groups/user/route.ts b/apps/sim/app/api/permission-groups/user/route.ts index 4fb90f82005..23642eeefe4 100644 --- a/apps/sim/app/api/permission-groups/user/route.ts +++ b/apps/sim/app/api/permission-groups/user/route.ts @@ -9,15 +9,13 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' -import { - readUserPermissionConfig, - readUserPermissionConfigOperation, -} from '@/lib/permission-groups/application/read-user-config' +import { permissionGroupWorkspaceOperations } from '@/lib/permission-groups/application/operations' +import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' export const GET = defineInternalJsonRoute({ contract: getUserPermissionConfigContract, auth: internalSessionAuth, - operation: readUserPermissionConfigOperation, + operation: permissionGroupWorkspaceOperations.readUserConfig, rateLimit: internalRateLimits.none({ reason: 'Preserve the existing internal policy read rate.', }), diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts index eefad3d2827..b6892c2ce9c 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -10,6 +10,7 @@ import { schemaMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { mockDetachOrganizationWorkspacesTx, @@ -197,4 +198,23 @@ describe('admin organization DELETE', () => { expect(recordAudit).not.toHaveBeenCalled() expect(recordAuditBatch).not.toHaveBeenCalled() }) + + it('returns a retryable conflict if the workspace lock set changed', async () => { + queueOrganization() + queueTableRows(schemaMock.subscription, []) + queueTableRows(schemaMock.member, [{ value: 3 }]) + const message = 'Organization workspaces changed during detachment; retry' + mockDetachOrganizationWorkspacesTx.mockRejectedValueOnce( + new OrchestrationError('conflict', message) + ) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ error: { message } }) + expect(mockEnqueueResourceCleanup).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + expect(recordAuditBatch).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index d33652870ae..5bb566fbdc1 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -57,6 +57,7 @@ import { ENTITLED_SUBSCRIPTION_STATUSES, TERMINAL_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enqueueOrganizationResourceCleanup } from '@/lib/organizations/resource-cleanup' import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces' @@ -338,6 +339,9 @@ export const DELETE = withRouteHandler( }) } catch (error) { logger.error('Admin API: Failed to delete organization', { error, organizationId }) + if (error instanceof OrchestrationError && error.code === 'conflict') { + return conflictResponse(error.message) + } return internalErrorResponse('Failed to delete organization') } }) diff --git a/apps/sim/app/api/v2/access-requests.routes.test.ts b/apps/sim/app/api/v2/access-requests.routes.test.ts new file mode 100644 index 00000000000..05d2bbf3712 --- /dev/null +++ b/apps/sim/app/api/v2/access-requests.routes.test.ts @@ -0,0 +1,593 @@ +/** @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + discover: vi.fn(), + listMine: vi.fn(), + create: vi.fn(), + cancel: vi.fn(), + listOrganization: vi.fn(), + preview: vi.fn(), + resolve: vi.fn(), + getSettings: vi.fn(), + updateSettings: vi.fn(), +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/ee/access-requests/lib/application/requests', () => ({ + discoverAccessRequests: { + operation: { id: 'access_requests.discover' }, + execute: mocks.discover, + }, + listMyAccessRequests: { + operation: { id: 'access_requests.list_mine' }, + execute: mocks.listMine, + }, + createAccessRequest: { operation: { id: 'access_requests.create' }, execute: mocks.create }, + cancelAccessRequest: { operation: { id: 'access_requests.cancel' }, execute: mocks.cancel }, + listOrganizationAccessRequests: { + operation: { id: 'access_requests.list_organization' }, + execute: mocks.listOrganization, + }, + getAccessRequestSettings: { + operation: { id: 'access_requests.get_settings' }, + execute: mocks.getSettings, + }, + updateAccessRequestSettings: { + operation: { id: 'access_requests.update_settings' }, + execute: mocks.updateSettings, + }, +})) +vi.mock('@/ee/access-requests/lib/application/review', () => ({ + previewAccessRequest: { + operation: { id: 'access_requests.preview' }, + execute: mocks.preview, + }, + resolveAccessRequest: { + operation: { id: 'access_requests.resolve' }, + execute: mocks.resolve, + }, +})) + +import type { + AccessRequestDiscoveryEntry, + AccessRequestPreviewResponse, + AccessRequestRecord, +} from '@/lib/api/contracts/access-requests' +import type { JsonNextRouteHandler } from '@/lib/api/server/routes/types' +import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST as cancelOrganization } from '@/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel/route' +import { GET as previewOrganization } from '@/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview/route' +import { POST as resolveOrganization } from '@/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve/route' +import { GET as discoverOrganization } from '@/app/api/v2/organizations/[organizationId]/access-requests/discovery/route' +import { GET as listMyOrganization } from '@/app/api/v2/organizations/[organizationId]/access-requests/mine/route' +import { + POST as createOrganization, + GET as listOrganization, +} from '@/app/api/v2/organizations/[organizationId]/access-requests/route' +import { + GET as getSettings, + PATCH as updateSettings, +} from '@/app/api/v2/organizations/[organizationId]/access-requests/settings/route' +import { POST as cancelWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel/route' +import { GET as discoverWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/access-requests/discovery/route' +import { + POST as createWorkspace, + GET as listWorkspace, +} from '@/app/api/v2/workspaces/[workspaceId]/access-requests/route' + +const principal = { kind: 'personal_api_key', userId: 'caller', keyId: 'key' } as const +const organizationId = 'organization-123' +const workspaceId = 'workspace-123' +const requestId = 'request-123' +const workspaceScope = { kind: 'workspace', workspaceId } as const +const organizationScope = { kind: 'organization', organizationId } as const +const target = { kind: 'integration', id: 'slack' } as const +const record: AccessRequestRecord = { + id: requestId, + organizationId, + workspaceId, + target, + targetLabel: 'Slack', + reason: 'Coordinate incident response', + status: 'pending', + decisionReason: null, + createdAt: '2026-09-21T10:00:00.000Z', + decidedAt: null, + groupName: 'Engineering', + requester: { id: principal.userId, name: 'Caller', email: 'caller@example.com' }, +} +const entry: AccessRequestDiscoveryEntry = { + target, + label: 'Slack', + state: 'requestable', + reason: null, + pendingRequestId: null, +} +const preview: AccessRequestPreviewResponse = { + resolutionKind: 'permission', + request: record, + group: { id: 'group-123', name: 'Engineering' }, + changes: [], + impact: { memberCount: 4, workspaceCount: 1, workspaceNames: ['Production'], truncated: false }, + currentLimitCredits: null, + newLimitCredits: null, + fingerprint: 'reviewed-fingerprint', + canApply: true, + unavailableReason: null, +} + +interface RouteCase { + name: string + handler: JsonNextRouteHandler + method: 'GET' | 'POST' | 'PATCH' + params: Record + execute: ReturnType + body?: unknown +} + +const listCases = [ + { + name: 'workspace history', + handler: listWorkspace, + method: 'GET', + params: { workspaceId }, + execute: mocks.listMine, + }, + { + name: 'organization caller history', + handler: listMyOrganization, + method: 'GET', + params: { organizationId }, + execute: mocks.listMine, + }, + { + name: 'organization admin list', + handler: listOrganization, + method: 'GET', + params: { organizationId }, + execute: mocks.listOrganization, + }, +] satisfies RouteCase[] +const discoveryCases = [ + { + name: 'workspace discovery', + handler: discoverWorkspace, + method: 'GET', + params: { workspaceId }, + execute: mocks.discover, + scope: workspaceScope, + }, + { + name: 'organization discovery', + handler: discoverOrganization, + method: 'GET', + params: { organizationId }, + execute: mocks.discover, + scope: organizationScope, + }, +] satisfies (RouteCase & { scope: typeof workspaceScope | typeof organizationScope })[] +const createCases = [ + { + name: 'workspace create', + handler: createWorkspace, + method: 'POST', + params: { workspaceId }, + execute: mocks.create, + body: { target }, + scope: workspaceScope, + }, + { + name: 'organization create', + handler: createOrganization, + method: 'POST', + params: { organizationId }, + execute: mocks.create, + body: { target }, + scope: organizationScope, + }, +] satisfies (RouteCase & { scope: typeof workspaceScope | typeof organizationScope })[] +const cancelCases = [ + { + name: 'workspace cancel', + handler: cancelWorkspace, + method: 'POST', + params: { workspaceId, requestId }, + execute: mocks.cancel, + scope: workspaceScope, + }, + { + name: 'organization cancel', + handler: cancelOrganization, + method: 'POST', + params: { organizationId, requestId }, + execute: mocks.cancel, + scope: organizationScope, + }, +] satisfies (RouteCase & { scope: typeof workspaceScope | typeof organizationScope })[] +const previewCase: RouteCase = { + name: 'organization preview', + handler: previewOrganization, + method: 'GET', + params: { organizationId, requestId }, + execute: mocks.preview, +} +const resolveCase: RouteCase = { + name: 'organization resolve', + handler: resolveOrganization, + method: 'POST', + params: { organizationId, requestId }, + execute: mocks.resolve, + body: { action: 'apply', expectedFingerprint: preview.fingerprint }, +} +const settingsCases: RouteCase[] = [ + { + name: 'organization settings read', + handler: getSettings, + method: 'GET', + params: { organizationId }, + execute: mocks.getSettings, + }, + { + name: 'organization settings update', + handler: updateSettings, + method: 'PATCH', + params: { organizationId }, + execute: mocks.updateSettings, + body: { allowRequests: false }, + }, +] +const allCases = [ + ...listCases, + ...discoveryCases, + ...createCases, + ...cancelCases, + previewCase, + resolveCase, + ...settingsCases, +] + +function call( + route: RouteCase, + options: { query?: string; body?: unknown; params?: Record } = {} +) { + const body = options.body ?? route.body + return route.handler( + new NextRequest(`http://localhost/api/v2/access-requests${options.query ?? ''}`, { + method: route.method, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { 'content-type': 'application/json' }, + }), + { params: Promise.resolve(options.params ?? route.params) } + ) +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + principal, + keyType: 'personal', + rateLimitSubjectIds: ['user:caller'], + rateLimitSubscription: null, + }) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.discover.mockResolvedValue({ entries: [entry], hasMore: false }) + mocks.listMine.mockResolvedValue({ requests: [record], nextCursorKeys: null }) + mocks.listOrganization.mockResolvedValue({ requests: [record], nextCursorKeys: null }) + mocks.create.mockResolvedValue({ request: record }) + mocks.cancel.mockResolvedValue({ request: { ...record, status: 'cancelled' } }) + mocks.preview.mockResolvedValue(preview) + mocks.resolve.mockResolvedValue({ request: { ...record, status: 'fulfilled' } }) + mocks.getSettings.mockResolvedValue({ allowRequests: true }) + mocks.updateSettings.mockResolvedValue({ allowRequests: false }) +}) + +describe('public access-request adapters', () => { + it.each(allCases)('$name authenticates before parsing or executing', async (route) => { + v2RouteMocks.authenticate.mockRejectedValue( + new v2ApiKeyAuthModuleMock.V2ApiKeyUnauthenticatedError() + ) + const response = await call(route, { query: '?userId=other-user' }) + expect(response.status).toBe(401) + expect(await response.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } }) + expect(route.execute).not.toHaveBeenCalled() + }) + + it.each(allCases)('$name rejects undeclared query fields before executing', async (route) => { + const response = await call(route, { query: '?userId=other-user' }) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } }) + expect(route.execute).not.toHaveBeenCalled() + }) + + it.each(allCases)( + '$name forwards the authenticated actor and returns the v2 envelope', + async (route) => { + const response = await call(route) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(route.execute).toHaveBeenCalledWith(expect.objectContaining({ principal })) + expect(await response.json()).toHaveProperty('data') + } + ) + + it.each([...listCases, ...discoveryCases])( + '$name rejects fractional and out-of-range limits', + async (route) => { + for (const limit of ['1.5', '0', '101', '-1']) { + expect((await call(route, { query: `?limit=${limit}` })).status).toBe(400) + } + expect(route.execute).not.toHaveBeenCalled() + } + ) + + it.each(createCases)( + '$name derives scope exclusively from the path and defaults an omitted reason', + async (route) => { + const response = await call(route) + expect(await response.json()).toEqual({ data: record }) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ input: { scope: route.scope, target, reason: '' } }) + ) + mocks.create.mockClear() + for (const body of [ + { target, scope: { kind: 'organization', organizationId: 'other-org' } }, + { target, userId: 'other-user' }, + { target: { ...target, unexpected: true } }, + { target: { kind: 'unsupported', id: 'slack' } }, + { target, reason: 'a'.repeat(1001) }, + ]) { + expect((await call(route, { body })).status).toBe(400) + } + expect(mocks.create).not.toHaveBeenCalled() + } + ) + + it.each(cancelCases)( + '$name accepts an empty HTTP body and passes the asserted scope', + async (route) => { + const response = await call(route) + expect(await response.json()).toEqual({ data: { ...record, status: 'cancelled' } }) + expect(mocks.cancel).toHaveBeenCalledWith( + expect.objectContaining({ input: { requestId, scope: route.scope } }) + ) + expect((await call(route, { body: {} })).status).toBe(200) + } + ) + + it('keeps caller history separate from organization administrator listing', async () => { + await call(listCases[0], { query: '?status=pending&sortBy=targetLabel&sortOrder=asc&limit=3' }) + expect(mocks.listMine).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: { + scope: workspaceScope, + status: 'pending', + limit: 3, + offset: 0, + paging: { sortBy: 'targetLabel', sortOrder: 'asc', cursorKeys: undefined }, + }, + }) + ) + await call(listCases[1]) + expect(mocks.listMine).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ scope: organizationScope }) }) + ) + expect(mocks.listOrganization).not.toHaveBeenCalled() + const response = await call(listCases[2], { query: '?search=Caller&status=pending' }) + expect(await response.json()).toEqual({ data: [record], nextCursor: null }) + expect(mocks.listOrganization).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + organizationId, + search: 'Caller', + status: 'pending', + limit: 50, + offset: 0, + paging: { sortBy: 'createdAt', sortOrder: 'desc', cursorKeys: undefined }, + }, + }) + ) + expect((await call(listCases[0], { query: '?search=Caller' })).status).toBe(400) + expect((await call(listCases[1], { query: '?search=Caller' })).status).toBe(400) + }) + + it.each(discoveryCases)('$name maps bounded discovery filters and scope', async (route) => { + const response = await call(route, { + query: '?search=Slack&targetKind=integration&state=requestable&limit=3&sortOrder=desc', + }) + expect(await response.json()).toEqual({ data: [entry], nextCursor: null }) + expect(mocks.discover).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + ...route.scope, + search: 'Slack', + targetKind: 'integration', + state: 'requestable', + limit: 3, + sortBy: 'label', + sortOrder: 'desc', + offset: 0, + }), + }) + ) + }) + + it.each(listCases)( + '$name resumes a keyset cursor and rejects scope, sort, or filter rebinding', + async (route) => { + const keys = [record.createdAt, requestId] + route.execute.mockResolvedValueOnce({ requests: [record], nextCursorKeys: keys }) + const first = await call(route, { query: '?status=pending&limit=1' }) + const { nextCursor } = await first.json() + expect(nextCursor).toEqual(expect.any(String)) + const cursorQuery = `?status=pending&cursor=${encodeURIComponent(nextCursor)}` + const second = await call(route, { query: cursorQuery }) + expect(second.status).toBe(200) + expect(await second.json()).toEqual({ data: [record], nextCursor: null }) + expect(route.execute).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + paging: { sortBy: 'createdAt', sortOrder: 'desc', cursorKeys: keys }, + }), + }) + ) + route.execute.mockClear() + for (const query of [ + `${cursorQuery}&sortBy=targetLabel`, + `${cursorQuery}&sortOrder=asc`, + cursorQuery.replace('status=pending', 'status=declined'), + '?cursor=not-a-cursor', + ]) { + expect((await call(route, { query })).status).toBe(400) + } + const scopeParams = + 'workspaceId' in route.params + ? { workspaceId: 'other-workspace' } + : { organizationId: 'other-org' } + expect((await call(route, { query: cursorQuery, params: scopeParams })).status).toBe(400) + if (route.execute === mocks.listOrganization) { + expect((await call(route, { query: `${cursorQuery}&search=other` })).status).toBe(400) + expect((await call(listCases[1], { query: cursorQuery })).status).toBe(400) + expect(mocks.listMine).not.toHaveBeenCalled() + } + expect(route.execute).not.toHaveBeenCalled() + } + ) + + it.each(discoveryCases)( + '$name binds its offset cursor to filters, sorting, and scope', + async (route) => { + mocks.discover.mockResolvedValueOnce({ entries: [entry], hasMore: true }) + const query = '?search=Slack&targetKind=integration&state=requestable&limit=1' + const first = await call(route, { query }) + const { nextCursor } = await first.json() + expect(nextCursor).toEqual(expect.any(String)) + const cursorQuery = `${query}&cursor=${encodeURIComponent(nextCursor)}` + const second = await call(route, { query: cursorQuery }) + expect(await second.json()).toEqual({ data: [entry], nextCursor: null }) + expect(mocks.discover).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ offset: 1 }) }) + ) + mocks.discover.mockClear() + for (const rebound of [ + `${cursorQuery}&sortOrder=desc`, + cursorQuery.replace('search=Slack', 'search=GitHub'), + cursorQuery.replace('targetKind=integration', 'targetKind=model'), + cursorQuery.replace('state=requestable', 'state=allowed'), + '?cursor=not-a-cursor', + ]) { + expect((await call(route, { query: rebound })).status).toBe(400) + } + const scopeParams = + 'workspaceId' in route.params + ? { workspaceId: 'other-workspace' } + : { organizationId: 'other-org' } + expect((await call(route, { query: cursorQuery, params: scopeParams })).status).toBe(400) + const otherScope = route.handler === discoverWorkspace ? discoveryCases[1] : discoveryCases[0] + expect((await call(otherScope, { query: cursorQuery })).status).toBe(400) + expect(mocks.discover).not.toHaveBeenCalled() + } + ) + + it('returns the full preview under the v2 data envelope', async () => { + const response = await call(previewCase) + expect(await response.json()).toEqual({ data: preview }) + expect(mocks.preview).toHaveBeenCalledWith( + expect.objectContaining({ input: { organizationId, requestId } }) + ) + }) + + it.each([ + { action: 'apply', expectedFingerprint: preview.fingerprint }, + { action: 'apply', expectedFingerprint: preview.fingerprint, newLimitCredits: 500 }, + { action: 'decline', reason: 'Please use the approved integration' }, + ])('passes the exact discriminated review decision to the shared use case', async (decision) => { + const response = await call(resolveCase, { body: decision }) + expect(await response.json()).toEqual({ data: { ...record, status: 'fulfilled' } }) + expect(mocks.resolve).toHaveBeenCalledWith( + expect.objectContaining({ input: { organizationId, requestId, decision } }) + ) + }) + + it.each([ + { action: 'approve', expectedFingerprint: preview.fingerprint }, + { action: 'apply' }, + { action: 'apply', expectedFingerprint: '' }, + { action: 'apply', expectedFingerprint: 'x'.repeat(129) }, + { action: 'apply', expectedFingerprint: preview.fingerprint, reason: 'wrong branch' }, + ...[0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '500', null].map((newLimitCredits) => ({ + action: 'apply', + expectedFingerprint: preview.fingerprint, + newLimitCredits, + })), + { action: 'decline' }, + { action: 'decline', reason: ' ' }, + { action: 'decline', reason: 'x'.repeat(1001) }, + { action: 'decline', reason: 'No', expectedFingerprint: preview.fingerprint }, + { action: 'decline', reason: 'No', newLimitCredits: 500 }, + ])('rejects invalid or mixed review decisions before applying any change', async (body) => { + expect((await call(resolveCase, { body })).status).toBe(400) + expect(mocks.resolve).not.toHaveBeenCalled() + }) + + it('preserves a stale-preview conflict instead of retrying the mutation', async () => { + mocks.resolve.mockRejectedValue(new OrchestrationError('conflict', 'Preview changed')) + const response = await call(resolveCase) + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { code: 'CONFLICT', message: 'Preview changed' }, + }) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + }) + + it('reads and updates the exact organization request setting', async () => { + expect(await (await call(settingsCases[0])).json()).toEqual({ data: { allowRequests: true } }) + expect(mocks.getSettings).toHaveBeenCalledWith( + expect.objectContaining({ input: { organizationId } }) + ) + expect(await (await call(settingsCases[1])).json()).toEqual({ data: { allowRequests: false } }) + expect(mocks.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ input: { organizationId, allowRequests: false } }) + ) + mocks.updateSettings.mockClear() + for (const body of [ + {}, + { allowRequests: 'false' }, + { allowRequests: false, organizationId: 'other' }, + ]) { + expect((await call(settingsCases[1], { body })).status).toBe(400) + } + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it.each([ + new NoWorkspaceAccessError(), + new OrchestrationError('not_found', 'Workspace not found'), + ])('conceals inaccessible and missing scopes consistently', async (error) => { + mocks.listMine.mockRejectedValue(error) + const response = await call(listCases[0]) + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Access request scope not found' }, + }) + }) + + it('preserves the explicit workspace-key refusal code', async () => { + mocks.create.mockRejectedValue(new WorkspaceApiKeyAuthorizationError()) + const response = await call(createCases[0]) + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' } }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel/route.ts new file mode 100644 index 00000000000..001115e71a6 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel/route.ts @@ -0,0 +1,19 @@ +import { v2CancelOrganizationAccessRequestContract } from '@/lib/api/contracts/v2/access-requests' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { cancelAccessRequest } from '@/ee/access-requests/lib/application/requests' + +export const POST = defineV2JsonRoute({ + contract: v2CancelOrganizationAccessRequestContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.cancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params }) => ({ + requestId: params.requestId, + scope: { kind: 'organization' as const, organizationId: params.organizationId }, + }), + useCase: cancelAccessRequest, + present: ({ request }) => ({ data: request }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview/route.ts new file mode 100644 index 00000000000..3f2b8a1501d --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview/route.ts @@ -0,0 +1,16 @@ +import { v2PreviewOrganizationAccessRequestContract } from '@/lib/api/contracts/v2/access-requests' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { previewAccessRequest } from '@/ee/access-requests/lib/application/review' + +export const GET = defineV2JsonRoute({ + contract: v2PreviewOrganizationAccessRequestContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.preview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params }) => params, + useCase: previewAccessRequest, + present: (preview) => ({ data: preview }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve/route.ts new file mode 100644 index 00000000000..cba6896188e --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve/route.ts @@ -0,0 +1,16 @@ +import { v2ResolveOrganizationAccessRequestContract } from '@/lib/api/contracts/v2/access-requests' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { resolveAccessRequest } from '@/ee/access-requests/lib/application/review' + +export const POST = defineV2JsonRoute({ + contract: v2ResolveOrganizationAccessRequestContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.resolve, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, decision: body }), + useCase: resolveAccessRequest, + present: ({ request }) => ({ data: request }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/discovery/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/discovery/route.ts new file mode 100644 index 00000000000..9bd60b01fe9 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/discovery/route.ts @@ -0,0 +1,51 @@ +import { v2DiscoverOrganizationAccessRequestsContract } from '@/lib/api/contracts/v2/access-requests' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { discoverAccessRequests } from '@/ee/access-requests/lib/application/requests' + +function cursorFilters( + params: { organizationId: string }, + query: { search?: string; targetKind?: string; state?: string } +) { + return cursorScopeKey(cursorRoute(v2DiscoverOrganizationAccessRequestsContract, params), { + search: query.search, + targetKind: query.targetKind, + state: query.state, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2DiscoverOrganizationAccessRequestsContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, query }) => ({ + ...query, + kind: 'organization' as const, + organizationId: params.organizationId, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + cursorFilters(params, query) + ), + }), + useCase: discoverAccessRequests, + present: ({ entries, hasMore }, { params, query }) => ({ + data: entries, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + cursorFilters(params, query), + decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + cursorFilters(params, query) + ) + entries.length + ) + : null, + }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/mine/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/mine/route.ts new file mode 100644 index 00000000000..7b4110cba8f --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/mine/route.ts @@ -0,0 +1,47 @@ +import { v2ListMyOrganizationAccessRequestsContract } from '@/lib/api/contracts/v2/access-requests' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { listMyAccessRequests } from '@/ee/access-requests/lib/application/requests' + +function cursorFilters(params: { organizationId: string }, query: { status?: string }) { + return cursorScopeKey(cursorRoute(v2ListMyOrganizationAccessRequestsContract, params), { + status: query.status, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListMyOrganizationAccessRequestsContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.listMine, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, query }) => ({ + scope: { kind: 'organization' as const, organizationId: params.organizationId }, + limit: query.limit, + offset: 0, + status: query.status, + paging: { + sortBy: query.sortBy, + sortOrder: query.sortOrder, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }, + }), + useCase: listMyAccessRequests, + present: ({ requests, nextCursorKeys }, { params, query }) => ({ + data: requests, + nextCursor: writeSortedCursor( + nextCursorKeys ?? null, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/route.ts new file mode 100644 index 00000000000..67f0ed9c650 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/route.ts @@ -0,0 +1,72 @@ +import { + v2CreateOrganizationAccessRequestContract, + v2ListOrganizationAccessRequestsContract, +} from '@/lib/api/contracts/v2/access-requests' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { + createAccessRequest, + listOrganizationAccessRequests, +} from '@/ee/access-requests/lib/application/requests' + +function cursorFilters( + params: { organizationId: string }, + query: { search?: string; status?: string } +) { + return cursorScopeKey(cursorRoute(v2ListOrganizationAccessRequestsContract, params), { + search: query.search, + status: query.status, + }) +} + +export const POST = defineV2JsonRoute({ + contract: v2CreateOrganizationAccessRequestContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, body }) => ({ + ...body, + scope: { kind: 'organization' as const, organizationId: params.organizationId }, + }), + useCase: createAccessRequest, + present: ({ request }) => ({ data: request }), +}) + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationAccessRequestsContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.listOrganization, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + limit: query.limit, + offset: 0, + search: query.search, + status: query.status, + paging: { + sortBy: query.sortBy, + sortOrder: query.sortOrder, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }, + }), + useCase: listOrganizationAccessRequests, + present: ({ requests, nextCursorKeys }, { params, query }) => ({ + data: requests, + nextCursor: writeSortedCursor( + nextCursorKeys ?? null, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/settings/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/settings/route.ts new file mode 100644 index 00000000000..edb5f0f1247 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/access-requests/settings/route.ts @@ -0,0 +1,33 @@ +import { + v2GetOrganizationAccessRequestSettingsContract, + v2UpdateOrganizationAccessRequestSettingsContract, +} from '@/lib/api/contracts/v2/access-requests' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { + getAccessRequestSettings, + updateAccessRequestSettings, +} from '@/ee/access-requests/lib/application/requests' + +export const GET = defineV2JsonRoute({ + contract: v2GetOrganizationAccessRequestSettingsContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.getSettings, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params }) => params, + useCase: getAccessRequestSettings, + present: (settings) => ({ data: settings }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateOrganizationAccessRequestSettingsContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.updateSettings, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateAccessRequestSettings, + present: (settings) => ({ data: settings }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces/route.test.ts b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces/route.test.ts new file mode 100644 index 00000000000..fa322d244f4 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces/route.test.ts @@ -0,0 +1,215 @@ +/** @vitest-environment node */ +import type { Principal } from '@sim/auth/principal' +import { invitation, invitationWorkspaceGrant, member, workspace } from '@sim/db/schema' +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ config: vi.fn() })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mocks.config, +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET } from '@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces/route' + +const principal: Principal = { kind: 'personal_api_key', userId: 'actor', keyId: 'key' } +const params = { organizationId: 'organization', invitationId: 'invitation' } +const rows = [ + { id: 'workspace-a', name: 'Engineering', permission: 'write', archivedAt: null }, + { + id: 'workspace-b', + name: 'Engineering', + permission: 'read', + archivedAt: new Date('2026-01-01'), + }, +] + +function setPrincipal(value: Principal) { + v2RouteMocks.authenticate.mockResolvedValue({ + principal: value, + keyType: value.kind === 'workspace_api_key' ? 'workspace' : 'personal', + rateLimitSubjectIds: ['key:key'], + rateLimitSubscription: null, + }) +} + +function request(query = '', scope = params) { + return GET( + new NextRequest( + `http://localhost/api/v2/organizations/${scope.organizationId}/invitations/${scope.invitationId}/workspaces${query}`, + { headers: { 'x-api-key': 'key', 'x-forwarded-for': '127.0.0.1' } } + ), + { params: Promise.resolve(scope) } + ) +} + +function queueAuthorized(status = 'pending', grants = rows) { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(invitation, [{ id: 'invitation', organizationId: 'organization', status }]) + queueTableRows(invitationWorkspaceGrant, grants) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setPrincipal(principal) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.config.mockResolvedValue(null) +}) + +describe('organization invitation workspace grants', () => { + it.each(['pending', 'accepted', 'rejected', 'cancelled', 'expired'])( + 'inspects retained grants on a %s invitation without exposing tokens', + async (status) => { + queueAuthorized(status) + const response = await request() + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(await response.json()).toEqual({ + data: [rows[0], { ...rows[1], archivedAt: '2026-01-01T00:00:00.000Z' }], + nextCursor: null, + }) + expect(dbChainMockFns.select).toHaveBeenLastCalledWith({ + id: workspace.id, + name: workspace.name, + permission: invitationWorkspaceGrant.permission, + archivedAt: workspace.archivedAt, + }) + } + ) + + it('returns an empty page for an invitation without workspace grants', async () => { + queueAuthorized('pending', []) + const response = await request() + expect(await response.json()).toEqual({ data: [], nextCursor: null }) + }) + + it('bounds pages and retains a unique workspace ID tiebreaker', async () => { + queueAuthorized() + const first = await request('?limit=1') + const payload = await first.json() + expect(payload.data).toEqual([rows[0]]) + expect(payload.nextCursor).toEqual(expect.any(String)) + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(2) + queueAuthorized('expired', [rows[1]]) + const next = await request(`?limit=5&cursor=${encodeURIComponent(payload.nextCursor)}`) + expect(await next.json()).toEqual({ + data: [{ ...rows[1], archivedAt: '2026-01-01T00:00:00.000Z' }], + nextCursor: null, + }) + expect(dbChainMockFns.orderBy).toHaveBeenLastCalledWith( + { type: 'asc', column: workspace.name }, + { type: 'asc', column: workspace.id } + ) + }) + + it('binds a cursor to the organization, invitation, search, and sort', async () => { + queueAuthorized() + const payload = await (await request('?limit=1&search=Engineering')).json() + const cursor = encodeURIComponent(payload.nextCursor) + for (const [query, scope] of [ + [`?search=Other&cursor=${cursor}`, params], + [`?search=Engineering&sortBy=id&cursor=${cursor}`, params], + [`?search=Engineering&sortOrder=desc&cursor=${cursor}`, params], + [`?search=Engineering&cursor=${cursor}`, { ...params, invitationId: 'another' }], + [`?search=Engineering&cursor=${cursor}`, { ...params, organizationId: 'another' }], + ] as const) { + const selects = dbChainMockFns.select.mock.calls.length + expect((await request(query, scope)).status).toBe(400) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(selects) + } + }) + + it('scopes both invitation ownership and joined workspace metadata to the organization', async () => { + queueAuthorized() + await request() + expect(dbChainMockFns.where).toHaveBeenLastCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: invitation.organizationId, right: 'organization' }, + { type: 'eq', left: invitationWorkspaceGrant.invitationId, right: 'invitation' }, + { type: 'eq', left: workspace.organizationId, right: 'organization' }, + undefined, + undefined, + ], + }) + }) + + it.each([ + ['member', 403], + [null, 404], + ] as const)('refuses organization role %s before invitation loading', async (role, status) => { + queueTableRows(member, role ? [{ role }] : []) + expect((await request()).status).toBe(status) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(invitation) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(invitationWorkspaceGrant) + }) + + it('conceals an invitation belonging to another organization', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(invitation, []) + const response = await request() + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: { code: 'NOT_FOUND' } }) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: invitation.organizationId, right: 'organization' }, + { type: 'eq', left: invitation.id, right: 'invitation' }, + ], + }) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(invitationWorkspaceGrant) + }) + + it('refuses actorless workspace keys before protected loading', async () => { + setPrincipal({ kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }) + expect((await request()).status).toBe(403) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('admits read-only OAuth with current administrator authority', async () => { + setPrincipal({ + kind: 'oauth_access_token', + userId: 'actor', + clientId: 'client', + tokenId: 'token', + scopes: ['api:read'], + expiresAt: new Date('2099-01-01'), + }) + queueAuthorized() + expect((await request()).status).toBe(200) + }) + + it('rechecks credential policy but does not require permission to send new invitations', async () => { + queueAuthorized() + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disableInvitations: true }) + expect((await request()).status).toBe(200) + queueTableRows(member, [{ role: 'admin' }]) + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + expect((await request()).status).toBe(403) + }) + + it.each(['?limit=1.5', '?limit=0', '?limit=101', '?sortBy=email', '?extra=true', '?cursor=bad'])( + 'rejects invalid query %s before protected loading', + async (query) => { + expect((await request(query)).status).toBe(400) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces/route.ts new file mode 100644 index 00000000000..3f602c81c94 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces/route.ts @@ -0,0 +1,42 @@ +import { v2ListOrganizationInvitationWorkspacesContract } 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 { listOrganizationInvitationWorkspaces } from '@/lib/organizations/application/reads' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationInvitationWorkspacesContract, + operation: organizationOperations.listInvitationWorkspaces, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationInvitationWorkspacesContract, params), { + search: query.search, + }) + ), + }), + useCase: listOrganizationInvitationWorkspaces, + present: ({ data, nextCursorKeys }, { params, query }) => ({ + data: data.map((workspace) => ({ + ...workspace, + archivedAt: workspace.archivedAt?.toISOString() ?? null, + })), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationInvitationWorkspacesContract, params), { + search: query.search, + }) + ), + }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/members/[userId]/usage-limit/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/members/[userId]/usage-limit/route.ts new file mode 100644 index 00000000000..4f581a5a427 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/members/[userId]/usage-limit/route.ts @@ -0,0 +1,36 @@ +import { + v2GetOrganizationMemberUsageLimitContract, + v2UpdateOrganizationMemberUsageLimitContract, +} from '@/lib/api/contracts/v2/organization-usage' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { memberUsageLimitOperations } from '@/lib/billing/application/member-usage-limits/operations' +import { + getOrganizationMemberUsageLimit, + requireHostedMemberUsageLimits, + updateOrganizationMemberUsageLimit, +} from '@/lib/billing/application/member-usage-limits/use-cases' + +export const GET = defineV2JsonRoute({ + contract: v2GetOrganizationMemberUsageLimitContract, + operation: memberUsageLimitOperations.read, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + beforeParse: requireHostedMemberUsageLimits, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getOrganizationMemberUsageLimit, + present: (data) => ({ data }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateOrganizationMemberUsageLimitContract, + operation: memberUsageLimitOperations.update, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + beforeParse: requireHostedMemberUsageLimits, + errorPolicy: v2OrganizationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateOrganizationMemberUsageLimit, + present: (data) => ({ data }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/usage/breakdown/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/usage/breakdown/route.ts new file mode 100644 index 00000000000..a0a941c3c6b --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/usage/breakdown/route.ts @@ -0,0 +1,27 @@ +import { v2GetOrganizationUsageBreakdownContract } from '@/lib/api/contracts/v2/organization-usage' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationUsageErrorPolicy } from '@/lib/api/server/routes/organization-usage' +import { getOrganizationUsageBreakdown } from '@/lib/billing/application/organization-usage/get-organization-usage-breakdown' +import { + PUBLIC_ORGANIZATION_USAGE_MAX_GROUPED_ROWS, + PUBLIC_ORGANIZATION_USAGE_MAX_WINDOW_DAYS, +} from '@/lib/billing/application/organization-usage/limits' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetOrganizationUsageBreakdownContract, + operation: organizationUsageOperations.readBreakdown, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationUsageErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + maxWindowDays: PUBLIC_ORGANIZATION_USAGE_MAX_WINDOW_DAYS, + maxGroupedRows: PUBLIC_ORGANIZATION_USAGE_MAX_GROUPED_ROWS, + }), + useCase: getOrganizationUsageBreakdown, + present: (data) => ({ data }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/usage/events/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/usage/events/route.ts new file mode 100644 index 00000000000..98068010a23 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/usage/events/route.ts @@ -0,0 +1,58 @@ +import { v2ListOrganizationUsageEventsContract } from '@/lib/api/contracts/v2/organization-usage' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationUsageErrorPolicy } from '@/lib/api/server/routes/organization-usage' +import { PUBLIC_ORGANIZATION_USAGE_MAX_WINDOW_DAYS } from '@/lib/billing/application/organization-usage/limits' +import { listOrganizationUsageEvents } from '@/lib/billing/application/organization-usage/list-organization-usage-events' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' +import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationUsageEventsContract, + operation: organizationUsageOperations.listEvents, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationUsageErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + preset: query.preset, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + timezone: query.timezone, + source: query.source ? toInternalUsageLogSources(query.source) : undefined, + limit: query.limit, + maxWindowDays: PUBLIC_ORGANIZATION_USAGE_MAX_WINDOW_DAYS, + keyset: { + sortOrder: query.sortOrder, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationUsageEventsContract, params), { + preset: query.preset, + startDate: query.startDate, + endDate: query.endDate, + timezone: query.timezone, + source: query.source, + }) + ), + }, + }), + useCase: listOrganizationUsageEvents, + present: ({ events, nextCursorKeys }, { params, query }) => ({ + data: events.map((event) => ({ ...event, source: toBillingUsageLogSource(event.source) })), + nextCursor: writeSortedCursor( + nextCursorKeys ?? null, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationUsageEventsContract, params), { + preset: query.preset, + startDate: query.startDate, + endDate: query.endDate, + timezone: query.timezone, + source: query.source, + }) + ), + }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts b/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts new file mode 100644 index 00000000000..aa72574f436 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts @@ -0,0 +1,591 @@ +/** @vitest-environment node */ + +import { recordAudit } from '@sim/audit' +import type { OAuthAccessTokenPrincipal, Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { member } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauth: vi.fn(), + rate: vi.fn(), + config: vi.fn(), + subscription: vi.fn(), + entitled: vi.fn(), + limit: vi.fn(), + used: vi.fn(), + setLimit: vi.fn(), + limitTarget: vi.fn(), + totals: vi.fn(), + series: vi.fn(), + breakdown: vi.fn(), + logs: 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/billing/core/billing', () => ({ getOrganizationSubscription: mocks.subscription })) +vi.mock('@/lib/billing/core/subscription', async (original) => ({ + ...(await original()), + isOrganizationFeatureEntitled: mocks.entitled, +})) +vi.mock('@/lib/billing/organizations/member-limits', () => ({ + getOrgMemberUsageLimit: mocks.limit, + getOrgMemberUsageForCurrentPeriod: mocks.used, + setOrgMemberUsageLimit: mocks.setLimit, + isOrgMemberUsageLimitTarget: mocks.limitTarget, +})) +vi.mock('@/lib/billing/core/usage-analytics-queries', () => ({ + readUsageTotals: mocks.totals, + readUsageTimeSeries: mocks.series, + readUsageBreakdown: mocks.breakdown, + readUsageEntityNames: vi.fn().mockResolvedValue(new Map()), +})) +vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingEntityUsageLogs: mocks.logs })) + +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' +import { + GET as getLimit, + PATCH as setLimit, +} from '@/app/api/v2/organizations/[organizationId]/members/[userId]/usage-limit/route' +import { GET as breakdown } from '@/app/api/v2/organizations/[organizationId]/usage/breakdown/route' +import { GET as events } from '@/app/api/v2/organizations/[organizationId]/usage/events/route' +import { GET as summary } from '@/app/api/v2/organizations/[organizationId]/usage/summary/route' + +const personal = { kind: 'personal_api_key', userId: 'actor', keyId: 'key' } as const +const oauth: OAuthAccessTokenPrincipal = { + kind: 'oauth_access_token', + userId: 'actor', + tokenId: 'token', + clientId: 'client', + scopes: ['api:read', 'api:write'], + expiresAt: new Date('2099-01-01'), +} +const context = { params: Promise.resolve({ organizationId: 'org', userId: 'external-user' }) } +const usageContext = { params: Promise.resolve({ organizationId: 'org' }) } +function authenticate(principal: Principal) { + mocks.authenticate.mockResolvedValue({ + principal, + keyType: 'personal', + rateLimitSubjectIds: ['key:key'], + rateLimitSubscription: null, + }) +} +function request(path: string, body?: unknown) { + return new NextRequest(`http://localhost/api/v2/organizations/org/${path}`, { + method: body === undefined ? 'GET' : 'PATCH', + headers: { + 'x-api-key': 'key', + 'content-type': 'application/json', + 'x-forwarded-for': '127.0.0.1', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} +function admin() { + queueTableRows(member, [{ role: 'admin' }]) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isHosted: true, isBillingEnabled: true }) + authenticate(personal) + 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.entitled.mockResolvedValue(true) + mocks.subscription.mockResolvedValue({ + plan: 'enterprise', + periodStart: new Date('2026-08-01'), + periodEnd: new Date('2026-09-01'), + }) + mocks.limit.mockResolvedValue(2) + mocks.used.mockResolvedValue(1) + mocks.limitTarget.mockResolvedValue(true) + mocks.setLimit.mockResolvedValue(undefined) + mocks.totals.mockResolvedValue({ cost: 1 }) + mocks.series.mockResolvedValue([]) + mocks.breakdown.mockResolvedValue([]) + mocks.logs.mockResolvedValue({ logs: [], pagination: { hasMore: false, nextCursorKeys: null } }) +}) +afterEach(() => vi.useRealTimers()) +afterAll(resetEnvFlagsMock) + +describe('organization credit-limit API', () => { + it.each([personal, oauth])( + 'admits $kind and preserves external-user credit units and one semantic audit', + async (principal) => { + authenticate(principal) + admin() + admin() + const response = await setLimit( + request('members/external-user/usage-limit', { creditLimit: 400 }), + context + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { creditLimit: 400 } }) + expect(mocks.setLimit).toHaveBeenCalledWith('org', 'external-user', 2, 'actor', db) + expect(mocks.limitTarget).toHaveBeenCalledWith('org', 'external-user') + expect(recordAudit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + actorId: 'actor', + resourceId: 'org', + metadata: expect.objectContaining({ + organizationId: 'org', + targetUserId: 'external-user', + creditLimit: 400, + }), + }) + ) + } + ) + + it.each([null, 0])('supports the distinct cap value %s', async (creditLimit) => { + admin() + admin() + expect( + (await setLimit(request('members/external-user/usage-limit', { creditLimit }), context)) + .status + ).toBe(200) + expect(mocks.setLimit).toHaveBeenCalledWith('org', 'external-user', creditLimit, 'actor', db) + }) + + it('rechecks the target after acquiring mutation locks', async () => { + admin() + admin() + mocks.limitTarget.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + const response = await setLimit( + request('members/external-user/usage-limit', { creditLimit: 400 }), + context + ) + expect(response.status).toBe(404) + expect(mocks.limitTarget).toHaveBeenLastCalledWith('org', 'external-user', { + executor: db, + forShare: true, + }) + expect(mocks.setLimit).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + }) + + it('refuses an actor demoted while waiting for mutation locks', async () => { + admin() + queueTableRows(member, [{ role: 'member' }]) + const response = await setLimit( + request('members/external-user/usage-limit', { creditLimit: 400 }), + context + ) + expect(response.status).toBe(403) + expect(mocks.setLimit).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + }) + + it.each([ + [personal, { disablePersonalApiKeys: true }], + [oauth, { disableOAuthAppAccess: true }], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], + ] as const)( + 'rechecks $0.kind credential policy inside the mutation', + async (principal, restriction) => { + authenticate(principal) + admin() + admin() + mocks.config.mockResolvedValueOnce(null).mockResolvedValueOnce({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...restriction, + }) + const response = await setLimit( + request('members/external-user/usage-limit', { creditLimit: 400 }), + context + ) + expect(response.status).toBe(403) + expect(mocks.config).toHaveBeenLastCalledWith('org', db) + expect(mocks.setLimit).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + } + ) + + it('returns credits and the resolved organization billing interval', async () => { + admin() + const response = await getLimit(request('members/external-user/usage-limit'), context) + expect(await response.json()).toEqual({ + data: { creditsUsed: 200, creditLimit: 400, billingInterval: 'month' }, + }) + expect(mocks.used).toHaveBeenCalledWith( + 'org', + 'external-user', + await mocks.subscription.mock.results[0].value + ) + }) + + it('does not require Usage Monitoring for hosted caps', async () => { + admin() + mocks.entitled.mockResolvedValue(false) + expect((await getLimit(request('members/external-user/usage-limit'), context)).status).toBe(200) + expect(mocks.entitled).not.toHaveBeenCalled() + }) + + it('preserves hosted-only admission before body validation', async () => { + setEnvFlags({ isHosted: false }) + expect( + (await setLimit(request('members/external-user/usage-limit', { invalid: true }), context)) + .status + ).toBe(404) + expect(mocks.setLimit).not.toHaveBeenCalled() + }) + + it('refuses reads for a user outside the organization before loading usage', async () => { + admin() + mocks.limitTarget.mockResolvedValue(false) + const response = await getLimit(request('members/external-user/usage-limit'), context) + expect(response.status).toBe(404) + expect(mocks.limit).not.toHaveBeenCalled() + expect(mocks.used).not.toHaveBeenCalled() + expect(mocks.subscription).not.toHaveBeenCalled() + }) + + it.each([10, null])( + 'refuses cap %s for a user outside the organization without mutation or audit', + async (creditLimit) => { + admin() + mocks.limitTarget.mockResolvedValue(false) + const response = await setLimit( + request('members/external-user/usage-limit', { creditLimit }), + context + ) + expect(response.status).toBe(404) + expect(mocks.setLimit).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + } + ) + + it.each([{}, { creditLimit: -1 }, { creditLimit: 0.5 }, { creditLimit: 100, unexpected: true }])( + 'rejects malformed cap %j before protected reads', + async (body) => { + expect( + (await setLimit(request('members/external-user/usage-limit', body), context)).status + ).toBe(400) + expect(mocks.limitTarget).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + } + ) + + it('refuses read-only OAuth writes before target reads', async () => { + authenticate({ ...oauth, scopes: ['api:read'] }) + expect( + (await setLimit(request('members/external-user/usage-limit', { creditLimit: 10 }), context)) + .status + ).toBe(403) + expect(mocks.limitTarget).not.toHaveBeenCalled() + }) +}) + +describe('organization usage API authorization and bounds', () => { + it.each([personal, oauth])( + 'reads summary through $kind and reports credit units', + async (principal) => { + authenticate(principal) + admin() + const response = await summary(request('usage/summary'), usageContext) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: { totals: { credits: 200 }, previousTotals: null }, + }) + expect(mocks.entitled).toHaveBeenCalledWith('org', expect.any(Boolean)) + } + ) + + it('serializes local calendar buckets as UTC timestamps across daylight saving', async () => { + admin() + const response = await summary( + request( + 'usage/summary?preset=custom&startDate=2026-03-08&endDate=2026-03-09&timezone=America%2FLos_Angeles' + ), + usageContext + ) + expect(response.status).toBe(200) + expect( + (await response.json()).data.series.map((point: { timestamp: string }) => point.timestamp) + ).toEqual(['2026-03-08T08:00:00.000Z', '2026-03-09T07:00:00.000Z']) + }) + + it('keeps weekly bucket boundaries in the selected timezone for billing presets', async () => { + admin() + mocks.subscription.mockResolvedValue({ + plan: 'enterprise', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-07-01'), + }) + const response = await summary( + request('usage/summary?preset=current-period&timezone=America%2FNew_York'), + usageContext + ) + expect(response.status).toBe(200) + const { data } = await response.json() + expect(data.bucket).toBe('week') + expect(data.series[0].timestamp).toBe('2025-12-29T05:00:00.000Z') + expect(data.series).toContainEqual({ + timestamp: '2026-03-09T04:00:00.000Z', + credits: 0, + events: 0, + }) + }) + + it.each([ + ['member', 403], + [null, 404], + ] as const)('refuses current organization role %s', async (role, status) => { + queueTableRows(member, role ? [{ role }] : []) + expect((await summary(request('usage/summary'), usageContext)).status).toBe(status) + expect(mocks.entitled).not.toHaveBeenCalled() + expect(mocks.totals).not.toHaveBeenCalled() + }) + + it.each([ + { ...personal, kind: 'workspace_api_key' as const, workspaceId: 'ws' }, + { ...oauth, scopes: [] }, + { ...oauth, expiresAt: new Date('2000-01-01') }, + ])('refuses invalid principal or scope before entitlement reads', async (principal) => { + authenticate(principal) + expect((await summary(request('usage/summary'), usageContext)).status).toBe( + principal.kind === 'oauth_access_token' && principal.expiresAt < new Date() ? 401 : 403 + ) + expect(mocks.entitled).not.toHaveBeenCalled() + }) + + it.each([ + [personal, 'disablePersonalApiKeys'], + [oauth, 'disableOAuthAppAccess'], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, 'disableCliAccess'], + ] as const)('rechecks current credential policy %s / %s', async (principal, field) => { + authenticate(principal) + admin() + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, [field]: true }) + expect((await summary(request('usage/summary'), usageContext)).status).toBe(403) + expect(mocks.totals).not.toHaveBeenCalled() + }) + + it('retains the Usage Monitoring entitlement', async () => { + admin() + mocks.entitled.mockResolvedValue(false) + expect((await events(request('usage/events'), usageContext)).status).toBe(403) + expect(mocks.logs).not.toHaveBeenCalled() + }) + + it.each([ + 'preset=custom', + 'preset=30d&startDate=2026-01-01', + 'preset=custom&startDate=2026-01-01&endDate=2026-05-01', + 'preset=custom&startDate=2026-02-30&endDate=2026-03-01', + 'unknown=value', + 'preset=custom&startDate=9999-12-31&endDate=9999-12-31', + ])('rejects ambiguous or unbounded query %s', async (query) => { + expect((await summary(request(`usage/summary?${query}`), usageContext)).status).toBe(400) + expect(mocks.totals).not.toHaveBeenCalled() + }) + + it('rejects an oversized billing period before analytics reads', async () => { + admin() + mocks.subscription.mockResolvedValue({ + plan: 'enterprise', + periodStart: new Date('2020-01-01'), + periodEnd: new Date('2026-01-01'), + }) + expect( + (await summary(request('usage/summary?preset=current-period'), usageContext)).status + ).toBe(400) + expect(mocks.totals).not.toHaveBeenCalled() + }) + + it('reports an oversized breakdown instead of presenting a partial total', async () => { + admin() + mocks.breakdown.mockResolvedValue( + Array.from({ length: 10_001 }, (_, index) => ({ key: `user-${index}`, cost: 1, events: 1 })) + ) + const response = await breakdown(request('usage/breakdown?dimension=member'), usageContext) + expect(response.status).toBe(413) + expect(mocks.breakdown).toHaveBeenCalledWith(expect.any(Array), 'member', undefined, 10_000) + }) +}) + +describe('organization usage event cursors', () => { + const customQuery = + 'preset=custom&startDate=2026-03-08&endDate=2026-03-09&timezone=America%2FLos_Angeles&source=sim-chat&limit=1' + const eventKeys = ['2026-03-09T12:00:00.000Z', 'event-1'] + + async function firstCustomPage() { + admin() + mocks.logs.mockResolvedValueOnce({ + logs: [], + pagination: { hasMore: true, nextCursorKeys: eventKeys }, + }) + const response = await events(request(`usage/events?${customQuery}`), usageContext) + expect(response.status).toBe(200) + return (await response.json()).nextCursor as string + } + + it('preserves the exact custom calendar range across daylight saving on continuation', async () => { + const cursor = await firstCustomPage() + admin() + const response = await events( + request(`usage/events?${customQuery}&cursor=${encodeURIComponent(cursor)}`), + usageContext + ) + expect(response.status).toBe(200) + expect(mocks.logs).toHaveBeenCalledTimes(2) + for (const [, options] of mocks.logs.mock.calls) { + expect(options).toMatchObject({ + startDate: new Date('2026-03-08T08:00:00.000Z'), + endDate: new Date('2026-03-10T07:00:00.000Z'), + endDateExclusive: true, + }) + expect(options.billingPeriod).toBeUndefined() + } + expect(mocks.logs.mock.calls[1][1].keyset.cursorKeys).toEqual(eventKeys) + }) + + it.each([ + ['range', '2026-03-07T08:00:00.000Z', '2026-03-10T07:00:00.000Z'], + ['range', '2026-03-08T08:00:00.000Z', '2026-03-11T07:00:00.000Z'], + ['period', '2026-03-08T08:00:00.000Z', '2026-03-10T07:00:00.000Z'], + ])('rejects tampered custom window %s / %s / %s before ledger reads', async (...windowKeys) => { + const cursor = await firstCustomPage() + const payload = decodeCursor>(cursor) + expect(payload).not.toBeNull() + const tampered = encodeCursor({ ...payload, keys: [...windowKeys, ...eventKeys] }) + admin() + const response = await events( + request(`usage/events?${customQuery}&cursor=${encodeURIComponent(tampered)}`), + usageContext + ) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { + code: 'BAD_REQUEST', + message: 'Usage event cursor does not match the requested custom range; restart pagination', + }, + }) + expect(mocks.logs).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['startDate', '2026-03-07'], + ['endDate', '2026-03-10'], + ['timezone', 'UTC'], + ['source', 'workflow'], + ['sortOrder', 'asc'], + ])('rejects a custom cursor reused with changed %s', async (filter, value) => { + const cursor = await firstCustomPage() + const query = new URLSearchParams(customQuery) + query.set(filter, value) + query.set('cursor', cursor) + const response = await events(request(`usage/events?${query}`), usageContext) + expect(response.status).toBe(400) + expect(mocks.logs).toHaveBeenCalledTimes(1) + }) + + it('keeps the first billing predicate after a subscription period changes', async () => { + admin() + mocks.logs.mockResolvedValueOnce({ + logs: [], + pagination: { hasMore: true, nextCursorKeys: ['2026-08-15T00:00:00.000Z', 'event-1'] }, + }) + const first = await events(request('usage/events?preset=current-period'), usageContext) + expect(first.status).toBe(200) + const { nextCursor } = await first.json() + mocks.subscription.mockResolvedValue({ + plan: 'enterprise', + periodStart: new Date('2026-09-01'), + periodEnd: new Date('2026-10-01'), + }) + admin() + const second = await events( + request(`usage/events?preset=current-period&cursor=${encodeURIComponent(nextCursor)}`), + usageContext + ) + expect(second.status).toBe(200) + expect(mocks.logs).toHaveBeenCalledTimes(2) + for (const [, options] of mocks.logs.mock.calls) { + expect(options.billingPeriod).toEqual({ + start: new Date('2026-08-01'), + end: new Date('2026-09-01'), + }) + expect(options.startDate).toBeUndefined() + expect(options.endDate).toBeUndefined() + } + }) + + it('keeps the first 30d window across clock advances and converts public source names', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-09-10T12:00:00Z')) + admin() + mocks.logs.mockResolvedValueOnce({ + logs: [ + { + id: 'event-1', + createdAt: '2026-09-01T00:00:00.000Z', + source: 'workspace-chat', + description: 'Model', + cost: 0.001, + }, + ], + pagination: { hasMore: true, nextCursorKeys: ['2026-09-01T00:00:00.000Z', 'event-1'] }, + }) + const response = await events(request('usage/events?source=sim-chat&limit=1'), usageContext) + expect(response.status).toBe(200) + const first = await response.json() + expect(first.data[0]).toMatchObject({ source: 'sim-chat', credits: 0, hasCost: true }) + const firstOptions = mocks.logs.mock.calls[0][1] + expect(firstOptions.source).toEqual(['copilot', 'workspace-chat']) + expect(firstOptions.keyset.sortOrder).toBe('desc') + vi.setSystemTime(new Date('2026-09-15T12:00:00Z')) + admin() + const second = await events( + request( + `usage/events?source=sim-chat&limit=1&cursor=${encodeURIComponent(first.nextCursor)}` + ), + usageContext + ) + expect(second.status).toBe(200) + const secondOptions = mocks.logs.mock.calls[1][1] + expect(secondOptions.startDate).toEqual(firstOptions.startDate) + expect(secondOptions.endDate).toEqual(firstOptions.endDate) + expect(secondOptions.keyset.cursorKeys).toEqual(['2026-09-01T00:00:00.000Z', 'event-1']) + expect(await second.json()).toEqual({ data: [], nextCursor: null }) + const changed = await events( + request( + `usage/events?source=workflow&limit=1&cursor=${encodeURIComponent(first.nextCursor)}` + ), + usageContext + ) + expect(changed.status).toBe(400) + expect(mocks.logs).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/usage/summary/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/usage/summary/route.ts new file mode 100644 index 00000000000..24c174ba978 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/usage/summary/route.ts @@ -0,0 +1,32 @@ +import { v2GetOrganizationUsageSummaryContract } from '@/lib/api/contracts/v2/organization-usage' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2OrganizationUsageErrorPolicy } from '@/lib/api/server/routes/organization-usage' +import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary' +import { PUBLIC_ORGANIZATION_USAGE_MAX_WINDOW_DAYS } from '@/lib/billing/application/organization-usage/limits' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' +import { zonedWallClockToUtc } from '@/lib/core/utils/timezone' + +export const GET = defineV2JsonRoute({ + contract: v2GetOrganizationUsageSummaryContract, + operation: organizationUsageOperations.readSummary, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrganizationUsageErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + maxWindowDays: PUBLIC_ORGANIZATION_USAGE_MAX_WINDOW_DAYS, + }), + useCase: getOrganizationUsageSummary, + present: (data, { query }) => ({ + data: { + ...data, + series: data.series.map((point) => ({ + ...point, + timestamp: zonedWallClockToUtc(point.timestamp, query.timezone).toISOString(), + })), + }, + }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel/route.ts new file mode 100644 index 00000000000..e120d96eeb0 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel/route.ts @@ -0,0 +1,19 @@ +import { v2CancelWorkspaceAccessRequestContract } from '@/lib/api/contracts/v2/access-requests' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { cancelAccessRequest } from '@/ee/access-requests/lib/application/requests' + +export const POST = defineV2JsonRoute({ + contract: v2CancelWorkspaceAccessRequestContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.cancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params }) => ({ + requestId: params.requestId, + scope: { kind: 'workspace' as const, workspaceId: params.workspaceId }, + }), + useCase: cancelAccessRequest, + present: ({ request }) => ({ data: request }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/discovery/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/discovery/route.ts new file mode 100644 index 00000000000..835bb1fa1d0 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/discovery/route.ts @@ -0,0 +1,51 @@ +import { v2DiscoverWorkspaceAccessRequestsContract } from '@/lib/api/contracts/v2/access-requests' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { discoverAccessRequests } from '@/ee/access-requests/lib/application/requests' + +function cursorFilters( + params: { workspaceId: string }, + query: { search?: string; targetKind?: string; state?: string } +) { + return cursorScopeKey(cursorRoute(v2DiscoverWorkspaceAccessRequestsContract, params), { + search: query.search, + targetKind: query.targetKind, + state: query.state, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2DiscoverWorkspaceAccessRequestsContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, query }) => ({ + ...query, + kind: 'workspace' as const, + workspaceId: params.workspaceId, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + cursorFilters(params, query) + ), + }), + useCase: discoverAccessRequests, + present: ({ entries, hasMore }, { params, query }) => ({ + data: entries, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + cursorFilters(params, query), + decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + cursorFilters(params, query) + ) + entries.length + ) + : null, + }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/route.ts new file mode 100644 index 00000000000..54df6252af1 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/access-requests/route.ts @@ -0,0 +1,67 @@ +import { + v2CreateWorkspaceAccessRequestContract, + v2ListMyWorkspaceAccessRequestsContract, +} from '@/lib/api/contracts/v2/access-requests' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { + createAccessRequest, + listMyAccessRequests, +} from '@/ee/access-requests/lib/application/requests' + +function cursorFilters(params: { workspaceId: string }, query: { status?: string }) { + return cursorScopeKey(cursorRoute(v2ListMyWorkspaceAccessRequestsContract, params), { + status: query.status, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListMyWorkspaceAccessRequestsContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.listMine, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, query }) => ({ + scope: { kind: 'workspace' as const, workspaceId: params.workspaceId }, + limit: query.limit, + offset: 0, + status: query.status, + paging: { + sortBy: query.sortBy, + sortOrder: query.sortOrder, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }, + }), + useCase: listMyAccessRequests, + present: ({ requests, nextCursorKeys }, { params, query }) => ({ + data: requests, + nextCursor: writeSortedCursor( + nextCursorKeys ?? null, + query.sortBy, + query.sortOrder, + cursorFilters(params, query) + ), + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateWorkspaceAccessRequestContract, + auth: v2ApiKeyAuth, + operation: accessRequestOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2AccessRequestErrorPolicy, + mapInput: ({ params, body }) => ({ + ...body, + scope: { kind: 'workspace' as const, workspaceId: params.workspaceId }, + }), + useCase: createAccessRequest, + present: ({ request }) => ({ data: request }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts new file mode 100644 index 00000000000..fe45d9a0912 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts @@ -0,0 +1,132 @@ +/** @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ send: vi.fn() })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/lib/invitations/application/send-invitation-batch', () => ({ + sendInvitationBatch: { operation: { id: 'invitations.send_batch' }, execute: mocks.send }, +})) + +import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/workspaces/[workspaceId]/invitations/route' + +const workspaceId = 'workspace-123' +const principal = { kind: 'personal_api_key', userId: 'caller', keyId: 'key' } as const +const call = (body: unknown, query = '') => + POST( + new NextRequest(`http://localhost/api/v2/workspaces/${workspaceId}/invitations${query}`, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }), + { params: Promise.resolve({ workspaceId }) } + ) + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + principal, + keyType: 'personal', + rateLimitSubjectIds: ['user:caller'], + rateLimitSubscription: null, + }) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) +}) + +describe('workspace invitation public adapter', () => { + it('preserves partial recipient outcomes under the v2 data envelope', async () => { + const result = { + success: false, + successful: ['new@example.com'], + added: ['existing@example.com'], + failed: [{ email: 'failed@example.com', error: 'No available seats' }], + invitations: [ + { + id: 'invitation-123', + email: 'new@example.com', + workspaceIds: [workspaceId], + permission: 'read', + membershipIntent: 'internal', + }, + { + id: 'user-123', + email: 'existing@example.com', + workspaceIds: [workspaceId], + permission: 'read', + membershipIntent: 'internal', + instantAdd: true, + outcome: 'added', + }, + ], + } + mocks.send.mockResolvedValue(result) + const response = await call({ + emails: ['new@example.com', 'existing@example.com', 'failed@example.com'], + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: result }) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(mocks.send).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: { + workspaceIds: [workspaceId], + emails: ['new@example.com', 'existing@example.com', 'failed@example.com'], + permission: 'read', + membership: 'member', + }, + }) + ) + }) + + it.each([ + { emails: [] }, + { emails: Array.from({ length: 51 }, () => 'member@example.com') }, + { emails: ['invalid'] }, + { emails: ['member@example.com'], workspaceIds: ['different-workspace'] }, + { emails: ['member@example.com'], organizationId: 'other-organization' }, + { emails: ['member@example.com'], permission: 'owner' }, + ])('rejects invalid or unsupported invitation inputs before executing', async (body) => { + expect((await call(body)).status).toBe(400) + expect(mocks.send).not.toHaveBeenCalled() + }) + + it('rejects undeclared query fields', async () => { + expect((await call({ emails: ['member@example.com'] }, '?organizationId=other')).status).toBe( + 400 + ) + expect(mocks.send).not.toHaveBeenCalled() + }) + + it.each([ + new NoWorkspaceAccessError(), + new OrchestrationError('not_found', 'Workspace not found'), + ])('conceals absent and inaccessible workspace targets identically', async (error) => { + mocks.send.mockRejectedValue(error) + const response = await call({ emails: ['member@example.com'] }) + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) + + it('publishes the shared workspace-key refusal code', async () => { + mocks.send.mockRejectedValue(new WorkspaceApiKeyAuthorizationError()) + const response = await call({ emails: ['member@example.com'] }) + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' } }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts new file mode 100644 index 00000000000..a08ab25b0e2 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts @@ -0,0 +1,24 @@ +import { v2CreateWorkspaceInvitationsContract } from '@/lib/api/contracts/v2/workspace-invitations' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { invitationOperations } from '@/lib/invitations/application/operations' +import { sendInvitationBatch } from '@/lib/invitations/application/send-invitation-batch' + +export const POST = defineV2JsonRoute({ + contract: v2CreateWorkspaceInvitationsContract, + operation: invitationOperations.sendBatch, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + render: v2OrganizationErrorPolicy.render, + }), + mapInput: ({ params, body }) => ({ ...body, workspaceIds: [params.workspaceId] }), + useCase: sendInvitationBatch, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index 8df0265bdc6..b12db8d4594 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -51,6 +51,7 @@ export const GET = defineV2JsonRoute({ useCase: listPublicWorkspaceMembers, present: ({ page }, { params }) => ({ data: page.members.map((member) => ({ + userId: member.userId, email: member.email, name: member.name, image: member.image, diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/permission-config/route.test.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/permission-config/route.test.ts new file mode 100644 index 00000000000..d66bf5dd2a5 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/permission-config/route.test.ts @@ -0,0 +1,94 @@ +/** @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ read: vi.fn() })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/lib/permission-groups/application/read-user-config', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@/lib/permission-groups/application/read-user-config') + >()), + readUserPermissionConfig: { + operation: { id: 'permission_groups.read_user_config' }, + execute: mocks.read, + }, +})) + +import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' +import { GET } from '@/app/api/v2/workspaces/[workspaceId]/permission-config/route' + +const workspaceId = 'workspace-123' +const principal = { kind: 'personal_api_key', userId: 'caller', keyId: 'key' } as const +const result = { + permissionGroupId: null, + groupName: null, + config: null, + entitled: false, + organizationId: 'organization-123', + isOrgAdmin: false, +} +const call = (query = '') => + GET( + new NextRequest(`http://localhost/api/v2/workspaces/${workspaceId}/permission-config${query}`), + { params: Promise.resolve({ workspaceId }) } + ) + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + principal, + keyType: 'personal', + rateLimitSubjectIds: ['user:caller'], + rateLimitSubscription: null, + }) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.read.mockResolvedValue(result) +}) + +describe('effective caller permission configuration public adapter', () => { + it('returns the shared configuration without inventing a workspace role', async () => { + const response = await call() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: result }) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(mocks.read).toHaveBeenCalledWith( + expect.objectContaining({ principal, input: { workspaceId } }) + ) + }) + + it.each([ + '?userId=other-user', + '?organizationId=other-organization', + '?workspaceId=other-workspace', + ])('rejects an asserted target outside its caller-only contract: %s', async (query) => { + expect((await call(query)).status).toBe(400) + expect(mocks.read).not.toHaveBeenCalled() + }) + + it('conceals inaccessible workspace policy', async () => { + mocks.read.mockRejectedValue(new NoWorkspaceAccessError()) + const response = await call() + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) + + it('uses the shared workspace-key refusal code', async () => { + mocks.read.mockRejectedValue(new WorkspaceApiKeyAuthorizationError()) + const response = await call() + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' } }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/permission-config/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/permission-config/route.ts new file mode 100644 index 00000000000..06246e82554 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/permission-config/route.ts @@ -0,0 +1,16 @@ +import { v2GetWorkspacePermissionConfigContract } from '@/lib/api/contracts/v2/workspace-permissions' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { permissionGroupWorkspaceOperations } from '@/lib/permission-groups/application/operations' +import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' +import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspacePermissionConfigContract, + operation: permissionGroupWorkspaceOperations.readUserConfig, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkspaceErrorPolicies.concealWorkspaceAuthorization, + mapInput: ({ params }) => params, + useCase: readUserPermissionConfig, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts index a6ab601aff3..4c37ec095ff 100644 --- a/apps/sim/app/api/v2/workspaces/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -186,7 +186,7 @@ describe('v2 workspace routes', () => { }) }) - it('keeps member user IDs out of data and cursors', async () => { + it('returns canonical member user IDs while preserving email-based cursors', async () => { const request = new NextRequest( `http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members?limit=1` ) @@ -195,6 +195,7 @@ describe('v2 workspace routes', () => { expect(response.status).toBe(200) expect(body.data[0]).toEqual({ + userId: 'user-1', email: 'ada@example.com', name: 'Ada', image: null, diff --git a/apps/sim/ee/access-requests/lib/application/authorization.test.ts b/apps/sim/ee/access-requests/lib/application/authorization.test.ts index bb0e652f8e8..024ec984a1c 100644 --- a/apps/sim/ee/access-requests/lib/application/authorization.test.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.test.ts @@ -5,7 +5,11 @@ import { member, permissions, user, workspace } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ effectiveRole: vi.fn(), config: vi.fn() })) +const mocks = vi.hoisted(() => ({ + effectiveRole: vi.fn(), + config: vi.fn(), + workspaceConfig: vi.fn(), +})) vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({ ...(await importOriginal()), resolveEffectiveWorkspacePermission: mocks.effectiveRole, @@ -14,7 +18,13 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: mocks.config, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.workspaceConfig, +})) + +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' import type { DbOrTx } from '@/lib/db/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { authorizeAccessRequestScope, loadAccessRequestMembership, @@ -36,6 +46,7 @@ function queueMembership(orgRole: string | null = 'member', grantId: string | nu beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mocks.workspaceConfig.mockResolvedValue(null) mocks.effectiveRole.mockResolvedValue('read') mocks.config.mockRejectedValue(new Error('A capability-exempt session must not load config')) }) @@ -233,3 +244,78 @@ describe('access request scope authorization', () => { expect(dbChainMockFns.from).toHaveBeenCalledExactlyOnceWith(workspace) }) }) + +describe('access request credential policy', () => { + const key = { kind: 'personal_api_key', userId: 'person', keyId: 'key' } as const + const oauth = { + kind: 'oauth_access_token', + userId: 'person', + tokenId: 'token', + clientId: SIM_CLI_CLIENT_ID, + scopes: ['api:write'], + expiresAt: new Date('2099-01-01'), + } as const + + it.each([key, oauth])( + 'admits $kind in a workspace only through the current human grant', + async (caller) => { + queueTableRows(workspace, [{ ...canonicalWorkspace, allowPersonalApiKeys: true }]) + queueMembership(null) + await expect( + authorizeAccessRequestScope(caller, accessRequestOperations.create, workspaceScope) + ).resolves.toMatchObject({ membershipId: '[null,"grant"]' }) + expect(mocks.workspaceConfig).toHaveBeenCalled() + } + ) + + it('preserves the workspace personal-key switch', async () => { + queueTableRows(workspace, [canonicalWorkspace]) + queueMembership() + await expect( + authorizeAccessRequestScope(key, accessRequestOperations.create, workspaceScope) + ).rejects.toMatchObject({ detailCode: 'PERSONAL_API_KEYS_DISABLED' }) + }) + + it.each(['disablePersonalApiKeys', 'disableOAuthAppAccess', 'disableCliAccess'] as const)( + 'enforces %s even though access requests are capability-exempt', + async (restriction) => { + queueTableRows(workspace, [{ ...canonicalWorkspace, allowPersonalApiKeys: true }]) + queueMembership() + mocks.workspaceConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + [restriction]: true, + }) + await expect( + authorizeAccessRequestScope(oauth, accessRequestOperations.create, workspaceScope) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + ) + + it.each([key, oauth])( + 'requires an administrator for organization review with $kind', + async (caller) => { + queueMembership('member') + queueTableRows(member, [{ role: 'member' }]) + mocks.config.mockResolvedValue(null) + await expect( + authorizeAccessRequestScope(caller, accessRequestOperations.resolve, organizationScope) + ).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + } + ) + + it.each([key, oauth])( + 'reauthorizes current organization credential restrictions for $kind', + async (caller) => { + queueMembership('admin') + queueTableRows(member, [{ role: 'admin' }]) + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + await expect( + authorizeAccessRequestScope(caller, accessRequestOperations.resolve, organizationScope) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.config).toHaveBeenCalledWith('org', db) + } + ) +}) diff --git a/apps/sim/ee/access-requests/lib/application/authorization.ts b/apps/sim/ee/access-requests/lib/application/authorization.ts index 6e613b6c737..7321f3a4f41 100644 --- a/apps/sim/ee/access-requests/lib/application/authorization.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.ts @@ -1,4 +1,3 @@ -import type { SessionPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { member, permissions, user, workspace } from '@sim/db/schema' import { @@ -15,7 +14,10 @@ import { } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestOperation } from '@/ee/access-requests/lib/application/operations' +import type { + AccessRequestOperation, + AccessRequestPrincipal, +} from '@/ee/access-requests/lib/application/operations' import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' export interface AccessRequestContext { @@ -82,7 +84,7 @@ export async function loadAccessRequestMembership( /** Canonical scope and current role are loaded together on the transaction's connection. */ export async function authorizeAccessRequestScope( - principal: SessionPrincipal, + principal: AccessRequestPrincipal, operation: AccessRequestOperation, scope: AccessRequestScope, executor: DbOrTx = db, diff --git a/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts index 652f072253b..b75a0c22a31 100644 --- a/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts @@ -23,6 +23,7 @@ vi.mock('@/lib/core/network/context.server', () => ({ })) import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' +import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authorized-workspace-use-case' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' @@ -48,19 +49,8 @@ beforeEach(() => { }) describe('authorized access request execution', () => { - it.each([ - { kind: 'personal_api_key', userId: 'owner', keyId: 'key' }, - { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, - { - kind: 'oauth_access_token', - userId: 'owner', - tokenId: 'token', - clientId: 'client', - scopes: ['api:write'], - expiresAt: new Date('2099-01-01'), - }, - ])('refuses $kind before scope lookup or preparation', async (caller) => { - const prepare = vi.fn().mockResolvedValue({ ready: true }) + it('refuses workspace keys before scope lookup or preparation', async () => { + const prepare = vi.fn() const execute = vi.fn() const getScope = vi.fn().mockReturnValue(scope) const useCase = defineAuthorizedAccessRequestUseCase({ @@ -69,13 +59,99 @@ describe('authorized access request execution', () => { prepare, execute, }) - await expect(useCase.execute({ principal: caller, input })).rejects.toThrow('signed-in user') + await expect( + useCase.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, + input, + }) + ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) expect(getScope).not.toHaveBeenCalled() expect(mocks.authorize).not.toHaveBeenCalled() expect(prepare).not.toHaveBeenCalled() expect(execute).not.toHaveBeenCalled() }) + it.each([ + { kind: 'personal_api_key', userId: 'requester', keyId: 'key' }, + { + kind: 'oauth_access_token', + userId: 'requester', + tokenId: 'token', + clientId: 'client', + scopes: ['api:write'], + expiresAt: new Date('2099-01-01'), + }, + ])( + 'preserves the $kind actor through preparation, transactional reauthorization and audit', + async (caller) => { + const prepare = vi.fn().mockResolvedValue(true) + const execute = vi.fn().mockResolvedValue('result') + const audit: WorkspaceUseCaseAuditEntry[] = [] + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: () => scope, + mutation: true, + prepare, + execute, + projectAudit: () => audit, + }) + await expect(useCase.execute({ principal: caller, input })).resolves.toBe('result') + expect(prepare).toHaveBeenCalledWith({ principal: caller, input, context }) + expect(mocks.authorize).toHaveBeenNthCalledWith( + 2, + caller, + accessRequestOperations.create, + scope, + transaction, + true, + context + ) + expect(execute).toHaveBeenCalledWith({ + principal: caller, + input, + context, + executor: transaction, + prepared: true, + }) + expect(mocks.audit).toHaveBeenCalledWith( + accessRequestOperations.create, + 'workspace', + caller, + undefined, + audit, + 'org' + ) + } + ) + + it.each([ + { scopes: ['api:read'], expiresAt: new Date('2099-01-01'), code: 'forbidden' }, + { scopes: ['api:write'], expiresAt: new Date('2000-01-01'), code: 'unauthorized' }, + ])( + 'rejects an insufficient or expired OAuth grant before loading scope', + async ({ scopes, expiresAt, code }) => { + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: () => scope, + execute: vi.fn(), + }) + await expect( + useCase.execute({ + principal: { + kind: 'oauth_access_token', + userId: 'requester', + tokenId: 'token', + clientId: 'client', + scopes, + expiresAt, + }, + input, + }) + ).rejects.toMatchObject({ code }) + expect(mocks.authorize).not.toHaveBeenCalled() + } + ) + it('does not prepare or execute when the initial authorization fails', async () => { mocks.authorize.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) const prepare = vi.fn() diff --git a/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts index 3672ee15f84..98a5241a7e4 100644 --- a/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts @@ -1,4 +1,4 @@ -import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { @@ -6,18 +6,21 @@ import { type WorkspaceUseCaseAuditEntry, } from '@/lib/core/application/authorized-workspace-use-case' import type { OperationUseCase } from '@/lib/core/application/operation' +import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' -import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { type AccessRequestContext, authorizeAccessRequestScope, } from '@/ee/access-requests/lib/application/authorization' -import type { AccessRequestOperation } from '@/ee/access-requests/lib/application/operations' +import type { + AccessRequestOperation, + AccessRequestPrincipal, +} from '@/ee/access-requests/lib/application/operations' import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' interface AccessRequestPreparationArgs { - principal: SessionPrincipal + principal: AccessRequestPrincipal input: I context: AccessRequestContext } @@ -43,10 +46,11 @@ interface UnpreparedAccessRequestUseCase extends AccessRequestUseCaseDefin execute(args: AccessRequestUseCaseArgs & { prepared: undefined }): Promise } -function requireSession(principal: Principal): asserts principal is SessionPrincipal { - if (principal.kind !== 'session') { - throw new OrchestrationError('forbidden', 'A signed-in user is required') - } +function requireAccessRequestPrincipal( + principal: Principal, + operation: AccessRequestOperation +): asserts principal is AccessRequestPrincipal { + requireAllowedWorkspacePrincipal(principal, operation) } export function defineAuthorizedAccessRequestUseCase( @@ -55,18 +59,18 @@ export function defineAuthorizedAccessRequestUseCase( export function defineAuthorizedAccessRequestUseCase( definition: UnpreparedAccessRequestUseCase ): OperationUseCase -/** Shared session-only funnel; preparation finishes before any transaction acquires locks. */ +/** Shared human-credential funnel; preparation finishes before any transaction acquires locks. */ export function defineAuthorizedAccessRequestUseCase( definition: PreparedAccessRequestUseCase | UnpreparedAccessRequestUseCase ): OperationUseCase { return { operation: definition.operation, async authorize({ principal, input }) { - requireSession(principal) + requireAccessRequestPrincipal(principal, definition.operation) await authorizeAccessRequestScope(principal, definition.operation, definition.scope(input)) }, async execute({ principal, input, request }) { - requireSession(principal) + requireAccessRequestPrincipal(principal, definition.operation) const scope = definition.scope(input) const initial = await authorizeAccessRequestScope(principal, definition.operation, scope) return runWithOutboundOrganization(initial.organizationId, async () => { diff --git a/apps/sim/ee/access-requests/lib/application/operations.ts b/apps/sim/ee/access-requests/lib/application/operations.ts index fd6e959ffcf..c2a8e8a2e4c 100644 --- a/apps/sim/ee/access-requests/lib/application/operations.ts +++ b/apps/sim/ee/access-requests/lib/application/operations.ts @@ -1,11 +1,22 @@ +import type { Principal } from '@sim/auth/principal' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' -function defineAccessRequestOperation(id: string, admin = false) { +export type AccessRequestPrincipal = Extract< + Principal, + { kind: 'session' | 'personal_api_key' | 'oauth_access_token' } +> + +function defineAccessRequestOperation( + id: string, + oauthScope: 'api:read' | 'api:write', + admin = false +) { const organizationOperation = defineOrganizationOperation({ id, + oauthScope, minimumRole: admin ? 'admin' : 'member', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], /** * permission-group-exempt: reviewing and requesting withheld access must remain reachable. */ @@ -13,9 +24,10 @@ function defineAccessRequestOperation(id: string, admin = false) { }) const workspaceOperation = defineWorkspaceOperation({ id, + oauthScope, minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], /** * permission-group-exempt: requests never grant a withheld capability without administrator review. */ @@ -28,39 +40,47 @@ export const accessRequestOperations = { /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - discover: defineAccessRequestOperation('access_requests.discover'), + discover: defineAccessRequestOperation('access_requests.discover', 'api:read'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - listMine: defineAccessRequestOperation('access_requests.list_mine'), + listMine: defineAccessRequestOperation('access_requests.list_mine', 'api:read'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - create: defineAccessRequestOperation('access_requests.create'), + create: defineAccessRequestOperation('access_requests.create', 'api:write'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - cancel: defineAccessRequestOperation('access_requests.cancel'), + cancel: defineAccessRequestOperation('access_requests.cancel', 'api:write'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - listOrganization: defineAccessRequestOperation('access_requests.list_organization', true), + listOrganization: defineAccessRequestOperation( + 'access_requests.list_organization', + 'api:read', + true + ), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - preview: defineAccessRequestOperation('access_requests.preview', true), + preview: defineAccessRequestOperation('access_requests.preview', 'api:read', true), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - resolve: defineAccessRequestOperation('access_requests.resolve', true), + resolve: defineAccessRequestOperation('access_requests.resolve', 'api:write', true), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - getSettings: defineAccessRequestOperation('access_requests.get_settings', true), + getSettings: defineAccessRequestOperation('access_requests.get_settings', 'api:read', true), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - updateSettings: defineAccessRequestOperation('access_requests.update_settings', true), + updateSettings: defineAccessRequestOperation( + 'access_requests.update_settings', + 'api:write', + true + ), } as const export type AccessRequestOperation = ReturnType diff --git a/apps/sim/ee/access-requests/lib/application/requests.test.ts b/apps/sim/ee/access-requests/lib/application/requests.test.ts index c3e20698881..e7990f5b4e5 100644 --- a/apps/sim/ee/access-requests/lib/application/requests.test.ts +++ b/apps/sim/ee/access-requests/lib/application/requests.test.ts @@ -535,7 +535,7 @@ describe('discovery and request history', () => { input: { scope, limit: 50, offset: 0 }, }) expect(history.requests).toHaveLength(1) - expect(mocks.list).toHaveBeenCalledWith(db, expect.anything(), 50, 0) + expect(mocks.list).toHaveBeenCalledWith(db, expect.anything(), 50, 0, undefined, undefined) expect(mocks.list.mock.calls[0]?.[1]).toMatchObject({ conditions: expect.arrayContaining([ expect.objectContaining({ diff --git a/apps/sim/ee/access-requests/lib/application/requests.ts b/apps/sim/ee/access-requests/lib/application/requests.ts index e3bf1be02d5..10c9a4ac83f 100644 --- a/apps/sim/ee/access-requests/lib/application/requests.ts +++ b/apps/sim/ee/access-requests/lib/application/requests.ts @@ -5,7 +5,9 @@ import { workspace, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' +import { compareStrings } from '@sim/utils/string' import { and, count, eq, gte, isNull, or } from 'drizzle-orm' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' @@ -49,6 +51,7 @@ import { } from '@/ee/access-requests/lib/targets' import type { AccessRequestDiscovery, + AccessRequestPaging, AccessRequestRecord, AccessRequestSettings, AccessRequestStatus, @@ -58,8 +61,8 @@ import type { function requireOrganization(organizationId: string | null): string { if (!organizationId) - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'ACCESS_REQUEST_ORGANIZATION_REQUIRED', 'Access requests require an organization-owned workspace' ) return organizationId @@ -199,6 +202,18 @@ export const discoverAccessRequests = defineAuthorizedAccessRequestUseCase({ pendingRequestId: pendingByTarget.get(getAccessRequestTargetKey(target)) ?? null, }) } + if (input.sortOrder) { + const direction = input.sortOrder === 'asc' ? 1 : -1 + entries.sort( + (left, right) => + direction * + (compareStrings(left.label, right.label) || + compareStrings( + getAccessRequestTargetKey(left.target), + getAccessRequestTargetKey(right.target) + )) + ) + } const page = entries.slice(offset, offset + (input.limit ?? 50)) return { enabled: true, @@ -230,8 +245,8 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ }): Promise { const organizationId = requireOrganization(context.organizationId) if (!(await isAccessRequestEnabled(organizationId, executor))) - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'ACCESS_REQUESTS_DISABLED', 'Access requests are turned off for this organization' ) await acquirePermissionGroupOrgLock(executor, organizationId, { @@ -432,6 +447,8 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ }) interface ListMineInput { + paging?: AccessRequestPaging + status?: AccessRequestStatus requestId?: string scope: AccessRequestScope limit: number @@ -448,13 +465,16 @@ export const listMyAccessRequests = defineAuthorizedAccessRequestUseCase({ eq(permissionAccessRequest.organizationId, context.organizationId), eq(permissionAccessRequest.requesterId, principal.userId), input.requestId ? eq(permissionAccessRequest.id, input.requestId) : undefined, + input.status ? eq(permissionAccessRequest.status, input.status) : undefined, or( eq(permissionAccessRequest.scopeKey, accessRequestScopeKey(input.scope)), eq(permissionAccessRequest.scopeKey, memberLimitScopeKey(context.organizationId)) ) )!, input.limit, - input.offset + input.offset, + undefined, + input.paging ) }, }) @@ -514,6 +534,7 @@ interface OrganizationInput { organizationId: string } interface OrganizationListInput extends OrganizationInput { + paging?: AccessRequestPaging limit: number offset: number status?: AccessRequestStatus @@ -536,7 +557,8 @@ export const listOrganizationAccessRequests = defineAuthorizedAccessRequestUseCa )!, input.limit, input.offset, - input.search + input.search, + input.paging ), }) diff --git a/apps/sim/ee/access-requests/lib/repository.postgres.test.ts b/apps/sim/ee/access-requests/lib/repository.postgres.test.ts index cce7027acde..d0a7cccfe3d 100644 --- a/apps/sim/ee/access-requests/lib/repository.postgres.test.ts +++ b/apps/sim/ee/access-requests/lib/repository.postgres.test.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { CursorKey } from '@/lib/api/list-query' import { listAccessRequestRecords } from '@/ee/access-requests/lib/repository' vi.unmock('@sim/db/schema') @@ -97,4 +98,47 @@ describe.skipIf(!databaseUrl)('organization request search on PostgreSQL', () => const missing = await listAccessRequestRecords(fixture.executor, where, 25, 0, 'missing') expect(missing).toEqual({ requests: [], total: 0, hasMore: false }) }) + + it.each([ + ['createdAt', 'asc'], + ['createdAt', 'desc'], + ['targetLabel', 'asc'], + ['targetLabel', 'desc'], + ] as const)( + 'pages %s %s across tied values without repeats or omissions', + async (sortBy, sortOrder) => { + const expected = await listAccessRequestRecords(fixture.executor, where, 100, 0, undefined, { + sortBy, + sortOrder, + }) + const ids: string[] = [] + let cursorKeys: CursorKey[] | undefined + for (let pageNumber = 0; pageNumber < 10; pageNumber++) { + const page = await listAccessRequestRecords( + fixture.executor, + where, + pageNumber === 0 ? 1 : 2, + 0, + undefined, + { sortBy, sortOrder, cursorKeys } + ) + ids.push(...page.requests.map(({ id }) => id)) + expect(page.total).toBe(4) + if (!page.nextCursorKeys) break + cursorKeys = page.nextCursorKeys + } + expect(ids).toEqual(expected.requests.map(({ id }) => id)) + expect(new Set(ids).size).toBe(4) + } + ) + + it('rejects invalid timestamp cursor values before PostgreSQL', async () => { + await expect( + listAccessRequestRecords(fixture.executor, where, 1, 0, undefined, { + sortBy: 'createdAt', + sortOrder: 'desc', + cursorKeys: ['not-a-date', 'a'], + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) }) diff --git a/apps/sim/ee/access-requests/lib/repository.ts b/apps/sim/ee/access-requests/lib/repository.ts index 2e651e0e1d2..0c09a648719 100644 --- a/apps/sim/ee/access-requests/lib/repository.ts +++ b/apps/sim/ee/access-requests/lib/repository.ts @@ -1,10 +1,22 @@ import { permissionAccessRequest, user } from '@sim/db/schema' import { and, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm' -import { escapeLikePattern } from '@/lib/api/list-query' +import { + escapeLikePattern, + keysetColumns, + keysetPage, + listOrderBy, + resumeKeyset, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { storedAccessRequestTargetSchema } from '@/ee/access-requests/lib/schemas' -import type { AccessRequestList, AccessRequestRecord } from '@/ee/access-requests/lib/types' +import type { + AccessRequestList, + AccessRequestPaging, + AccessRequestRecord, +} from '@/ee/access-requests/lib/types' export type StoredAccessRequest = typeof permissionAccessRequest.$inferSelect @@ -81,7 +93,8 @@ export async function listAccessRequestRecords( where: SQL, limit: number, offset: number, - search?: string + search?: string, + paging?: AccessRequestPaging ): Promise { const searchTerm = search?.trim() const pattern = searchTerm ? `%${escapeLikePattern(searchTerm)}%` : undefined @@ -95,6 +108,13 @@ export async function listAccessRequestRecords( ) : undefined ) + type PageRow = { row: AccessRequestPresentation; requester: AccessRequestRecord['requester'] } + const keys = [ + paging?.sortBy === 'targetLabel' + ? textKey(permissionAccessRequest.targetLabel, ({ row }) => row.targetLabel) + : timestampKey(permissionAccessRequest.createdAt, ({ row }) => row.createdAt), + textKey(permissionAccessRequest.id, ({ row }) => row.id), + ] const rows = await executor .select({ row: { @@ -114,16 +134,34 @@ export async function listAccessRequestRecords( }) .from(permissionAccessRequest) .innerJoin(user, eq(user.id, permissionAccessRequest.requesterId)) - .where(filteredWhere) - .orderBy(desc(permissionAccessRequest.createdAt), desc(permissionAccessRequest.id)) - .limit(limit) - .offset(offset) + .where( + and( + filteredWhere, + paging ? resumeKeyset(keys, paging.cursorKeys, paging.sortOrder) : undefined + ) + ) + .orderBy( + ...(paging + ? listOrderBy(keysetColumns(keys), paging.sortOrder) + : [desc(permissionAccessRequest.createdAt), desc(permissionAccessRequest.id)]) + ) + .limit(paging ? limit + 1 : limit) + .offset(paging ? 0 : offset) const [aggregate] = await executor .select({ total: count() }) .from(permissionAccessRequest) .innerJoin(user, eq(user.id, permissionAccessRequest.requesterId)) .where(filteredWhere) const total = aggregate?.total ?? 0 + if (paging) { + const page = keysetPage(keys, rows, limit) + return { + requests: page.data.map(({ row, requester }) => projectAccessRequest(row, requester)), + nextCursorKeys: page.nextCursorKeys, + hasMore: page.nextCursorKeys !== null, + total, + } + } return { requests: rows.map(({ row, requester }) => projectAccessRequest(row, requester)), total, diff --git a/apps/sim/ee/access-requests/lib/schemas.ts b/apps/sim/ee/access-requests/lib/schemas.ts index 2ce98b701af..61ce8161cb6 100644 --- a/apps/sim/ee/access-requests/lib/schemas.ts +++ b/apps/sim/ee/access-requests/lib/schemas.ts @@ -3,25 +3,73 @@ import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' import { FILE_SHARE_AUTH_TYPES, PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' import type { AccessRequestTarget as DomainAccessRequestTarget } from '@/ee/access-requests/lib/targets' -const targetIdSchema = z.string().min(1, 'Target ID cannot be empty').max(512) +const targetIdSchema = z + .string() + .min(1, 'Target ID cannot be empty') + .max(512) + .describe('Identifier returned by access discovery.') const fingerprintSchema = z.string().min(1, 'A current preview is required').max(128) /** Canonical validators for the target and decision JSON persisted with a request. */ export const storedAccessRequestTargetSchema = z.discriminatedUnion('kind', [ z .object({ - kind: z.literal('feature'), - configKey: z.enum(PLATFORM_FEATURES.map((feature) => feature.configKey)), + kind: z.literal('feature').describe('Kind of access being requested.'), + configKey: z + .enum(PLATFORM_FEATURES.map((feature) => feature.configKey)) + .describe('Feature restriction key returned by access discovery.'), + }) + .strict(), + z + .object({ + kind: z.literal('integration').describe('Kind of access being requested.'), + id: targetIdSchema, + }) + .strict(), + z + .object({ + kind: z.literal('provider').describe('Kind of access being requested.'), + id: targetIdSchema, + }) + .strict(), + z + .object({ + kind: z.literal('model').describe('Kind of access being requested.'), + id: targetIdSchema, + }) + .strict(), + z + .object({ + kind: z.literal('tool').describe('Kind of access being requested.'), + id: targetIdSchema, + }) + .strict(), + z + .object({ + kind: z.literal('knowledge_connector').describe('Kind of access being requested.'), + id: targetIdSchema, + }) + .strict(), + z + .object({ + kind: z.literal('file_share_auth').describe('Kind of access being requested.'), + id: z.enum(FILE_SHARE_AUTH_TYPES).describe('Authentication mode to request.'), + }) + .strict(), + z + .object({ + kind: z.literal('chat_deploy_auth').describe('Kind of access being requested.'), + id: z.enum(FILE_SHARE_AUTH_TYPES).describe('Authentication mode to request.'), + }) + .strict(), + z + .object({ + kind: z.literal('usage_limit').describe('Kind of access being requested.'), + id: z + .literal('member') + .describe('Request an increase to the acting user’s member credit cap.'), }) .strict(), - z.object({ kind: z.literal('integration'), id: targetIdSchema }).strict(), - z.object({ kind: z.literal('provider'), id: targetIdSchema }).strict(), - z.object({ kind: z.literal('model'), id: targetIdSchema }).strict(), - z.object({ kind: z.literal('tool'), id: targetIdSchema }).strict(), - z.object({ kind: z.literal('knowledge_connector'), id: targetIdSchema }).strict(), - z.object({ kind: z.literal('file_share_auth'), id: z.enum(FILE_SHARE_AUTH_TYPES) }).strict(), - z.object({ kind: z.literal('chat_deploy_auth'), id: z.enum(FILE_SHARE_AUTH_TYPES) }).strict(), - z.object({ kind: z.literal('usage_limit'), id: z.literal('member') }).strict(), ]) satisfies z.ZodType export const storedAccessRequestPolicyValueSchema = z.union([ z.boolean(), @@ -30,12 +78,12 @@ export const storedAccessRequestPolicyValueSchema = z.union([ export const storedAccessRequestPolicyChangeSchema = z .object({ - configKey: z.enum( - Object.keys(PERMISSION_GROUP_FIELDS) as (keyof typeof PERMISSION_GROUP_FIELDS)[] - ), - label: z.string().min(1).max(512), - before: storedAccessRequestPolicyValueSchema, - after: storedAccessRequestPolicyValueSchema, + configKey: z + .enum(Object.keys(PERMISSION_GROUP_FIELDS) as (keyof typeof PERMISSION_GROUP_FIELDS)[]) + .describe('Permission restriction changed by approval.'), + label: z.string().min(1).max(512).describe('Human-readable permission name.'), + before: storedAccessRequestPolicyValueSchema.describe('Current value of the restriction.'), + after: storedAccessRequestPolicyValueSchema.describe('Value after applying the request.'), }) .superRefine((change, context) => { const schema = PERMISSION_GROUP_FIELDS[change.configKey].readSchema @@ -51,19 +99,47 @@ export const storedAccessRequestPolicyChangeSchema = z }) export const storedAccessRequestDecisionSchema = z.object({ - resolutionKind: z.enum(['permission', 'usage_limit']), + resolutionKind: z + .enum(['permission', 'usage_limit']) + .describe('Whether approval changes group permissions or a member credit cap.'), changes: z .array(storedAccessRequestPolicyChangeSchema) - .max(Object.keys(PERMISSION_GROUP_FIELDS).length), - impact: z.object({ - memberCount: z.number().int().nonnegative(), - workspaceCount: z.number().int().nonnegative(), - workspaceNames: z.array(z.string()).max(100), - truncated: z.boolean(), - }), - group: z.object({ id: z.string().min(1).max(128), name: z.string() }).nullable(), - currentLimitCredits: z.number().finite().nonnegative().nullable(), - newLimitCredits: z.number().finite().nonnegative().nullable(), - fingerprint: fingerprintSchema, + .max(Object.keys(PERMISSION_GROUP_FIELDS).length) + .describe('Permission changes applied to the governing group.'), + impact: z + .object({ + memberCount: z.number().int().nonnegative().describe('Number of affected members.'), + workspaceCount: z.number().int().nonnegative().describe('Number of affected workspaces.'), + workspaceNames: z + .array(z.string()) + .max(100) + .describe('Names of affected workspaces, capped at 100.'), + truncated: z + .boolean() + .describe('Whether the workspace-name list is truncated; counts include the full impact.'), + }) + .describe('Members and workspaces affected by approval.'), + group: z + .object({ + id: z.string().min(1).max(128).describe('Governing group identifier.'), + name: z.string().describe('Governing group name.'), + }) + .nullable() + .describe('Group affected by a permission approval; null for credit-cap changes.'), + currentLimitCredits: z + .number() + .finite() + .nonnegative() + .nullable() + .describe('Current member credit cap; null for permission changes.'), + newLimitCredits: z + .number() + .finite() + .nonnegative() + .nullable() + .describe('Applied member credit cap; null before approval or for permission changes.'), + fingerprint: fingerprintSchema.describe( + 'Fingerprint binding approval to this policy and membership snapshot.' + ), }) export type AccessRequestDecision = z.output diff --git a/apps/sim/ee/access-requests/lib/types.ts b/apps/sim/ee/access-requests/lib/types.ts index 33f6e18e476..8a94ee8e890 100644 --- a/apps/sim/ee/access-requests/lib/types.ts +++ b/apps/sim/ee/access-requests/lib/types.ts @@ -1,3 +1,4 @@ +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import type { AccessRequestDecision } from '@/ee/access-requests/lib/schemas' import type { AccessRequestScope, AccessRequestTarget } from '@/ee/access-requests/lib/targets' @@ -22,8 +23,15 @@ export interface AccessRequestRecord { requester: { id: string; name: string | null; email: string } } +export interface AccessRequestPaging { + sortBy: 'createdAt' | 'targetLabel' + sortOrder: ListSortOrder + cursorKeys?: CursorKey[] +} + export interface AccessRequestList { requests: AccessRequestRecord[] + nextCursorKeys?: CursorKey[] | null total: number hasMore: boolean } @@ -38,6 +46,7 @@ export type DiscoverAccessRequestsInput = AccessRequestScope & { limit: number offset: number search?: string + sortOrder?: ListSortOrder targetKind?: AccessRequestTarget['kind'] targetKey?: string state?: 'allowed' | 'requestable' | 'unavailable' diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts index 4532bbf6524..02951251afb 100644 --- a/apps/sim/lib/api/contracts/access-requests.ts +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -33,6 +33,7 @@ const requestIdSchema = z .string() .min(1, 'Request ID cannot be empty') .max(ACCESS_REQUEST_MAX_ID_LENGTH) + .describe('Access request identifier.') const reasonSchema = z.string().trim().max(1000, 'Reason must be at most 1000 characters') const fingerprintSchema = z.string().min(1, 'A current preview is required').max(128) const usageLimitSchema = z @@ -164,32 +165,58 @@ export type AccessRequestSettings = z.output export type UpdateAccessRequestSettingsBody = z.input export const accessRequestRecordSchema = z.object({ - id: requestIdSchema, - organizationId: organizationIdSchema, - workspaceId: workspaceIdSchema.nullable(), - target: accessRequestTargetSchema, - targetLabel: z.string().min(1).max(512), - reason: reasonSchema, - status: z.enum(ACCESS_REQUEST_STATUSES), - decisionReason: reasonSchema.nullable(), - createdAt: z.iso.datetime(), - decidedAt: z.iso.datetime().nullable(), - groupName: z.string().nullable(), - requester: z.object({ - id: z.string().min(1).max(128), - name: z.string().nullable(), - email: z.string().max(320), - }), + id: requestIdSchema.describe('Access request identifier.'), + organizationId: organizationIdSchema.describe('Organization that owns the request.'), + workspaceId: workspaceIdSchema + .nullable() + .describe('Workspace where access was requested; null for an organization-level request.'), + target: accessRequestTargetSchema.describe( + 'The requested feature, integration, model, tool, authentication mode, or member credit cap.' + ), + targetLabel: z.string().min(1).max(512).describe('Human-readable name of the requested access.'), + reason: reasonSchema.describe('Reason supplied by the requester.'), + status: z.enum(ACCESS_REQUEST_STATUSES).describe('Current request status.'), + decisionReason: reasonSchema + .nullable() + .describe('Explanation for a declined or closed request; null when none was recorded.'), + createdAt: z.iso.datetime().describe('When the request was submitted.'), + decidedAt: z.iso + .datetime() + .nullable() + .describe('When the request was resolved; null while pending.'), + groupName: z + .string() + .nullable() + .describe( + 'Name of the governing group when the request was submitted; null for credit-cap requests.' + ), + requester: z + .object({ + id: z.string().min(1).max(128).describe('Requester user identifier.'), + name: z.string().nullable().describe('Requester display name.'), + email: z.string().max(320).describe('Requester email address.'), + }) + .describe('User who submitted the request.'), }) export type AccessRequestRecord = z.output export type AccessRequestStatus = AccessRequestRecord['status'] export const accessRequestDiscoveryEntrySchema = z.object({ - target: accessRequestTargetSchema, - label: z.string().min(1).max(512), - state: z.enum(['allowed', 'requestable', 'unavailable']), - reason: z.string().max(1000).nullable(), - pendingRequestId: requestIdSchema.nullable(), + target: accessRequestTargetSchema.describe( + 'Pass this target unchanged to Create Access Request.' + ), + label: z.string().min(1).max(512).describe('Human-readable access item name.'), + state: z + .enum(['allowed', 'requestable', 'unavailable']) + .describe('Whether access is already allowed, can be requested, or is unavailable.'), + reason: z + .string() + .max(1000) + .nullable() + .describe('Why access is unavailable or restricted; null when no explanation is needed.'), + pendingRequestId: requestIdSchema + .nullable() + .describe('Existing pending request for this item; null when none exists.'), }) export type AccessRequestDiscoveryEntry = z.output @@ -219,29 +246,53 @@ export const accessRequestDecisionSchema = storedAccessRequestDecisionSchema export type AccessRequestDecision = z.output const previewShape = { - newLimitCredits: z.number().finite().nonnegative().nullable(), - request: accessRequestRecordSchema, + newLimitCredits: z + .number() + .finite() + .nonnegative() + .nullable() + .describe( + 'Applied credit cap for a fulfilled request; null before approval or for permission changes.' + ), + request: accessRequestRecordSchema.describe('Access request being reviewed.'), changes: z .array(accessRequestPolicyChangeSchema) - .max(Object.keys(PERMISSION_GROUP_FIELDS).length), + .max(Object.keys(PERMISSION_GROUP_FIELDS).length) + .describe('Permission changes proposed for the whole governing group.'), impact: storedAccessRequestDecisionSchema.shape.impact, - fingerprint: fingerprintSchema, - canApply: z.boolean(), - unavailableReason: z.string().max(1000).nullable(), + fingerprint: fingerprintSchema.describe( + 'Pass to Resolve Organization Access Request after reviewing the changes and impact.' + ), + canApply: z.boolean().describe('Whether this request can currently be approved.'), + unavailableReason: z + .string() + .max(1000) + .nullable() + .describe('Why approval is unavailable; null when canApply is true.'), } export const accessRequestPreviewResponseSchema = z.discriminatedUnion('resolutionKind', [ z.object({ ...previewShape, - resolutionKind: z.literal('permission'), + resolutionKind: z + .literal('permission') + .describe('Approval changes the governing permission group.'), group: storedAccessRequestDecisionSchema.shape.group, - currentLimitCredits: z.null(), + currentLimitCredits: z.null().describe('Not applicable to permission changes.'), }), z.object({ ...previewShape, - resolutionKind: z.literal('usage_limit'), - group: z.null(), - currentLimitCredits: z.number().finite().nonnegative().max(Number.MAX_SAFE_INTEGER).nullable(), + resolutionKind: z + .literal('usage_limit') + .describe('Approval raises the requester’s member credit cap.'), + group: z.null().describe('Credit-cap requests do not change a permission group.'), + currentLimitCredits: z + .number() + .finite() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .nullable() + .describe('Current member credit cap. Approval requires a higher newLimitCredits.'), }), ]) export type AccessRequestPreviewResponse = z.output diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index cbc360f10ab..ea21b56c7a5 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -84,23 +84,54 @@ export const batchWorkspaceInvitationBodySchema = z }) export const batchInvitationResultSchema = z.object({ - success: z.boolean(), - /** Emails that received a pending invitation. */ - successful: z.array(z.string()), - /** Emails that were existing organization members and got access immediately. */ - added: z.array(z.string()), - failed: z.array(z.object({ email: z.string(), error: z.string() })), - invitations: z.array( - z.object({ - id: z.string(), - email: z.string(), - workspaceIds: z.array(z.string()), - permission: workspacePermissionSchema, - membershipIntent: z.enum(['internal', 'external']), - instantAdd: z.boolean().optional(), - outcome: z.enum(['added', 'updated', 'unchanged']).optional(), - }) - ), + success: z + .boolean() + .describe( + 'Whether every recipient succeeded. Inspect failed even when the HTTP response is successful.' + ), + successful: z.array(z.string()).describe('Email addresses that received a pending invitation.'), + added: z + .array(z.string()) + .describe('Existing organization members granted workspace access immediately.'), + failed: z + .array( + z.object({ + email: z.string().describe('Recipient whose operation failed.'), + error: z.string().describe('Reason the invitation or access grant could not be completed.'), + }) + ) + .describe( + 'Failures for individual recipients. Earlier successful recipients remain committed.' + ), + invitations: z + .array( + z.object({ + id: z + .string() + .describe( + 'Invitation identifier, or direct-grant result identifier when instantAdd is true.' + ), + email: z.string().describe('Recipient email address.'), + workspaceIds: z + .array(z.string()) + .describe('Workspaces included in this invitation or direct grant.'), + permission: workspacePermissionSchema.describe('Workspace permission offered or granted.'), + membershipIntent: z + .enum(['internal', 'external']) + .describe( + 'Whether the recipient joins the organization or receives only workspace access.' + ), + instantAdd: z + .boolean() + .optional() + .describe('Whether access was granted immediately without a pending invitation.'), + outcome: z + .enum(['added', 'updated', 'unchanged']) + .optional() + .describe('Result when reconciling access for an existing user.'), + }) + ) + .describe('Invitation and immediate-access results for successful recipients.'), }) export const removeWorkspaceMemberBodySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/organization-usage.ts b/apps/sim/lib/api/contracts/organization-usage.ts index dbfeea8ecda..8a8f3876bcd 100644 --- a/apps/sim/lib/api/contracts/organization-usage.ts +++ b/apps/sim/lib/api/contracts/organization-usage.ts @@ -30,7 +30,9 @@ export const USAGE_BREAKDOWN_DIMENSIONS = [ 'byok', 'source', ] as const -export const usageBreakdownDimensionSchema = z.enum(USAGE_BREAKDOWN_DIMENSIONS) +export const usageBreakdownDimensionSchema = z + .enum(USAGE_BREAKDOWN_DIMENSIONS) + .describe('Usage grouping dimension.') export type UsageBreakdownDimension = z.output /** @@ -104,7 +106,8 @@ const organizationUsageWindowQuerySchema = z.object({ .string() .min(1, 'timezone cannot be empty') .refine(isValidTimezone, 'Expected an IANA timezone such as America/Los_Angeles') - .default('UTC'), + .default('UTC') + .describe('IANA timezone for calendar boundaries; defaults to UTC.'), }) /** @@ -115,7 +118,9 @@ const organizationUsageWindowQuerySchema = z.object({ * surface could express alone is one the two could disagree about. */ const usageWorkspaceScopeShape = { - workspaceId: workspaceIdSchema.optional(), + workspaceId: workspaceIdSchema + .optional() + .describe('Restrict usage to one workspace owned by the organization.'), } as const export const organizationUsageSummaryQuerySchema = @@ -162,65 +167,93 @@ export type OrganizationUsageExportQuery = z.input export const organizationUsageBreakdownRowSchema = z.object({ - id: z.string(), - label: z.string(), - credits: z.number(), - events: z.number().int(), + id: z.string().describe('Group identifier; an empty ID represents unattributed usage.'), + label: z.string().describe('Display label for the usage group.'), + credits: z.number().describe('Whole credits attributed to this total or group.'), + events: z.number().int().describe('Number of usage events.'), /** 0..1 of the window total, not of the visible rows. */ - share: z.number().min(0).max(1), + share: z + .number() + .min(0) + .max(1) + .describe('Fraction of total cost, or total tokens for BYOK, between zero and one.'), /** Model dimensions only — resolved server-side so the client needs no model registry. */ - providerId: z.string().optional(), + providerId: z.string().optional().describe('Model provider identifier, when applicable.'), /** Model dimensions only; BYOK rows carry no cost, so this is their only usage figure. */ - tokens: z.number().int().optional(), + tokens: z.number().int().optional().describe('Input and output tokens for model or BYOK groups.'), }) export type OrganizationUsageBreakdownRow = z.output export const organizationUsageBreakdownResponseSchema = z.object({ dimension: usageBreakdownDimensionSchema, - rows: z.array(organizationUsageBreakdownRowSchema), + rows: z + .array(organizationUsageBreakdownRowSchema) + .describe('Top usage groups ordered by cost, or tokens for BYOK.'), /** The truncated tail, so the visible rows plus this reconcile to `totalCredits`. */ - other: z.object({ - credits: z.number(), - events: z.number().int(), - rowCount: z.number().int(), - /** Tokens for the omitted rows, so the token-denominated BYOK tab still adds up. */ - tokens: z.number().int().nonnegative(), - }), - totalCredits: z.number(), + other: z + .object({ + credits: z.number().describe('Whole credits attributed to this total or group.'), + events: z.number().int().describe('Number of usage events.'), + rowCount: z.number().int().describe('Number of groups omitted from rows.'), + /** Tokens for the omitted rows, so the token-denominated BYOK tab still adds up. */ + tokens: z + .number() + .int() + .nonnegative() + .describe('Input and output tokens attributed to omitted groups.'), + }) + .describe('Combined usage for groups omitted by the limit.'), + totalCredits: z + .number() + .describe( + 'Whole credits represented by this breakdown; workflow includes only workflow-attributed usage.' + ), }) export type OrganizationUsageBreakdown = z.output export const organizationUsageEventSchema = z.object({ - id: z.string(), + id: z.string().describe('Unique usage-ledger event identifier.'), createdAt: z.string(), source: z.string(), description: z.string(), workflowName: z.string().nullable(), - credits: z.number(), + credits: z.number().describe('Whole credits attributed to this total or group.'), hasCost: z.boolean(), }) export type OrganizationUsageEvent = z.output diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 96f3b4b3332..174b6e60bed 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -90,14 +90,24 @@ export const userPermissionConfigQuerySchema = z.object({ }) export const userPermissionConfigSchema = z.object({ - permissionGroupId: z.string().nullable(), - groupName: z.string().nullable(), - config: permissionGroupFullConfigSchema.nullable(), - entitled: z.boolean(), - /** The workspace's owning organization id (null when the workspace has no org). */ - organizationId: z.string().nullable(), - /** Whether the caller is an owner/admin of the workspace's owning organization. */ - isOrgAdmin: z.boolean(), + permissionGroupId: z + .string() + .nullable() + .describe('Identifier of the group governing the caller; null when no group applies.'), + groupName: z.string().nullable().describe('Name of the governing permission group.'), + config: permissionGroupFullConfigSchema + .nullable() + .describe( + 'Effective group restrictions. True disables a boolean capability; null allowlists allow all values and empty allowlists allow none. Null config means no group applies.' + ), + entitled: z.boolean().describe('Whether organization permission governance is active.'), + organizationId: z + .string() + .nullable() + .describe('Organization that owns the workspace; null for a personal workspace.'), + isOrgAdmin: z + .boolean() + .describe('Whether the caller is an owner or administrator of the workspace’s organization.'), }) export type UserPermissionConfig = z.output 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 21385ead7c3..197174200be 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,14 @@ import { /** Lists that accept `limit` + `cursor` and can return a non-null `nextCursor`. */ const PAGED_LISTS = [ + 'GET /api/v2/organizations/[organizationId]/usage/events', + 'GET /api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces', + 'GET /api/v2/organizations/[organizationId]/access-requests', + 'GET /api/v2/organizations/[organizationId]/access-requests/mine', + 'GET /api/v2/workspaces/[workspaceId]/access-requests', + 'GET /api/v2/organizations/[organizationId]/access-requests/discovery', + 'GET /api/v2/workspaces/[workspaceId]/access-requests/discovery', + 'GET /api/v2/organizations', 'GET /api/v2/organizations/[organizationId]/members', 'GET /api/v2/organizations/[organizationId]/invitations', @@ -154,6 +162,47 @@ const FULL_SET_LISTS = [ * therefore fails here until someone decides whether the cursor is bound to it. */ const CURSOR_BINDINGS: Record = { + 'GET /api/v2/organizations/[organizationId]/usage/events': [ + 'preset', + 'startDate', + 'endDate', + 'timezone', + 'source', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces': [ + 'search', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/organizations/[organizationId]/access-requests': [ + 'status', + 'search', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/organizations/[organizationId]/access-requests/mine': [ + 'status', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/workspaces/[workspaceId]/access-requests': ['status', 'sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/access-requests/discovery': [ + 'search', + 'targetKind', + 'state', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/workspaces/[workspaceId]/access-requests/discovery': [ + 'search', + 'targetKind', + 'state', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/organizations': ['search', 'sortBy', 'sortOrder'], 'GET /api/v2/organizations/[organizationId]/members': ['search', 'sortBy', 'sortOrder'], 'GET /api/v2/organizations/[organizationId]/invitations': [ @@ -311,6 +360,17 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { + 'GET /api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces': [ + 'organizationId', + 'invitationId', + ], + 'GET /api/v2/organizations/[organizationId]/access-requests': ['organizationId'], + 'GET /api/v2/organizations/[organizationId]/access-requests/mine': ['organizationId'], + 'GET /api/v2/workspaces/[workspaceId]/access-requests': ['workspaceId'], + 'GET /api/v2/organizations/[organizationId]/access-requests/discovery': ['organizationId'], + 'GET /api/v2/workspaces/[workspaceId]/access-requests/discovery': ['workspaceId'], + 'GET /api/v2/organizations/[organizationId]/usage/events': ['organizationId'], + 'GET /api/v2/organizations/[organizationId]/members': ['organizationId'], 'GET /api/v2/organizations/[organizationId]/invitations': ['organizationId'], 'GET /api/v2/organizations/[organizationId]/workspaces': ['organizationId'], diff --git a/apps/sim/lib/api/contracts/v2/access-requests.ts b/apps/sim/lib/api/contracts/v2/access-requests.ts new file mode 100644 index 00000000000..063415a7dce --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/access-requests.ts @@ -0,0 +1,258 @@ +import { z } from 'zod' +import { + ACCESS_REQUEST_STATUSES, + accessRequestDiscoveryEntrySchema, + accessRequestParamsSchema, + accessRequestPreviewResponseSchema, + accessRequestRecordSchema, + accessRequestSettingsSchema, + createAccessRequestBodySchema, + resolveAccessRequestBodySchema, +} from '@/lib/api/contracts/access-requests' +import { + noInputSchema, + organizationIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { ACCESS_REQUEST_TARGET_KINDS } from '@/ee/access-requests/lib/targets' + +export const v2AccessRequestSchema = accessRequestRecordSchema.meta({ id: 'V2AccessRequest' }) +export type V2AccessRequest = z.output +export const v2AccessRequestDiscoveryEntrySchema = accessRequestDiscoveryEntrySchema.meta({ + id: 'V2AccessRequestDiscoveryEntry', +}) +export type V2AccessRequestDiscoveryEntry = z.output +export const v2AccessRequestPreviewSchema = z.discriminatedUnion('resolutionKind', [ + accessRequestPreviewResponseSchema.options[0].meta({ id: 'V2PermissionAccessRequestPreview' }), + accessRequestPreviewResponseSchema.options[1].meta({ id: 'V2CreditLimitAccessRequestPreview' }), +]) +export type V2AccessRequestPreview = z.output + +export const v2OrganizationAccessRequestParamsSchema = z.object({ + organizationId: organizationIdSchema.describe('Organization that owns the access requests.'), +}) +export type V2OrganizationAccessRequestParams = z.input< + typeof v2OrganizationAccessRequestParamsSchema +> +export const v2WorkspaceAccessRequestParamsSchema = z.object({ + workspaceId: workspaceIdSchema.describe('Workspace in which the acting user requests access.'), +}) +export type V2WorkspaceAccessRequestParams = z.input +export const v2OrganizationAccessRequestDetailParamsSchema = + v2OrganizationAccessRequestParamsSchema.extend(accessRequestParamsSchema.shape) +export type V2OrganizationAccessRequestDetailParams = z.input< + typeof v2OrganizationAccessRequestDetailParamsSchema +> +export const v2WorkspaceAccessRequestDetailParamsSchema = + v2WorkspaceAccessRequestParamsSchema.extend(accessRequestParamsSchema.shape) +export type V2WorkspaceAccessRequestDetailParams = z.input< + typeof v2WorkspaceAccessRequestDetailParamsSchema +> + +export const v2ListAccessRequestsQuerySchema = z + .object({ + status: z + .enum(ACCESS_REQUEST_STATUSES) + .optional() + .describe('Filter by request status; omit to include all statuses.'), + ...v2SortFields(['createdAt', 'targetLabel'] as const, { + sortBy: 'createdAt', + sortOrder: 'desc', + }), + ...v2PaginationFields({ description: 'Maximum access requests to return per page.' }), + }) + .strict() +export type V2ListAccessRequestsQuery = z.input +export const v2ListOrganizationAccessRequestsQuerySchema = v2ListAccessRequestsQuerySchema + .extend({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the target label or requester name or email.' + ), + }) + .strict() +export type V2ListOrganizationAccessRequestsQuery = z.input< + typeof v2ListOrganizationAccessRequestsQuerySchema +> +export const v2DiscoverAccessRequestsQuerySchema = z + .object({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the access item label.' + ), + targetKind: z + .enum(ACCESS_REQUEST_TARGET_KINDS) + .optional() + .describe('Category of access to discover.'), + state: z + .enum(['allowed', 'requestable', 'unavailable']) + .optional() + .describe( + 'Filter by the acting user’s current access. Requestable items can be submitted for review.' + ), + ...v2SortFields(['label'] as const, { sortBy: 'label', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum access items to return per page.' }), + }) + .strict() +export type V2DiscoverAccessRequestsQuery = z.input +export const v2CreateAccessRequestBodySchema = createAccessRequestBodySchema + .omit({ scope: true }) + .extend({ + target: createAccessRequestBodySchema.shape.target.describe( + 'Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable.' + ), + reason: createAccessRequestBodySchema.shape.reason.describe( + 'Why the acting user needs this access.' + ), + }) + .strict() +export type V2CreateAccessRequestBody = z.input +export const v2ResolveAccessRequestBodySchema = z.discriminatedUnion('action', [ + resolveAccessRequestBodySchema.options[0].extend({ + action: z + .literal('apply') + .describe('Apply the reviewed change to the governing group or member credit cap.'), + expectedFingerprint: + resolveAccessRequestBodySchema.options[0].shape.expectedFingerprint.describe( + 'Fingerprint from Preview Organization Access Request. Review its changes and impact before applying; a stale preview returns a conflict.' + ), + newLimitCredits: resolveAccessRequestBodySchema.options[0].shape.newLimitCredits.describe( + 'Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests.' + ), + }), + resolveAccessRequestBodySchema.options[1].extend({ + action: z + .literal('decline') + .describe('Decline the request without changing permissions or credit limits.'), + reason: resolveAccessRequestBodySchema.options[1].shape.reason.describe( + 'Required explanation for declining this request.' + ), + }), +]) +export type V2ResolveAccessRequestBody = z.input +export const v2AccessRequestSettingsSchema = accessRequestSettingsSchema.extend({ + allowRequests: accessRequestSettingsSchema.shape.allowRequests.describe( + 'Allow new requests and approvals. Disabling requests preserves history and still allows cancellation and decline.' + ), +}) + +export type V2AccessRequestSettings = z.input + +export const v2AccessRequestSettingsDataSchema = v2AccessRequestSettingsSchema.meta({ + id: 'V2AccessRequestSettings', +}) +export type V2AccessRequestSettingsData = z.output + +export const v2DiscoverWorkspaceAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/access-requests/discovery', + params: v2WorkspaceAccessRequestParamsSchema, + query: v2DiscoverAccessRequestsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2AccessRequestDiscoveryEntrySchema) }, +}) + +export const v2ListMyWorkspaceAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/access-requests', + params: v2WorkspaceAccessRequestParamsSchema, + query: v2ListAccessRequestsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2AccessRequestSchema) }, +}) + +export const v2CreateWorkspaceAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/access-requests', + params: v2WorkspaceAccessRequestParamsSchema, + query: noInputSchema, + body: v2CreateAccessRequestBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestSchema) }, +}) + +export const v2CancelWorkspaceAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel', + params: v2WorkspaceAccessRequestDetailParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestSchema) }, +}) + +export const v2DiscoverOrganizationAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/discovery', + params: v2OrganizationAccessRequestParamsSchema, + query: v2DiscoverAccessRequestsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2AccessRequestDiscoveryEntrySchema) }, +}) + +export const v2ListMyOrganizationAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/mine', + params: v2OrganizationAccessRequestParamsSchema, + query: v2ListAccessRequestsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2AccessRequestSchema) }, +}) + +export const v2CreateOrganizationAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/access-requests', + params: v2OrganizationAccessRequestParamsSchema, + query: noInputSchema, + body: v2CreateAccessRequestBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestSchema) }, +}) + +export const v2CancelOrganizationAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel', + params: v2OrganizationAccessRequestDetailParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestSchema) }, +}) + +export const v2ListOrganizationAccessRequestsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests', + params: v2OrganizationAccessRequestParamsSchema, + query: v2ListOrganizationAccessRequestsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2AccessRequestSchema) }, +}) + +export const v2PreviewOrganizationAccessRequestContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview', + params: v2OrganizationAccessRequestDetailParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestPreviewSchema) }, +}) + +export const v2ResolveOrganizationAccessRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve', + params: v2OrganizationAccessRequestDetailParamsSchema, + query: noInputSchema, + body: v2ResolveAccessRequestBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestSchema) }, +}) + +export const v2GetOrganizationAccessRequestSettingsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/settings', + params: v2OrganizationAccessRequestParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestSettingsDataSchema) }, +}) + +export const v2UpdateOrganizationAccessRequestSettingsContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/access-requests/settings', + params: v2OrganizationAccessRequestParamsSchema, + query: noInputSchema, + body: v2AccessRequestSettingsSchema, + response: { mode: 'json', schema: v2DataResponse(v2AccessRequestSettingsDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/access-requests.ts b/apps/sim/lib/api/contracts/v2/openapi/access-requests.ts new file mode 100644 index 00000000000..f7efbe4ffd9 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/access-requests.ts @@ -0,0 +1,602 @@ +import { + v2CancelOrganizationAccessRequestContract, + v2CancelWorkspaceAccessRequestContract, + v2CreateOrganizationAccessRequestContract, + v2CreateWorkspaceAccessRequestContract, + v2DiscoverOrganizationAccessRequestsContract, + v2DiscoverWorkspaceAccessRequestsContract, + v2GetOrganizationAccessRequestSettingsContract, + v2ListMyOrganizationAccessRequestsContract, + v2ListMyWorkspaceAccessRequestsContract, + v2ListOrganizationAccessRequestsContract, + v2PreviewOrganizationAccessRequestContract, + v2ResolveOrganizationAccessRequestContract, + v2UpdateOrganizationAccessRequestSettingsContract, +} from '@/lib/api/contracts/v2/access-requests' +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' + +const requestExample = { + id: 'request-123', + organizationId: 'org-123', + workspaceId: 'workspace-123', + target: { kind: 'feature', configKey: 'hideTablesTab' }, + targetLabel: 'Tables', + reason: 'Maintain team data', + status: 'pending', + decisionReason: null, + createdAt: '2026-06-01T09:00:00.000Z', + decidedAt: null, + groupName: 'Engineering', + requester: { id: 'user-123', name: 'Alex Example', email: 'alex@example.com' }, +} as const + +export const accessRequestOpenApiRoutes = [ + defineOpenApiRoute( + v2DiscoverWorkspaceAccessRequestsContract, + { + applicationOperation: accessRequestOperations.discover, + operationId: 'discoverWorkspaceAccessRequests', + summary: 'Discover Workspace Access Requests', + description: `Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Discover Workspace Access Requests result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2DiscoverWorkspaceAccessRequestsContract.params, + 'DiscoverWorkspaceAccessRequestsParams', + 'Discover Workspace Access Requests parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2DiscoverWorkspaceAccessRequestsContract.query, + 'DiscoverWorkspaceAccessRequestsQuery', + 'Discover Workspace Access Requests query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2DiscoverWorkspaceAccessRequestsContract.response.schema, + 'DiscoverWorkspaceAccessRequestsResponse', + 'Discover Workspace Access Requests response', + 'Discover Workspace Access Requests result.', + [ + { + data: [ + { + target: requestExample.target, + label: 'Tables', + state: 'requestable', + reason: null, + pendingRequestId: null, + }, + ], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ListMyWorkspaceAccessRequestsContract, + { + applicationOperation: accessRequestOperations.listMine, + operationId: 'listMyWorkspaceAccessRequests', + summary: 'List My Workspace Access Requests', + description: `List only the acting user’s requests in this workspace, including resolved history and organization-wide member credit-limit requests. History remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'List My Workspace Access Requests result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListMyWorkspaceAccessRequestsContract.params, + 'ListMyWorkspaceAccessRequestsParams', + 'List My Workspace Access Requests parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2ListMyWorkspaceAccessRequestsContract.query, + 'ListMyWorkspaceAccessRequestsQuery', + 'List My Workspace Access Requests query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2ListMyWorkspaceAccessRequestsContract.response.schema, + 'ListMyWorkspaceAccessRequestsResponse', + 'List My Workspace Access Requests response', + 'List My Workspace Access Requests result.', + [{ data: [requestExample], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateWorkspaceAccessRequestContract, + { + applicationOperation: accessRequestOperations.create, + operationId: 'createWorkspaceAccessRequest', + summary: 'Create Workspace Access Request', + description: `Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires access to the workspace; external collaborators use their workspace grant. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Create Workspace Access Request result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateWorkspaceAccessRequestContract.params, + 'CreateWorkspaceAccessRequestParams', + 'Create Workspace Access Request parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2CreateWorkspaceAccessRequestContract.query, + 'CreateWorkspaceAccessRequestQuery', + 'Create Workspace Access Request query', + 'Query parameters for this operation.' + ), + body: documentedSchema( + v2CreateWorkspaceAccessRequestContract.body, + 'CreateWorkspaceAccessRequestBody', + 'Create Workspace Access Request body', + 'Inputs for this operation.', + [{ target: requestExample.target, reason: 'Maintain team data' }] + ), + response: documentedSchema( + v2CreateWorkspaceAccessRequestContract.response.schema, + 'CreateWorkspaceAccessRequestResponse', + 'Create Workspace Access Request response', + 'Create Workspace Access Request result.', + [{ data: requestExample }] + ), + } + ), + defineOpenApiRoute( + v2CancelWorkspaceAccessRequestContract, + { + applicationOperation: accessRequestOperations.cancel, + operationId: 'cancelWorkspaceAccessRequest', + summary: 'Cancel Workspace Access Request', + description: `Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Cancel Workspace Access Request result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CancelWorkspaceAccessRequestContract.params, + 'CancelWorkspaceAccessRequestParams', + 'Cancel Workspace Access Request parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2CancelWorkspaceAccessRequestContract.query, + 'CancelWorkspaceAccessRequestQuery', + 'Cancel Workspace Access Request query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2CancelWorkspaceAccessRequestContract.response.schema, + 'CancelWorkspaceAccessRequestResponse', + 'Cancel Workspace Access Request response', + 'Cancel Workspace Access Request result.', + [ + { + data: { ...requestExample, status: 'cancelled', decidedAt: '2026-06-01T10:00:00.000Z' }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2DiscoverOrganizationAccessRequestsContract, + { + applicationOperation: accessRequestOperations.discover, + operationId: 'discoverOrganizationAccessRequests', + summary: 'Discover Organization Access Requests', + description: `Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires organization membership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Discover Organization Access Requests result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2DiscoverOrganizationAccessRequestsContract.params, + 'DiscoverOrganizationAccessRequestsParams', + 'Discover Organization Access Requests parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2DiscoverOrganizationAccessRequestsContract.query, + 'DiscoverOrganizationAccessRequestsQuery', + 'Discover Organization Access Requests query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2DiscoverOrganizationAccessRequestsContract.response.schema, + 'DiscoverOrganizationAccessRequestsResponse', + 'Discover Organization Access Requests response', + 'Discover Organization Access Requests result.', + [ + { + data: [ + { + target: requestExample.target, + label: 'Tables', + state: 'requestable', + reason: null, + pendingRequestId: null, + }, + ], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ListMyOrganizationAccessRequestsContract, + { + applicationOperation: accessRequestOperations.listMine, + operationId: 'listMyOrganizationAccessRequests', + summary: 'List My Organization Access Requests', + description: `List the acting user’s organization-level requests and member credit-limit requests, including resolved history. For workspace-scoped requests, use List My Workspace Access Requests. History remains available while requests are disabled. Requires organization membership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'List My Organization Access Requests result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListMyOrganizationAccessRequestsContract.params, + 'ListMyOrganizationAccessRequestsParams', + 'List My Organization Access Requests parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2ListMyOrganizationAccessRequestsContract.query, + 'ListMyOrganizationAccessRequestsQuery', + 'List My Organization Access Requests query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2ListMyOrganizationAccessRequestsContract.response.schema, + 'ListMyOrganizationAccessRequestsResponse', + 'List My Organization Access Requests response', + 'List My Organization Access Requests result.', + [{ data: [{ ...requestExample, workspaceId: null }], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateOrganizationAccessRequestContract, + { + applicationOperation: accessRequestOperations.create, + operationId: 'createOrganizationAccessRequest', + summary: 'Create Organization Access Request', + description: `Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires organization membership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Create Organization Access Request result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateOrganizationAccessRequestContract.params, + 'CreateOrganizationAccessRequestParams', + 'Create Organization Access Request parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2CreateOrganizationAccessRequestContract.query, + 'CreateOrganizationAccessRequestQuery', + 'Create Organization Access Request query', + 'Query parameters for this operation.' + ), + body: documentedSchema( + v2CreateOrganizationAccessRequestContract.body, + 'CreateOrganizationAccessRequestBody', + 'Create Organization Access Request body', + 'Inputs for this operation.', + [{ target: requestExample.target, reason: 'Maintain team data' }] + ), + response: documentedSchema( + v2CreateOrganizationAccessRequestContract.response.schema, + 'CreateOrganizationAccessRequestResponse', + 'Create Organization Access Request response', + 'Create Organization Access Request result.', + [{ data: { ...requestExample, workspaceId: null } }] + ), + } + ), + defineOpenApiRoute( + v2CancelOrganizationAccessRequestContract, + { + applicationOperation: accessRequestOperations.cancel, + operationId: 'cancelOrganizationAccessRequest', + summary: 'Cancel Organization Access Request', + description: `Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires organization membership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Cancel Organization Access Request result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CancelOrganizationAccessRequestContract.params, + 'CancelOrganizationAccessRequestParams', + 'Cancel Organization Access Request parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2CancelOrganizationAccessRequestContract.query, + 'CancelOrganizationAccessRequestQuery', + 'Cancel Organization Access Request query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2CancelOrganizationAccessRequestContract.response.schema, + 'CancelOrganizationAccessRequestResponse', + 'Cancel Organization Access Request response', + 'Cancel Organization Access Request result.', + [ + { + data: { + ...requestExample, + workspaceId: null, + status: 'cancelled', + decidedAt: '2026-06-01T10:00:00.000Z', + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ListOrganizationAccessRequestsContract, + { + applicationOperation: accessRequestOperations.listOrganization, + operationId: 'listOrganizationAccessRequests', + summary: 'List Organization Access Requests', + description: `List requests across the organization for administrator review. Includes requests from organization members and external workspace collaborators; history remains available while requests are disabled. Requires organization administrator access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'List Organization Access Requests result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListOrganizationAccessRequestsContract.params, + 'ListOrganizationAccessRequestsParams', + 'List Organization Access Requests parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2ListOrganizationAccessRequestsContract.query, + 'ListOrganizationAccessRequestsQuery', + 'List Organization Access Requests query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2ListOrganizationAccessRequestsContract.response.schema, + 'ListOrganizationAccessRequestsResponse', + 'List Organization Access Requests response', + 'List Organization Access Requests result.', + [{ data: [requestExample], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2PreviewOrganizationAccessRequestContract, + { + applicationOperation: accessRequestOperations.preview, + operationId: 'previewOrganizationAccessRequest', + summary: 'Preview Organization Access Request', + description: `Preview the current permission changes, affected group and audience, or member credit cap. Review canApply, changes, impact, and fingerprint before resolving. Permission changes affect the entire governing group, not only the requester. Requires organization administrator access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Preview Organization Access Request result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2PreviewOrganizationAccessRequestContract.params, + 'PreviewOrganizationAccessRequestParams', + 'Preview Organization Access Request parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2PreviewOrganizationAccessRequestContract.query, + 'PreviewOrganizationAccessRequestQuery', + 'Preview Organization Access Request query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2PreviewOrganizationAccessRequestContract.response.schema, + 'PreviewOrganizationAccessRequestResponse', + 'Preview Organization Access Request response', + 'Preview Organization Access Request result.', + [ + { + data: { + resolutionKind: 'permission', + request: requestExample, + group: { id: 'group-123', name: 'Engineering' }, + changes: [ + { configKey: 'hideTablesTab', label: 'Tables', before: true, after: false }, + ], + impact: { + memberCount: 2, + workspaceCount: 1, + workspaceNames: ['Engineering'], + truncated: false, + }, + fingerprint: 'current-preview-fingerprint', + canApply: true, + unavailableReason: null, + currentLimitCredits: null, + newLimitCredits: null, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ResolveOrganizationAccessRequestContract, + { + applicationOperation: accessRequestOperations.resolve, + operationId: 'resolveOrganizationAccessRequest', + summary: 'Resolve Organization Access Request', + description: `Apply a reviewed request or decline it with a reason. Applying requires the preview fingerprint; changed policy or membership returns a conflict. Credit requests also require a higher newLimitCredits. Already resolved requests are returned unchanged. Requires organization administrator access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Resolve Organization Access Request result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ResolveOrganizationAccessRequestContract.params, + 'ResolveOrganizationAccessRequestParams', + 'Resolve Organization Access Request parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2ResolveOrganizationAccessRequestContract.query, + 'ResolveOrganizationAccessRequestQuery', + 'Resolve Organization Access Request query', + 'Query parameters for this operation.' + ), + body: documentedSchema( + v2ResolveOrganizationAccessRequestContract.body, + 'ResolveOrganizationAccessRequestBody', + 'Resolve Organization Access Request body', + 'Inputs for this operation.', + [{ action: 'apply', expectedFingerprint: 'current-preview-fingerprint' }] + ), + response: documentedSchema( + v2ResolveOrganizationAccessRequestContract.response.schema, + 'ResolveOrganizationAccessRequestResponse', + 'Resolve Organization Access Request response', + 'Resolve Organization Access Request result.', + [ + { + data: { ...requestExample, status: 'fulfilled', decidedAt: '2026-06-01T10:00:00.000Z' }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2GetOrganizationAccessRequestSettingsContract, + { + applicationOperation: accessRequestOperations.getSettings, + operationId: 'getOrganizationAccessRequestSettings', + summary: 'Get Organization Access Request Settings', + description: `Get whether the organization allows new access requests and approvals. This preference does not enable features unavailable in the deployment or subscription. Requires organization administrator access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Get Organization Access Request Settings result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2GetOrganizationAccessRequestSettingsContract.params, + 'GetOrganizationAccessRequestSettingsParams', + 'Get Organization Access Request Settings parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2GetOrganizationAccessRequestSettingsContract.query, + 'GetOrganizationAccessRequestSettingsQuery', + 'Get Organization Access Request Settings query', + 'Query parameters for this operation.' + ), + response: documentedSchema( + v2GetOrganizationAccessRequestSettingsContract.response.schema, + 'GetOrganizationAccessRequestSettingsResponse', + 'Get Organization Access Request Settings response', + 'Get Organization Access Request Settings result.', + [{ data: { allowRequests: true } }] + ), + } + ), + defineOpenApiRoute( + v2UpdateOrganizationAccessRequestSettingsContract, + { + applicationOperation: accessRequestOperations.updateSettings, + operationId: 'updateOrganizationAccessRequestSettings', + summary: 'Update Organization Access Request Settings', + description: `Allow or pause new access requests and approvals. Pausing preserves history, cancellation, and decline, and does not revoke previously granted access. Requires organization administrator access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Access Requests'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Update Organization Access Request Settings result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2UpdateOrganizationAccessRequestSettingsContract.params, + 'UpdateOrganizationAccessRequestSettingsParams', + 'Update Organization Access Request Settings parameters', + 'Scope and request identifiers.' + ), + query: documentedSchema( + v2UpdateOrganizationAccessRequestSettingsContract.query, + 'UpdateOrganizationAccessRequestSettingsQuery', + 'Update Organization Access Request Settings query', + 'Query parameters for this operation.' + ), + body: documentedSchema( + v2UpdateOrganizationAccessRequestSettingsContract.body, + 'UpdateOrganizationAccessRequestSettingsBody', + 'Update Organization Access Request Settings body', + 'Inputs for this operation.', + [{ allowRequests: false }] + ), + response: documentedSchema( + v2UpdateOrganizationAccessRequestSettingsContract.response.schema, + 'UpdateOrganizationAccessRequestSettingsResponse', + 'Update Organization Access Request Settings response', + 'Update Organization Access Request Settings result.', + [{ data: { allowRequests: false } }] + ), + } + ), +] diff --git a/apps/sim/lib/api/contracts/v2/openapi/organization-usage.ts b/apps/sim/lib/api/contracts/v2/openapi/organization-usage.ts new file mode 100644 index 00000000000..32bbd70a923 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/organization-usage.ts @@ -0,0 +1,246 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2GetOrganizationMemberUsageLimitContract, + v2GetOrganizationUsageBreakdownContract, + v2GetOrganizationUsageSummaryContract, + v2ListOrganizationUsageEventsContract, + v2UpdateOrganizationMemberUsageLimitContract, +} from '@/lib/api/contracts/v2/organization-usage' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { memberUsageLimitOperations } from '@/lib/billing/application/member-usage-limits/operations' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' + +export const organizationUsageOpenApiRoutes = [ + defineOpenApiRoute( + v2GetOrganizationMemberUsageLimitContract, + { + applicationOperation: memberUsageLimitOperations.read, + operationId: 'getOrganizationMemberUsageLimit', + summary: 'Get Organization Member Credit Limit', + description: `Read a person’s credit cap and credits consumed in the organization billing period. Hosted only. The userId identifies an organization member or external collaborator with workspace access in this organization; it is not a membership record ID. Null means no per-person cap, while organization limits still apply. Requires organization administrator access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { + description: 'Get Organization Member Credit Limit result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2GetOrganizationMemberUsageLimitContract.params, + 'GetOrganizationMemberUsageLimitParams', + 'Get Organization Member Credit Limit parameters', + 'Organization and target user identifiers, where applicable.' + ), + query: documentedSchema( + v2GetOrganizationMemberUsageLimitContract.query, + 'GetOrganizationMemberUsageLimitQuery', + 'Get Organization Member Credit Limit query', + 'Reporting window, filtering, and pagination controls, where applicable.' + ), + response: documentedSchema( + v2GetOrganizationMemberUsageLimitContract.response.schema, + 'GetOrganizationMemberUsageLimitResponse', + 'Get Organization Member Credit Limit response', + 'Get Organization Member Credit Limit result.', + [{ data: { creditsUsed: 200, creditLimit: 10000, billingInterval: 'month' } }] + ), + } + ), + defineOpenApiRoute( + v2UpdateOrganizationMemberUsageLimitContract, + { + applicationOperation: memberUsageLimitOperations.update, + operationId: 'updateOrganizationMemberUsageLimit', + summary: 'Update Organization Member Credit Limit', + description: `Set or clear a person’s credit cap. Hosted only. The userId must identify an organization member or external collaborator with workspace access in this organization. The cap is a nonnegative whole number of credits, not dollars: 0 prevents further credit-consuming usage; null removes the per-person cap. Organization limits continue to apply. Retrying the same value is safe. Requires organization administrator access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { + description: 'Update Organization Member Credit Limit result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2UpdateOrganizationMemberUsageLimitContract.params, + 'UpdateOrganizationMemberUsageLimitParams', + 'Update Organization Member Credit Limit parameters', + 'Organization and target user identifiers, where applicable.' + ), + query: documentedSchema( + v2UpdateOrganizationMemberUsageLimitContract.query, + 'UpdateOrganizationMemberUsageLimitQuery', + 'Update Organization Member Credit Limit query', + 'Reporting window, filtering, and pagination controls, where applicable.' + ), + body: documentedSchema( + v2UpdateOrganizationMemberUsageLimitContract.body, + 'UpdateOrganizationMemberUsageLimitBody', + 'Update Organization Member Credit Limit body', + 'Credit cap in whole credits; null clears the cap.', + [{ creditLimit: 10000 }, { creditLimit: null }] + ), + response: documentedSchema( + v2UpdateOrganizationMemberUsageLimitContract.response.schema, + 'UpdateOrganizationMemberUsageLimitResponse', + 'Update Organization Member Credit Limit response', + 'Update Organization Member Credit Limit result.', + [{ data: { creditLimit: 10000 } }] + ), + } + ), + defineOpenApiRoute( + v2GetOrganizationUsageSummaryContract, + { + applicationOperation: organizationUsageOperations.readSummary, + operationId: 'getOrganizationUsageSummary', + summary: 'Get Organization Usage Summary', + description: `Read pooled credits, a usage series, and an exact previous-period comparison when available. Requires organization administrator access and Usage Monitoring (Enterprise on hosted; enabled on self-hosted). Defaults to 30 days. Custom dates include both dates in the selected timezone and cannot exceed 92 days. Billing windows exceeding 366 days are rejected. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { + description: 'Get Organization Usage Summary result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2GetOrganizationUsageSummaryContract.params, + 'GetOrganizationUsageSummaryParams', + 'Get Organization Usage Summary parameters', + 'Organization and target user identifiers, where applicable.' + ), + query: documentedSchema( + v2GetOrganizationUsageSummaryContract.query, + 'GetOrganizationUsageSummaryQuery', + 'Get Organization Usage Summary query', + 'Reporting window, filtering, and pagination controls, where applicable.' + ), + response: documentedSchema( + v2GetOrganizationUsageSummaryContract.response.schema, + 'GetOrganizationUsageSummaryResponse', + 'Get Organization Usage Summary response', + 'Get Organization Usage Summary result.', + [ + { + data: { + window: { + start: '2026-06-01T00:00:00.000Z', + end: '2026-07-01T00:00:00.000Z', + source: 'range', + }, + bucket: 'day', + totals: { credits: 200 }, + previousTotals: null, + series: [{ timestamp: '2026-06-01T00:00:00.000Z', credits: 200, events: 1 }], + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2GetOrganizationUsageBreakdownContract, + { + applicationOperation: organizationUsageOperations.readBreakdown, + operationId: 'getOrganizationUsageBreakdown', + summary: 'Get Organization Usage Breakdown', + description: `Read ranked organization usage by member, workspace, workflow, model, BYOK provider, or source. Requires organization administrator access and Usage Monitoring. Omitted usage is summarized in other. BYOK ranks tokens; other dimensions rank cost. More than 10,000 underlying groups returns 413; narrow the window or workspace. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Get Organization Usage Breakdown result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2GetOrganizationUsageBreakdownContract.params, + 'GetOrganizationUsageBreakdownParams', + 'Get Organization Usage Breakdown parameters', + 'Organization and target user identifiers, where applicable.' + ), + query: documentedSchema( + v2GetOrganizationUsageBreakdownContract.query, + 'GetOrganizationUsageBreakdownQuery', + 'Get Organization Usage Breakdown query', + 'Reporting window, filtering, and pagination controls, where applicable.' + ), + response: documentedSchema( + v2GetOrganizationUsageBreakdownContract.response.schema, + 'GetOrganizationUsageBreakdownResponse', + 'Get Organization Usage Breakdown response', + 'Get Organization Usage Breakdown result.', + [ + { + data: { + dimension: 'member', + rows: [ + { id: 'user-123', label: 'Example Member', credits: 200, events: 1, share: 1 }, + ], + other: { credits: 0, events: 0, rowCount: 0, tokens: 0 }, + totalCredits: 200, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ListOrganizationUsageEventsContract, + { + applicationOperation: organizationUsageOperations.listEvents, + operationId: 'listOrganizationUsageEvents', + summary: 'List Organization Usage Events', + description: `Page through usage events, including zero-cost reporting. Requires organization administrator access and Usage Monitoring. Defaults to 30 days. Cursors retain the initial reporting window; keep filters and sort unchanged while paging. The sim-chat source covers both chat surfaces. Per-event rounding can produce credits=0 with hasCost=true. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { + description: 'List Organization Usage Events result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListOrganizationUsageEventsContract.params, + 'ListOrganizationUsageEventsParams', + 'List Organization Usage Events parameters', + 'Organization and target user identifiers, where applicable.' + ), + query: documentedSchema( + v2ListOrganizationUsageEventsContract.query, + 'ListOrganizationUsageEventsQuery', + 'List Organization Usage Events query', + 'Reporting window, filtering, and pagination controls, where applicable.' + ), + response: documentedSchema( + v2ListOrganizationUsageEventsContract.response.schema, + 'ListOrganizationUsageEventsResponse', + 'List Organization Usage Events response', + 'List Organization Usage Events result.', + [ + { + data: [ + { + id: 'event-123', + createdAt: '2026-06-01T09:00:00.000Z', + source: 'sim-chat', + description: 'Model usage', + workflowName: null, + credits: 200, + hasCost: true, + }, + ], + nextCursor: null, + }, + ] + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/organizations.ts b/apps/sim/lib/api/contracts/v2/openapi/organizations.ts index 56e15e7b9a0..4c34e9a5896 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/organizations.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/organizations.ts @@ -10,6 +10,7 @@ import { v2GetOrganizationContract, v2GetOrganizationInvitationContract, v2ListOrganizationInvitationsContract, + v2ListOrganizationInvitationWorkspacesContract, v2ListOrganizationMembersContract, v2ListOrganizationsContract, v2ListOrganizationWorkspacesContract, @@ -368,7 +369,7 @@ export const organizationOpenApiRoutes = [ 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}`, + description: `Get an invitation owned by the organization. Requires organization administrator access. Use List Organization Invitation Workspaces to inspect its workspace grants. 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 }, @@ -404,6 +405,49 @@ export const organizationOpenApiRoutes = [ ), } ), + defineOpenApiRoute( + v2ListOrganizationInvitationWorkspacesContract, + { + applicationOperation: organizationOperations.listInvitationWorkspaces, + operationId: 'listOrganizationInvitationWorkspaces', + summary: 'List Organization Invitation Workspaces', + description: `List workspace grants attached to an invitation of any status. Includes archived workspaces still owned by the organization; workspaces moved to another organization are omitted. Requires organization administrator access. These grants describe the invitation, not the invitee's current access. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_ERRORS, + success: { + description: 'A page of workspace grants attached to the invitation.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListOrganizationInvitationWorkspacesContract.params, + 'ListOrganizationInvitationWorkspacesParams', + 'List Organization Invitation Workspaces parameters', + 'Organization and invitation identifiers.' + ), + query: documentedSchema( + v2ListOrganizationInvitationWorkspacesContract.query, + 'ListOrganizationInvitationWorkspacesQuery', + 'List Organization Invitation Workspaces query', + 'Filtering, sorting, and pagination controls.' + ), + response: documentedSchema( + v2ListOrganizationInvitationWorkspacesContract.response.schema, + 'ListOrganizationInvitationWorkspacesResponse', + 'List Organization Invitation Workspaces response', + 'Workspace grants retained on an invitation.', + [ + { + data: [ + { id: 'workspace-123', name: 'Engineering', permission: 'write', archivedAt: null }, + ], + nextCursor: null, + }, + ] + ), + } + ), defineOpenApiRoute( v2ResendOrganizationInvitationContract, { diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 16e6eb96e1e..3ea9edf3e40 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 { accessRequestOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/access-requests' +import { organizationUsageOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/organization-usage' import { organizationOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/organizations' import { permissionGroupOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/permission-groups' import { @@ -49,6 +51,8 @@ import { withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' +import { workspaceInvitationOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/workspace-invitations' +import { workspacePermissionOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/workspace-permissions' import { v2CreateSandboxContract, v2DeleteSandboxContract, @@ -297,6 +301,7 @@ const WORKSPACE_EXAMPLE = { } as const const WORKSPACE_MEMBER_EXAMPLE = { + userId: 'user-123', email: 'jane@example.com', name: 'Jane Smith', image: null, @@ -609,7 +614,7 @@ const declaredRoutes = [ operationId: 'listWorkspaceMembers', summary: 'List Workspace Members', description: - 'List workspace members by email, including explicit grants and inherited organization admin access.', + 'List workspace members by email, including explicit grants and inherited organization admin access. Each member includes a stable user ID for member administration.', errors: RESOURCE_ERRORS, success: { description: 'An email-ordered page of effective workspace members.' }, }), @@ -2151,6 +2156,10 @@ const declaredRoutes = [ ), ...permissionGroupOpenApiRoutes, ...organizationOpenApiRoutes, + ...workspacePermissionOpenApiRoutes, + ...workspaceInvitationOpenApiRoutes, + ...organizationUsageOpenApiRoutes, + ...accessRequestOpenApiRoutes, ] as const const routes = declaredRoutes.map(withRequestBodyErrors) @@ -2174,6 +2183,11 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, servers: [{ url: 'https://www.sim.ai', description: 'Production' }], tags: [ + { + name: 'Access Requests', + description: + 'Request access and review changes to organization permissions and member credit limits.', + }, { name: 'Organizations', description: 'Discover organizations and manage their members and invitations.', diff --git a/apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts b/apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts new file mode 100644 index 00000000000..d1bef02d0fc --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts @@ -0,0 +1,68 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { v2CreateWorkspaceInvitationsContract } from '@/lib/api/contracts/v2/workspace-invitations' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { invitationOperations } from '@/lib/invitations/application/operations' + +export const workspaceInvitationOpenApiRoutes = [ + defineOpenApiRoute( + v2CreateWorkspaceInvitationsContract, + { + applicationOperation: invitationOperations.sendBatch, + operationId: 'createWorkspaceInvitations', + summary: 'Create Workspace Invitations', + description: `Invite people to a workspace or grant access immediately to existing organization members. Requires workspace administrator access and current invitation eligibility; organization administrator invitations also require organization administrator access. Recipients are processed independently: inspect failed even after HTTP 200, and inspect invitation status before retrying a delivery failure. Existing access is preserved. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Workspaces'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Per-recipient invitation and direct-grant outcomes.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateWorkspaceInvitationsContract.params, + 'CreateWorkspaceInvitationsParams', + 'Create Workspace Invitations parameters', + 'Target workspace.' + ), + query: v2CreateWorkspaceInvitationsContract.query, + body: documentedSchema( + v2CreateWorkspaceInvitationsContract.body, + 'CreateWorkspaceInvitationsBody', + 'Create Workspace Invitations body', + 'Recipients and the access to grant.', + [{ emails: ['member@example.com'], permission: 'write', membership: 'member' }] + ), + response: documentedSchema( + v2CreateWorkspaceInvitationsContract.response.schema, + 'CreateWorkspaceInvitationsResponse', + 'Create Workspace Invitations response', + 'Successful recipients remain committed when later recipients fail.', + [ + { + data: { + success: true, + successful: ['member@example.com'], + added: [], + failed: [], + invitations: [ + { + id: 'invitation-123', + email: 'member@example.com', + workspaceIds: ['workspace-123'], + permission: 'write', + membershipIntent: 'internal', + }, + ], + }, + }, + ] + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/workspace-permissions.ts b/apps/sim/lib/api/contracts/v2/openapi/workspace-permissions.ts new file mode 100644 index 00000000000..404b2892aed --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/workspace-permissions.ts @@ -0,0 +1,54 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { v2GetWorkspacePermissionConfigContract } from '@/lib/api/contracts/v2/workspace-permissions' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { permissionGroupWorkspaceOperations } from '@/lib/permission-groups/application/operations' + +export const workspacePermissionOpenApiRoutes = [ + defineOpenApiRoute( + v2GetWorkspacePermissionConfigContract, + { + applicationOperation: permissionGroupWorkspaceOperations.readUserConfig, + operationId: 'getWorkspacePermissionConfig', + summary: 'Get Workspace Permission Config', + description: `Get the acting user's governing permission group and configuration for a workspace they can access. This describes permission-group restrictions, not the user's workspace role. Group and config are null when no group governs the caller; entitled indicates whether organization permission governance is active. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Workspaces'], + errors: RESOURCE_ERRORS, + success: { + description: 'The caller’s effective permission-group configuration.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2GetWorkspacePermissionConfigContract.params, + 'GetWorkspacePermissionConfigParams', + 'Get Workspace Permission Config parameters', + 'Target workspace.' + ), + query: v2GetWorkspacePermissionConfigContract.query, + response: documentedSchema( + v2GetWorkspacePermissionConfigContract.response.schema, + 'GetWorkspacePermissionConfigResponse', + 'Get Workspace Permission Config response', + 'Configuration for the acting caller only.', + [ + { + data: { + permissionGroupId: null, + groupName: null, + config: null, + entitled: false, + organizationId: null, + isOrgAdmin: false, + }, + }, + ] + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/organization-usage.ts b/apps/sim/lib/api/contracts/v2/organization-usage.ts new file mode 100644 index 00000000000..2b26d3fad4a --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/organization-usage.ts @@ -0,0 +1,248 @@ +import { z } from 'zod' +import { + organizationMemberUsageLimitDataSchema, + updateOrganizationMemberUsageLimitBodySchema, +} from '@/lib/api/contracts/organization' +import { + MAX_CUSTOM_RANGE_DAYS, + organizationUsageBreakdownQuerySchema, + organizationUsageBreakdownResponseSchema, + organizationUsageSummaryQuerySchema, + organizationUsageSummaryResponseSchema, +} from '@/lib/api/contracts/organization-usage' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { usageLogSourceSchema } from '@/lib/api/contracts/user' +import { + v2OrganizationMemberParamsSchema, + v2OrganizationParamsSchema, +} from '@/lib/api/contracts/v2/organizations' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SortFields, + v2TimestampSchema, +} from '@/lib/api/contracts/v2/shared' + +export const v2OrganizationMemberUsageLimitSchema = organizationMemberUsageLimitDataSchema + .extend({ + billingInterval: organizationMemberUsageLimitDataSchema.shape.billingInterval.describe( + 'Organization billing interval used for the credit cap.' + ), + creditsUsed: z + .number() + .describe('Credits used by this person during the organization billing period.'), + creditLimit: z + .number() + .nullable() + .describe( + 'Per-person credit cap. Null means no per-person cap; organization limits still apply. Zero prevents further credit-consuming usage.' + ), + }) + .meta({ id: 'V2OrganizationMemberUsageLimit' }) +export type V2OrganizationMemberUsageLimit = z.output + +export const v2UpdateOrganizationMemberUsageLimitBodySchema = + updateOrganizationMemberUsageLimitBodySchema + .extend({ + creditLimit: updateOrganizationMemberUsageLimitBodySchema.shape.creditLimit.describe( + 'Credit cap for this person. Send null to clear the cap or 0 to prevent further credit-consuming usage. Organization limits still apply.' + ), + }) + .strict() +export type V2UpdateOrganizationMemberUsageLimitBody = z.input< + typeof v2UpdateOrganizationMemberUsageLimitBodySchema +> + +export const v2OrganizationMemberUsageLimitParamsSchema = v2OrganizationMemberParamsSchema.extend({ + userId: v2OrganizationMemberParamsSchema.shape.userId.describe( + 'User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.' + ), +}) + +export const v2GetOrganizationMemberUsageLimitContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/members/[userId]/usage-limit', + params: v2OrganizationMemberUsageLimitParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2OrganizationMemberUsageLimitSchema) }, +}) + +export const v2UpdateOrganizationMemberUsageLimitContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/members/[userId]/usage-limit', + params: v2OrganizationMemberUsageLimitParamsSchema, + query: noInputSchema, + body: v2UpdateOrganizationMemberUsageLimitBodySchema, + response: { + mode: 'json', + schema: v2DataResponse( + v2UpdateOrganizationMemberUsageLimitBodySchema.meta({ + id: 'V2OrganizationMemberUsageLimitUpdate', + }) + ), + }, +}) + +const windowFields = { + preset: organizationUsageSummaryQuerySchema.shape.preset + .default('30d') + .describe( + 'Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.' + ), + startDate: z.iso + .date() + .refine((value) => !value.startsWith('0000'), 'startDate must name a valid calendar year') + .optional() + .describe('First calendar date included, in the selected timezone. Requires preset=custom.'), + endDate: z.iso + .date() + .refine((value) => !value.startsWith('0000'), 'endDate must name a valid calendar year') + .refine( + (value) => value < '9999-12-31', + 'endDate must be before 9999-12-31 to include the full final day' + ) + .optional() + .describe('Last calendar date included, in the selected timezone. Requires preset=custom.'), +} + +function validateWindow( + query: { preset: string; startDate?: string; endDate?: string }, + context: z.RefinementCtx +) { + if (query.preset !== 'custom') { + for (const field of ['startDate', 'endDate'] as const) { + if (query[field] !== undefined) + context.addIssue({ + code: 'custom', + path: [field], + message: `${field} is only accepted with preset=custom`, + }) + } + return + } + for (const field of ['startDate', 'endDate'] as const) { + if (query[field] === undefined) + context.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required with preset=custom`, + }) + } + if (!query.startDate || !query.endDate) return + const start = Date.parse(query.startDate) + const end = Date.parse(query.endDate) + if (!Number.isFinite(start) || !Number.isFinite(end)) return + if (end < start) + context.addIssue({ + code: 'custom', + path: ['endDate'], + message: 'endDate must be on or after startDate', + }) + if ((end - start) / 86_400_000 + 1 > MAX_CUSTOM_RANGE_DAYS) + context.addIssue({ + code: 'custom', + path: ['endDate'], + message: `Custom usage windows cannot exceed ${MAX_CUSTOM_RANGE_DAYS} days`, + }) +} + +export const v2OrganizationUsageSummaryQuerySchema = organizationUsageSummaryQuerySchema + .extend(windowFields) + .strict() + .superRefine(validateWindow) +export type V2OrganizationUsageSummaryQuery = z.input + +export const v2OrganizationUsageBreakdownQuerySchema = organizationUsageBreakdownQuerySchema + .extend({ + ...windowFields, + limit: v2PaginationFields({ + description: 'Maximum ranked groups to return. Remaining usage is summarized in other.', + }).limit, + }) + .strict() + .superRefine(validateWindow) +export type V2OrganizationUsageBreakdownQuery = z.input< + typeof v2OrganizationUsageBreakdownQuerySchema +> + +export const v2OrganizationUsageEventsQuerySchema = organizationUsageSummaryQuerySchema + .omit({ workspaceId: true }) + .extend({ + ...windowFields, + source: usageLogSourceSchema.optional().describe('Restrict events to one product surface.'), + ...v2PaginationFields({ description: 'Maximum usage events per page.' }), + ...v2SortFields(['createdAt'] as const, { sortBy: 'createdAt', sortOrder: 'desc' }), + }) + .strict() + .superRefine(validateWindow) +export type V2OrganizationUsageEventsQuery = z.input + +export const v2OrganizationUsageEventSchema = z + .object({ + id: z.string().describe('Usage event identifier.'), + createdAt: v2TimestampSchema.describe('When the usage event was recorded.'), + source: usageLogSourceSchema.describe('Product surface that recorded the usage.'), + description: z.string().describe('Usage event description, such as a model name.'), + workflowName: z + .string() + .nullable() + .describe('Workflow name, or null for non-workflow usage or a deleted workflow.'), + credits: z.number().describe('Credits consumed by this event, rounded to whole credits.'), + hasCost: z + .boolean() + .describe('Whether the event consumed credits before rounding, including sub-credit usage.'), + }) + .meta({ id: 'V2OrganizationUsageEvent' }) +export type V2OrganizationUsageEvent = z.output + +export const v2GetOrganizationUsageSummaryContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/usage/summary', + params: v2OrganizationParamsSchema, + query: v2OrganizationUsageSummaryQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse( + organizationUsageSummaryResponseSchema + .extend({ + window: organizationUsageSummaryResponseSchema.shape.window + .extend({ + start: v2TimestampSchema.describe('Inclusive reporting-window start.'), + end: v2TimestampSchema.describe('Exclusive reporting-window end.'), + }) + .describe('Resolved reporting window.'), + series: z + .array( + organizationUsageSummaryResponseSchema.shape.series.element.extend({ + timestamp: v2TimestampSchema.describe('Start of this calendar bucket.'), + }) + ) + .describe('Chronological usage buckets, including buckets with no usage.'), + }) + .meta({ id: 'V2OrganizationUsageSummary' }) + ), + }, +}) + +export const v2GetOrganizationUsageBreakdownContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/usage/breakdown', + params: v2OrganizationParamsSchema, + query: v2OrganizationUsageBreakdownQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse( + organizationUsageBreakdownResponseSchema.meta({ id: 'V2OrganizationUsageBreakdown' }) + ), + }, +}) + +export const v2ListOrganizationUsageEventsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/usage/events', + params: v2OrganizationParamsSchema, + query: v2OrganizationUsageEventsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2OrganizationUsageEventSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/organizations.ts b/apps/sim/lib/api/contracts/v2/organizations.ts index 05cbe292d40..160dd9d30f7 100644 --- a/apps/sim/lib/api/contracts/v2/organizations.ts +++ b/apps/sim/lib/api/contracts/v2/organizations.ts @@ -14,6 +14,7 @@ import { v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces' export const v2OrganizationParamsSchema = z .object({ @@ -77,6 +78,25 @@ export const v2OrganizationWorkspaceSchema = z }) export type V2OrganizationWorkspace = z.output +export const v2OrganizationInvitationWorkspaceSchema = v2OrganizationWorkspaceSchema + .extend({ + permission: workspacePermissionSchema.describe( + 'Workspace permission offered by the invitation.' + ), + archivedAt: v2TimestampSchema + .nullable() + .describe('When the workspace was archived, or null while active.'), + }) + .meta({ + id: 'V2OrganizationInvitationWorkspace', + title: 'Organization invitation workspace', + description: + 'A workspace grant attached to an invitation, separate from organization membership.', + }) +export type V2OrganizationInvitationWorkspace = z.output< + typeof v2OrganizationInvitationWorkspaceSchema +> + export const v2OrganizationInvitationSchema = z .object({ id: z.string().describe('Invitation identifier.'), @@ -132,6 +152,11 @@ export const v2ListOrganizationWorkspacesQuerySchema = z export type V2ListOrganizationWorkspacesQuery = z.output< typeof v2ListOrganizationWorkspacesQuerySchema > +export const v2ListOrganizationInvitationWorkspacesQuerySchema = + v2ListOrganizationWorkspacesQuerySchema +export type V2ListOrganizationInvitationWorkspacesQuery = z.output< + typeof v2ListOrganizationInvitationWorkspacesQuerySchema +> export const v2ListOrganizationInvitationsQuerySchema = z .object({ search: v2SearchSchema.describe('Case-insensitive substring match against the invitee email.'), @@ -255,6 +280,13 @@ export const v2GetOrganizationInvitationContract = defineRouteContract({ query: noInputSchema, response: { mode: 'json', schema: v2DataResponse(v2OrganizationInvitationSchema) }, }) +export const v2ListOrganizationInvitationWorkspacesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces', + params: v2OrganizationInvitationParamsSchema, + query: v2ListOrganizationInvitationWorkspacesQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2OrganizationInvitationWorkspaceSchema) }, +}) export const v2RevokeOrganizationInvitationContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]', diff --git a/apps/sim/lib/api/contracts/v2/workspace-invitations.ts b/apps/sim/lib/api/contracts/v2/workspace-invitations.ts new file mode 100644 index 00000000000..ece23fcb43b --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspace-invitations.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { + batchInvitationResultSchema, + invitationMembershipSchema, +} from '@/lib/api/contracts/invitations' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces' + +export const v2CreateWorkspaceInvitationsParamsSchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export const v2CreateWorkspaceInvitationsBodySchema = z + .object({ + emails: z + .array( + z.string().trim().email('email must be a valid email address').max(320, 'email is too long') + ) + .min(1, 'emails must contain at least one address') + .max(50, 'emails cannot contain more than 50 addresses') + .describe( + 'Email addresses to invite. Each address is processed separately; inspect failed for unsuccessful recipients.' + ), + permission: workspacePermissionSchema + .default('read') + .describe('Workspace permission to grant. Existing workspace access is preserved.'), + membership: invitationMembershipSchema + .default('member') + .describe( + 'Organization membership: member or admin uses a seat when billing is enabled. External grants workspace access only and requires an eligible paid account when billing is enabled. Existing members of another organization remain external.' + ), + }) + .strict() + +export const v2CreateWorkspaceInvitationsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/invitations', + params: v2CreateWorkspaceInvitationsParamsSchema, + query: noInputSchema, + body: v2CreateWorkspaceInvitationsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(batchInvitationResultSchema.meta({ id: 'V2WorkspaceInvitationBatch' })), + }, +}) + +export type V2CreateWorkspaceInvitationsParams = z.input< + typeof v2CreateWorkspaceInvitationsParamsSchema +> +export type V2CreateWorkspaceInvitationsBody = z.input< + typeof v2CreateWorkspaceInvitationsBodySchema +> +export type V2CreateWorkspaceInvitationsResponse = z.output< + typeof v2CreateWorkspaceInvitationsContract.response.schema +> diff --git a/apps/sim/lib/api/contracts/v2/workspace-permissions.ts b/apps/sim/lib/api/contracts/v2/workspace-permissions.ts new file mode 100644 index 00000000000..7462a2f35a7 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspace-permissions.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { userPermissionConfigSchema } from '@/lib/api/contracts/permission-groups' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2GetWorkspacePermissionConfigParamsSchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export const v2GetWorkspacePermissionConfigContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/permission-config', + params: v2GetWorkspacePermissionConfigParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse(userPermissionConfigSchema.meta({ id: 'V2WorkspacePermissionConfig' })), + }, +}) + +export type V2GetWorkspacePermissionConfigParams = z.input< + typeof v2GetWorkspacePermissionConfigParamsSchema +> +export type V2GetWorkspacePermissionConfigResponse = z.output< + typeof v2GetWorkspacePermissionConfigContract.response.schema +> diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index 355d4e02945..dc7f976e25d 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -51,7 +51,8 @@ export type V2ListWorkspacesQuery = z.output export const v2WorkspaceMemberSchema = z .object({ - email: z.email().describe('Member email address and public member identifier.'), + userId: z.string().describe('User identifier; use this identifier for member administration.'), + email: z.email().describe('Member email address.'), name: z.string().describe('Member display name.'), image: z.string().nullable().describe('Member profile image URL, or null when absent.'), role: z.enum(['admin', 'write', 'read']).describe('Effective role in the workspace.'), diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index fef2ce65fb1..67ceefd5074 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -6,6 +6,21 @@ * `bun run generate:mcp-operations`; CI fails when this file is stale. */ +import { + v2CancelOrganizationAccessRequestContract, + v2CancelWorkspaceAccessRequestContract, + v2CreateOrganizationAccessRequestContract, + v2CreateWorkspaceAccessRequestContract, + v2DiscoverOrganizationAccessRequestsContract, + v2DiscoverWorkspaceAccessRequestsContract, + v2GetOrganizationAccessRequestSettingsContract, + v2ListMyOrganizationAccessRequestsContract, + v2ListMyWorkspaceAccessRequestsContract, + v2ListOrganizationAccessRequestsContract, + v2PreviewOrganizationAccessRequestContract, + v2ResolveOrganizationAccessRequestContract, + v2UpdateOrganizationAccessRequestSettingsContract, +} from '@/lib/api/contracts/v2/access-requests' import { v2GetAuditLogContract, v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' import { v2GetBillingStatusContract, @@ -135,11 +150,19 @@ import { v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { + v2GetOrganizationMemberUsageLimitContract, + v2GetOrganizationUsageBreakdownContract, + v2GetOrganizationUsageSummaryContract, + v2ListOrganizationUsageEventsContract, + v2UpdateOrganizationMemberUsageLimitContract, +} from '@/lib/api/contracts/v2/organization-usage' import { v2CreateOrganizationInvitationContract, v2GetOrganizationContract, v2GetOrganizationInvitationContract, v2ListOrganizationInvitationsContract, + v2ListOrganizationInvitationWorkspacesContract, v2ListOrganizationMembersContract, v2ListOrganizationsContract, v2ListOrganizationWorkspacesContract, @@ -300,10 +323,12 @@ import { v2UpdateWorkspaceForkExclusionsContract, v2UpdateWorkspaceForkMappingsContract, } from '@/lib/api/contracts/v2/workspace-fork' +import { v2CreateWorkspaceInvitationsContract } from '@/lib/api/contracts/v2/workspace-invitations' import { v2GetWorkspaceOperationContract, v2ListWorkspaceOperationsContract, } from '@/lib/api/contracts/v2/workspace-operations' +import { v2GetWorkspacePermissionConfigContract } from '@/lib/api/contracts/v2/workspace-permissions' import { v2GetWorkspaceContract, v2ListWorkspaceMembersContract, @@ -459,6 +484,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/tables/[tableId]/rows/bulk-update/route').then((route) => route.POST), }, + cancelOrganizationAccessRequest: { + contract: v2CancelOrganizationAccessRequestContract, + summary: 'Cancel Organization Access Request', + description: + 'Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires organization membership. 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]/access-requests/[requestId]/cancel/route' + ).then((route) => route.POST), + }, cancelTableDispatch: { contract: v2CancelTableDispatchContract, summary: 'Cancel Run Dispatch', @@ -504,6 +540,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + cancelWorkspaceAccessRequest: { + contract: v2CancelWorkspaceAccessRequestContract, + summary: 'Cancel Workspace Access Request', + description: + 'Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. 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/workspaces/[workspaceId]/access-requests/[requestId]/cancel/route').then( + (route) => route.POST + ), + }, chat: { contract: v2ChatContract, handler: () => import('@/app/api/v2/chat/route').then((route) => route.POST), @@ -649,6 +696,17 @@ export const V2_MCP_OPERATIONS = { 'Register an external MCP server without connecting to it. A duplicate URL returns `409`; use Update MCP Server to change the existing registration. The server remains disconnected until List MCP Server Tools succeeds.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/mcp-servers/route').then((route) => route.POST), }, + createOrganizationAccessRequest: { + contract: v2CreateOrganizationAccessRequestContract, + summary: 'Create Organization Access Request', + description: + 'Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires organization membership. 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]/access-requests/route').then( + (route) => route.POST + ), + }, createOrganizationInvitation: { contract: v2CreateOrganizationInvitationContract, summary: 'Create Organization Invitation', @@ -776,6 +834,26 @@ export const V2_MCP_OPERATIONS = { workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/workflow-mcp-servers/route').then((route) => route.POST), }, + createWorkspaceAccessRequest: { + contract: v2CreateWorkspaceAccessRequestContract, + summary: 'Create Workspace Access Request', + description: + 'Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires access to the workspace; external collaborators use their workspace grant. 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/workspaces/[workspaceId]/access-requests/route').then( + (route) => route.POST + ), + }, + createWorkspaceInvitations: { + contract: v2CreateWorkspaceInvitationsContract, + summary: 'Create Workspace Invitations', + description: + 'Invite people to a workspace or grant access immediately to existing organization members. Requires workspace administrator access and current invitation eligibility; organization administrator invitations also require organization administrator access. Recipients are processed independently: inspect failed even after HTTP 200, and inspect invitation status before retrying a delivery failure. Existing access is preserved. 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/workspaces/[workspaceId]/invitations/route').then((route) => route.POST), + }, deleteCredential: { contract: v2DeleteCredentialContract, summary: 'Disconnect Credential', @@ -1032,6 +1110,28 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + discoverOrganizationAccessRequests: { + contract: v2DiscoverOrganizationAccessRequestsContract, + summary: 'Discover Organization Access Requests', + description: + 'Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. 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]/access-requests/discovery/route').then( + (route) => route.GET + ), + }, + discoverWorkspaceAccessRequests: { + contract: v2DiscoverWorkspaceAccessRequestsContract, + summary: 'Discover Workspace Access Requests', + description: + 'Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. 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/workspaces/[workspaceId]/access-requests/discovery/route').then( + (route) => route.GET + ), + }, duplicateWorkflow: { contract: v2DuplicateWorkflowContract, summary: 'Duplicate Workflow', @@ -1228,17 +1328,61 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/organizations/[organizationId]/route').then((route) => route.GET), }, + getOrganizationAccessRequestSettings: { + contract: v2GetOrganizationAccessRequestSettingsContract, + summary: 'Get Organization Access Request Settings', + description: + 'Get whether the organization allows new access requests and approvals. This preference does not enable features unavailable in the deployment or subscription. Requires organization administrator 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]/access-requests/settings/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`.', + 'Get an invitation owned by the organization. Requires organization administrator access. Use List Organization Invitation Workspaces to inspect its workspace grants. 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 ), }, + getOrganizationMemberUsageLimit: { + contract: v2GetOrganizationMemberUsageLimitContract, + summary: 'Get Organization Member Credit Limit', + description: + 'Read a person’s credit cap and credits consumed in the organization billing period. Hosted only. The userId identifies an organization member or external collaborator with workspace access in this organization; it is not a membership record ID. Null means no per-person cap, while organization limits still apply. Requires organization administrator 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/[userId]/usage-limit/route').then( + (route) => route.GET + ), + }, + getOrganizationUsageBreakdown: { + contract: v2GetOrganizationUsageBreakdownContract, + summary: 'Get Organization Usage Breakdown', + description: + 'Read ranked organization usage by member, workspace, workflow, model, BYOK provider, or source. Requires organization administrator access and Usage Monitoring. Omitted usage is summarized in other. BYOK ranks tokens; other dimensions rank cost. More than 10,000 underlying groups returns 413; narrow the window or workspace. 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]/usage/breakdown/route').then( + (route) => route.GET + ), + }, + getOrganizationUsageSummary: { + contract: v2GetOrganizationUsageSummaryContract, + summary: 'Get Organization Usage Summary', + description: + 'Read pooled credits, a usage series, and an exact previous-period comparison when available. Requires organization administrator access and Usage Monitoring (Enterprise on hosted; enabled on self-hosted). Defaults to 30 days. Custom dates include both dates in the selected timezone and cannot exceed 92 days. Billing windows exceeding 366 days are rejected. 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]/usage/summary/route').then( + (route) => route.GET + ), + }, getPermissionGroup: { contract: v2GetPermissionGroupContract, summary: 'Get Permission Group', @@ -1444,6 +1588,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + getWorkspacePermissionConfig: { + contract: v2GetWorkspacePermissionConfigContract, + summary: 'Get Workspace Permission Config', + description: + "Get the acting user's governing permission group and configuration for a workspace they can access. This describes permission-group restrictions, not the user's workspace role. Group and config are null when no group governs the caller; entitled indicates whether organization permission governance is active. 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/workspaces/[workspaceId]/permission-config/route').then( + (route) => route.GET + ), + }, grantSkillEditor: { contract: v2GrantSkillEditorContract, summary: 'Grant Skill Editor', @@ -1635,6 +1790,39 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/mcp-servers/[mcpServerId]/tools/route').then((route) => route.GET), }, + listMyOrganizationAccessRequests: { + contract: v2ListMyOrganizationAccessRequestsContract, + summary: 'List My Organization Access Requests', + description: + 'List the acting user’s organization-level requests and member credit-limit requests, including resolved history. For workspace-scoped requests, use List My Workspace Access Requests. History remains available while requests are disabled. 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]/access-requests/mine/route').then( + (route) => route.GET + ), + }, + listMyWorkspaceAccessRequests: { + contract: v2ListMyWorkspaceAccessRequestsContract, + summary: 'List My Workspace Access Requests', + description: + 'List only the acting user’s requests in this workspace, including resolved history and organization-wide member credit-limit requests. History remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. 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/workspaces/[workspaceId]/access-requests/route').then( + (route) => route.GET + ), + }, + listOrganizationAccessRequests: { + contract: v2ListOrganizationAccessRequestsContract, + summary: 'List Organization Access Requests', + description: + 'List requests across the organization for administrator review. Includes requests from organization members and external workspace collaborators; history remains available while requests are disabled. Requires organization administrator 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]/access-requests/route').then( + (route) => route.GET + ), + }, listOrganizationInvitations: { contract: v2ListOrganizationInvitationsContract, summary: 'List Organization Invitations', @@ -1646,6 +1834,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + listOrganizationInvitationWorkspaces: { + contract: v2ListOrganizationInvitationWorkspacesContract, + summary: 'List Organization Invitation Workspaces', + description: + "List workspace grants attached to an invitation of any status. Includes archived workspaces still owned by the organization; workspaces moved to another organization are omitted. Requires organization administrator access. These grants describe the invitation, not the invitee's current 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]/invitations/[invitationId]/workspaces/route' + ).then((route) => route.GET), + }, listOrganizationMembers: { contract: v2ListOrganizationMembersContract, summary: 'List Organization Members', @@ -1665,6 +1864,17 @@ export const V2_MCP_OPERATIONS = { workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/organizations/route').then((route) => route.GET), }, + listOrganizationUsageEvents: { + contract: v2ListOrganizationUsageEventsContract, + summary: 'List Organization Usage Events', + description: + 'Page through usage events, including zero-cost reporting. Requires organization administrator access and Usage Monitoring. Defaults to 30 days. Cursors retain the initial reporting window; keep filters and sort unchanged while paging. The sim-chat source covers both chat surfaces. Per-event rounding can produce credits=0 with hasCost=true. 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]/usage/events/route').then( + (route) => route.GET + ), + }, listOrganizationWorkspaces: { contract: v2ListOrganizationWorkspacesContract, summary: 'List Organization Workspaces', @@ -1858,7 +2068,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ListWorkspaceMembersContract, summary: 'List Workspace Members', description: - 'List workspace members by email, including explicit grants and inherited organization admin access.\n\nOAuth scope: `api:read`.', + 'List workspace members by email, including explicit grants and inherited organization admin access. Each member includes a stable user ID for member administration.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/workspaces/[workspaceId]/members/route').then((route) => route.GET), }, @@ -1898,6 +2108,17 @@ export const V2_MCP_OPERATIONS = { 'Move up to 100 workflows into one folder. Moves succeed or fail independently; missing, archived, or locked workflows appear in `failed`. Duplicate IDs are ignored. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/move/route').then((route) => route.POST), }, + previewOrganizationAccessRequest: { + contract: v2PreviewOrganizationAccessRequestContract, + summary: 'Preview Organization Access Request', + description: + 'Preview the current permission changes, affected group and audience, or member credit cap. Review canApply, changes, impact, and fingerprint before resolving. Permission changes affect the entire governing group, not only the requester. Requires organization administrator 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]/access-requests/[requestId]/preview/route' + ).then((route) => route.GET), + }, previewWorkflowImport: { contract: v2PreviewWorkflowImportContract, summary: 'Preview Workflow Import', @@ -2077,6 +2298,17 @@ export const V2_MCP_OPERATIONS = { '@/app/api/v2/organizations/[organizationId]/invitations/[invitationId]/resend/route' ).then((route) => route.POST), }, + resolveOrganizationAccessRequest: { + contract: v2ResolveOrganizationAccessRequestContract, + summary: 'Resolve Organization Access Request', + description: + 'Apply a reviewed request or decline it with a reason. Applying requires the preview fingerprint; changed policy or membership returns a conflict. Credit requests also require a higher newLimitCredits. Already resolved requests are returned unchanged. Requires organization administrator access. 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]/access-requests/[requestId]/resolve/route' + ).then((route) => route.POST), + }, restoreFile: { contract: v2RestoreFileContract, summary: 'Restore File', @@ -2385,6 +2617,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.PATCH), }, + updateOrganizationAccessRequestSettings: { + contract: v2UpdateOrganizationAccessRequestSettingsContract, + summary: 'Update Organization Access Request Settings', + description: + 'Allow or pause new access requests and approvals. Pausing preserves history, cancellation, and decline, and does not revoke previously granted access. Requires organization administrator access. 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]/access-requests/settings/route').then( + (route) => route.PATCH + ), + }, updateOrganizationMember: { contract: v2UpdateOrganizationMemberContract, summary: 'Update Organization Member', @@ -2396,6 +2639,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.PATCH ), }, + updateOrganizationMemberUsageLimit: { + contract: v2UpdateOrganizationMemberUsageLimitContract, + summary: 'Update Organization Member Credit Limit', + description: + 'Set or clear a person’s credit cap. Hosted only. The userId must identify an organization member or external collaborator with workspace access in this organization. The cap is a nonnegative whole number of credits, not dollars: 0 prevents further credit-consuming usage; null removes the per-person cap. Organization limits continue to apply. Retrying the same value is safe. Requires organization administrator access. 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]/usage-limit/route').then( + (route) => route.PATCH + ), + }, updatePermissionGroup: { contract: v2UpdatePermissionGroupContract, summary: 'Update Permission Group', diff --git a/apps/sim/lib/api/server/routes/access-requests.ts b/apps/sim/lib/api/server/routes/access-requests.ts new file mode 100644 index 00000000000..6141512de25 --- /dev/null +++ b/apps/sim/lib/api/server/routes/access-requests.ts @@ -0,0 +1,18 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/resource-concealment' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { isRetryableTransactionError } from '@/lib/db/transaction' +import { PermissionGroupBusyError } from '@/lib/permission-groups/errors' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +const NOT_FOUND_MESSAGE = 'Access request scope not found' + +export const v2AccessRequestErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: NOT_FOUND_MESSAGE, + render(error) { + if (asOrchestrationError(error)?.code === 'not_found') + return v2Error('NOT_FOUND', NOT_FOUND_MESSAGE) + if (error instanceof PermissionGroupBusyError || 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/member-usage-limits.ts b/apps/sim/lib/api/server/routes/member-usage-limits.ts new file mode 100644 index 00000000000..d90c6cdd66f --- /dev/null +++ b/apps/sim/lib/api/server/routes/member-usage-limits.ts @@ -0,0 +1,16 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes/internal-json-route' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrganizationMembershipNotFoundError } from '@/lib/core/application/organization-authorization' + +export const internalMemberUsageLimitErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => + error instanceof OrganizationMembershipNotFoundError || + (error instanceof ForbiddenOperationError && error.detailCode === 'ORGANIZATION_ADMIN_REQUIRED') + ? internalErrorResponse(403, { error: 'Forbidden - Admin access required' }) + : null +) diff --git a/apps/sim/lib/api/server/routes/organization-usage.ts b/apps/sim/lib/api/server/routes/organization-usage.ts new file mode 100644 index 00000000000..e73dc5a7137 --- /dev/null +++ b/apps/sim/lib/api/server/routes/organization-usage.ts @@ -0,0 +1,19 @@ +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { + UsageWindowRangeInvertedError, + UsageWindowRangeTooLargeError, +} from '@/lib/billing/core/usage-analytics' +import { v2Error } from '@/app/api/v2/lib/response' + +export const v2OrganizationUsageErrorPolicy: V2ErrorPolicy = { + render(error) { + if ( + error instanceof UsageWindowRangeInvertedError || + error instanceof UsageWindowRangeTooLargeError + ) { + return v2Error('BAD_REQUEST', error.message) + } + return v2OrganizationErrorPolicy.render(error) + }, +} diff --git a/apps/sim/lib/billing/application/member-usage-limits/operations.ts b/apps/sim/lib/billing/application/member-usage-limits/operations.ts new file mode 100644 index 00000000000..e6ece6e3907 --- /dev/null +++ b/apps/sim/lib/billing/application/member-usage-limits/operations.ts @@ -0,0 +1,24 @@ +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' + +export const memberUsageLimitOperations = { + /** + * permission-group-exempt: administrators manage organization-funded credit caps independently of workspace capabilities. + */ + read: defineOrganizationOperation({ + id: 'organization_member_usage_limits.read', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: administrators set organization-funded credit caps independently of workspace capabilities. + */ + update: defineOrganizationOperation({ + id: 'organization_member_usage_limits.update', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:write', + }), +} as const diff --git a/apps/sim/lib/billing/application/member-usage-limits/use-cases.ts b/apps/sim/lib/billing/application/member-usage-limits/use-cases.ts new file mode 100644 index 00000000000..f24be27760b --- /dev/null +++ b/apps/sim/lib/billing/application/member-usage-limits/use-cases.ts @@ -0,0 +1,118 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { memberUsageLimitOperations } from '@/lib/billing/application/member-usage-limits/operations' +import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import { resolveBillingInterval } from '@/lib/billing/core/subscription' +import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion' +import { + getOrgMemberUsageForCurrentPeriod, + getOrgMemberUsageLimit, + isOrgMemberUsageLimitTarget, + setOrgMemberUsageLimit, +} from '@/lib/billing/organizations/member-limits' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import { + defineAuthorizedOrganizationUseCase, + type OrganizationUseCaseContext, +} from '@/lib/core/application/authorized-organization-use-case' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { isHosted } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const logger = createLogger('OrganizationMemberUsageLimits') + +export interface OrganizationMemberUsageLimitInput { + organizationId: string + userId: string +} + +export interface UpdateOrganizationMemberUsageLimitInput extends OrganizationMemberUsageLimitInput { + creditLimit: number | null +} + +/** Used before HTTP parsing as well as within the authorized application lifecycle. */ +export function requireHostedMemberUsageLimits() { + if (!isHosted) throw new OrchestrationError('not_found', 'Not found') +} + +async function requireMemberUsageLimitTarget({ + input, +}: OrganizationUseCaseContext) { + requireHostedMemberUsageLimits() + if (!(await isOrgMemberUsageLimitTarget(input.organizationId, input.userId))) { + throw new OrchestrationError('not_found', 'Member not found') + } +} + +export const getOrganizationMemberUsageLimit = defineAuthorizedOrganizationUseCase({ + operation: memberUsageLimitOperations.read, + authorizeResource: requireMemberUsageLimitTarget, + async execute({ input }: OrganizationUseCaseContext) { + const [limitDollars, subscription] = await Promise.all([ + getOrgMemberUsageLimit(input.organizationId, input.userId), + getOrganizationSubscription(input.organizationId), + ]) + const used = await getOrgMemberUsageForCurrentPeriod( + input.organizationId, + input.userId, + subscription + ) + return { + creditsUsed: dollarsToCredits(used), + creditLimit: limitDollars === null ? null : dollarsToCredits(limitDollars), + billingInterval: resolveBillingInterval(subscription), + } + }, +}) + +export const updateOrganizationMemberUsageLimit = defineAuthorizedOrganizationUseCase({ + operation: memberUsageLimitOperations.update, + authorizeResource: requireMemberUsageLimitTarget, + async execute({ + principal, + input, + context, + }: OrganizationUseCaseContext) { + const { organizationId, userId, creditLimit } = input + await db.transaction(async (tx) => { + await acquireOrganizationUserMutationLocks(tx, { userId, organizationIds: [organizationId] }) + await authorizeOrganizationOperation(principal, memberUsageLimitOperations.update, input, { + executor: tx, + forUpdate: true, + }) + if ( + !(await isOrgMemberUsageLimitTarget(organizationId, userId, { + executor: tx, + forShare: true, + })) + ) { + throw new OrchestrationError('not_found', 'Member not found') + } + await setOrgMemberUsageLimit( + organizationId, + userId, + creditLimit === null ? null : creditsToDollars(creditLimit), + context.userId, + tx + ) + }) + logger.info('Updated per-member usage limit', { + organizationId, + memberId: userId, + creditLimit, + updatedBy: context.userId, + }) + return { creditLimit } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: input.organizationId, + description: + result.creditLimit === null + ? `Cleared credit limit for member ${input.userId}` + : `Set credit limit for member ${input.userId} to ${result.creditLimit} credits`, + metadata: { targetUserId: input.userId, creditLimit: result.creditLimit }, + }), +}) diff --git a/apps/sim/lib/billing/application/organization-usage/authorized-organization-usage-use-case.ts b/apps/sim/lib/billing/application/organization-usage/authorized-organization-usage-use-case.ts index edef9a963ec..df4076d75c0 100644 --- a/apps/sim/lib/billing/application/organization-usage/authorized-organization-usage-use-case.ts +++ b/apps/sim/lib/billing/application/organization-usage/authorized-organization-usage-use-case.ts @@ -10,8 +10,8 @@ import { } from '@/lib/billing/core/reporting-period' import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' import type { BillingEntity } from '@/lib/billing/core/usage-log' -import { canUserManageBillingEntity } from '@/lib/billing/core/workspace-billing-authority' import { ForbiddenOperationError, type OperationUseCase } from '@/lib/core/application' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' import { isUsageMonitoringEnabled } from '@/lib/core/config/env-flags' export interface AuthorizedOrganizationUsageContext { @@ -49,15 +49,8 @@ function requireOrganizationUsagePrincipal( } /** - * Gate order for every organization usage read. Each step is a distinct refusal so a - * failure says which rule stopped it. - * - * 1. Principal kind — session only. - * 2. Billing authority — organization admin or owner. A workspace `admin` is - * explicitly not sufficient; this is pooled spend across every member. - * 3. Entitlement — enterprise plan on hosted, `USAGE_MONITORING_ENABLED` on - * self-hosted. Reuses audit-logs' error code so the client handles an - * entitlement refusal identically across EE settings. + * Organization-wide usage requires admin or owner authority, current credential policy, + * and usage-monitoring entitlement. Workspace admin authority alone is insufficient. */ export function defineAuthorizedOrganizationUsageUseCase< const O extends OrganizationUsageOperation, @@ -72,12 +65,7 @@ export function defineAuthorizedOrganizationUsageUseCase< const organizationId = definition.organizationId(input) const billingEntity: BillingEntity = { type: 'organization', id: organizationId } - if (!(await canUserManageBillingEntity(billingEntity, actorUserId))) { - throw new ForbiddenOperationError( - 'ORGANIZATION_ADMIN_REQUIRED', - 'Organization admin or owner authority is required to read pooled usage' - ) - } + await authorizeOrganizationOperation(principal, definition.operation, { organizationId }) /** * One call covers both the plan and the deployment: with billing on it checks diff --git a/apps/sim/lib/billing/application/organization-usage/event-cursor.test.ts b/apps/sim/lib/billing/application/organization-usage/event-cursor.test.ts new file mode 100644 index 00000000000..67aa3d684e4 --- /dev/null +++ b/apps/sim/lib/billing/application/organization-usage/event-cursor.test.ts @@ -0,0 +1,65 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { + readUsageEventCursor, + writeUsageEventCursor, +} from '@/lib/billing/application/organization-usage/event-cursor' +import { usageWindowLedgerFilter } from '@/lib/billing/core/usage-analytics' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const start = new Date('2026-08-01T00:00:00.000Z') +const end = new Date('2026-09-01T00:00:00.000Z') +const keys = ['2026-08-15T00:00:00.000Z', 'event-1'] + +describe('usage event window cursors', () => { + it.each(['stripe', 'reporting'] as const)( + 'retains the %s predicate when billing settings change', + (source) => { + const window = { + kind: 'period' as const, + period: { start, end, source, anchorDate: '2026-01-01', interval: 'month' as const }, + } + const encoded = writeUsageEventCursor(window, keys) + expect(encoded).not.toBeNull() + const decoded = readUsageEventCursor(encoded!, 366) + expect(usageWindowLedgerFilter(decoded.window)).toEqual(usageWindowLedgerFilter(window)) + expect(decoded.cursorKeys).toEqual(keys) + } + ) + + it.each([ + [], + ['range', start.toISOString(), end.toISOString()], + ['other', start.toISOString(), end.toISOString(), ...keys], + ['range', 'bad-date', end.toISOString(), ...keys], + ['range', end.toISOString(), start.toISOString(), ...keys], + ['range', '2000-01-01', end.toISOString(), ...keys], + ])('rejects malformed or oversized window %j', (...cursor) => { + expect(() => readUsageEventCursor(cursor, 366)).toThrow(OrchestrationError) + }) + + it('accepts the unchanged custom range', () => { + const decoded = readUsageEventCursor( + ['range', start.toISOString(), end.toISOString(), ...keys], + 366, + { start, end } + ) + expect(usageWindowLedgerFilter(decoded.window)).toEqual({ + startDate: start, + endDate: end, + endDateExclusive: true, + }) + expect(decoded.cursorKeys).toEqual(keys) + }) + + it.each([ + ['range', '2026-07-31T00:00:00.000Z', end.toISOString(), ...keys], + ['range', start.toISOString(), '2026-09-02T00:00:00.000Z', ...keys], + ['range', '2026-07-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ...keys], + ['period', start.toISOString(), end.toISOString(), ...keys], + ])('rejects a cursor overriding the custom range or its predicate: %j', (...cursor) => { + expect(() => readUsageEventCursor(cursor, 366, { start, end })).toThrow( + 'Usage event cursor does not match the requested custom range' + ) + }) +}) diff --git a/apps/sim/lib/billing/application/organization-usage/event-cursor.ts b/apps/sim/lib/billing/application/organization-usage/event-cursor.ts new file mode 100644 index 00000000000..336e2289e49 --- /dev/null +++ b/apps/sim/lib/billing/application/organization-usage/event-cursor.ts @@ -0,0 +1,59 @@ +import type { CursorKey } from '@/lib/api/list-query' +import { requireBoundedUsageWindow } from '@/lib/billing/application/organization-usage/limits' +import { type UsageAnalyticsWindow, usageWindowBounds } from '@/lib/billing/core/usage-analytics' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Preserve the first page's ledger predicate across clock and subscription changes. */ +export function readUsageEventCursor( + keys: CursorKey[], + maxWindowDays?: number, + expectedCustomRange?: { start: Date; end: Date } +) { + const [kind, start, end, ...cursorKeys] = keys + if ( + keys.length !== 5 || + (kind !== 'period' && kind !== 'range') || + typeof start !== 'string' || + typeof end !== 'string' + ) { + throw new OrchestrationError('validation', 'Invalid usage event cursor; restart pagination') + } + const from = new Date(start) + const to = new Date(end) + if (!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to <= from) { + throw new OrchestrationError( + 'validation', + 'Invalid usage event cursor window; restart pagination' + ) + } + if ( + expectedCustomRange && + (kind !== 'range' || + from.getTime() !== expectedCustomRange.start.getTime() || + to.getTime() !== expectedCustomRange.end.getTime()) + ) { + throw new OrchestrationError( + 'validation', + 'Usage event cursor does not match the requested custom range; restart pagination' + ) + } + const window: UsageAnalyticsWindow = + kind === 'range' + ? { kind, from, to } + : { + kind, + period: { start: from, end: to, source: 'stripe', anchorDate: null, interval: null }, + } + requireBoundedUsageWindow(window, maxWindowDays) + return { window, cursorKeys } +} + +export function writeUsageEventCursor( + window: UsageAnalyticsWindow, + keys: CursorKey[] | null +): CursorKey[] | null { + if (!keys) return null + const { start, end } = usageWindowBounds(window) + const kind = window.kind === 'period' && window.period.source !== 'reporting' ? 'period' : 'range' + return [kind, start.toISOString(), end.toISOString(), ...keys] +} diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-activity.test.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-activity.test.ts index 59aaaa23e16..0ee60fa459e 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-activity.test.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-activity.test.ts @@ -1,6 +1,7 @@ /** @vitest-environment node */ import type { Principal, SessionPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' const mocks = vi.hoisted(() => ({ authority: vi.fn(), @@ -11,8 +12,8 @@ const mocks = vi.hoisted(() => ({ workspace: vi.fn(), })) -vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({ - canUserManageBillingEntity: mocks.authority, +vi.mock('@/lib/core/application/organization-authorization', () => ({ + authorizeOrganizationOperation: mocks.authority, })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationFeatureEntitled: mocks.entitlement, @@ -76,11 +77,17 @@ describe.each([ }) it('requires current organization admin authority before checking entitlement or reading activity', async () => { - mocks.authority.mockResolvedValue(false) + mocks.authority.mockRejectedValue( + new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') + ) await expect(run(principal)).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED', }) - expect(mocks.authority).toHaveBeenCalledWith({ type: 'organization', id: 'org' }, 'admin') + expect(mocks.authority).toHaveBeenCalledWith( + principal, + expect.objectContaining({ minimumRole: 'admin', principalKinds: ['session'] }), + { organizationId: 'org' } + ) expect(mocks.entitlement).not.toHaveBeenCalled() expect(mocks.workspace).not.toHaveBeenCalled() expect(mocks.summary).not.toHaveBeenCalled() diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts index 4e5ea54fea3..e31077fc433 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts @@ -1,4 +1,8 @@ import { defineAuthorizedOrganizationUsageUseCase } from '@/lib/billing/application/organization-usage/authorized-organization-usage-use-case' +import { + requireBoundedUsageWindow, + UsageBreakdownTooLargeError, +} from '@/lib/billing/application/organization-usage/limits' import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' import { buildUsageAnalyticsScope, @@ -24,6 +28,7 @@ import { getProviderFromModel, PROVIDER_DEFINITIONS } from '@/providers/models' export interface OrganizationUsageBreakdownInput { organizationId: string + maxWindowDays?: number dimension: UsageBreakdownDimension preset: UsageWindowPreset startDate?: Date @@ -33,6 +38,7 @@ export interface OrganizationUsageBreakdownInput { /** Narrows to one workspace, for the Workspaces drill-down. */ workspaceId?: string limit: number + maxGroupedRows?: number } export interface OrganizationUsageBreakdownRow { @@ -78,8 +84,12 @@ export const getOrganizationUsageBreakdown = defineAuthorizedOrganizationUsageUs customEnd: input.endDate, timezone: input.timezone, }) + requireBoundedUsageWindow(window, input.maxWindowDays) const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId) - const raw = await readUsageBreakdown(scope, input.dimension) + const raw = await readUsageBreakdown(scope, input.dimension, undefined, input.maxGroupedRows) + if (input.maxGroupedRows !== undefined && raw.length > input.maxGroupedRows) { + throw new UsageBreakdownTooLargeError() + } /** * Re-key onto what the panel actually displays before ranking. diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts index 611f229be8e..dd4fa553186 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts @@ -1,4 +1,5 @@ import { defineAuthorizedOrganizationUsageUseCase } from '@/lib/billing/application/organization-usage/authorized-organization-usage-use-case' +import { requireBoundedUsageWindow } from '@/lib/billing/application/organization-usage/limits' import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' import type { UsagePeriodSource } from '@/lib/billing/core/reporting-period' import { @@ -16,6 +17,7 @@ import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conver export interface OrganizationUsageSummaryInput { organizationId: string + maxWindowDays?: number preset: UsageWindowPreset startDate?: Date endDate?: Date @@ -48,6 +50,7 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC customEnd: input.endDate, timezone: input.timezone, }) + requireBoundedUsageWindow(window, input.maxWindowDays) const bucket = resolveUsageBucket(window) const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId) diff --git a/apps/sim/lib/billing/application/organization-usage/limits.ts b/apps/sim/lib/billing/application/organization-usage/limits.ts new file mode 100644 index 00000000000..0110ddf1174 --- /dev/null +++ b/apps/sim/lib/billing/application/organization-usage/limits.ts @@ -0,0 +1,32 @@ +import { type UsageAnalyticsWindow, usageWindowBounds } from '@/lib/billing/core/usage-analytics' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export const PUBLIC_ORGANIZATION_USAGE_MAX_WINDOW_DAYS = 366 +export const PUBLIC_ORGANIZATION_USAGE_MAX_GROUPED_ROWS = 10_000 + +/** Public callers must choose a finite reporting window, including when no billing period exists. */ +export function requireBoundedUsageWindow(window: UsageAnalyticsWindow, maxDays?: number) { + if (maxDays === undefined) return + const { start, end } = usageWindowBounds(window) + if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) { + throw new OrchestrationError( + 'validation', + 'Usage window must have a finite start before its end' + ) + } + if (end.getTime() - start.getTime() > maxDays * 86_400_000) { + throw new OrchestrationError( + 'validation', + `Usage windows cannot exceed ${maxDays} days; choose 7d, 30d, or a bounded custom range` + ) + } +} + +export class UsageBreakdownTooLargeError extends OrchestrationError { + constructor() { + super( + 'payload_too_large', + 'Usage breakdown has too many groups; choose a narrower time window or workspace' + ) + } +} diff --git a/apps/sim/lib/billing/application/organization-usage/list-organization-usage-events.ts b/apps/sim/lib/billing/application/organization-usage/list-organization-usage-events.ts index 9a1e2b80607..288325b43f0 100644 --- a/apps/sim/lib/billing/application/organization-usage/list-organization-usage-events.ts +++ b/apps/sim/lib/billing/application/organization-usage/list-organization-usage-events.ts @@ -1,8 +1,15 @@ +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedOrganizationUsageUseCase } from '@/lib/billing/application/organization-usage/authorized-organization-usage-use-case' +import { + readUsageEventCursor, + writeUsageEventCursor, +} from '@/lib/billing/application/organization-usage/event-cursor' +import { requireBoundedUsageWindow } from '@/lib/billing/application/organization-usage/limits' import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' import { resolveUsageAnalyticsWindow, type UsageWindowPreset, + usageWindowBounds, usageWindowLedgerFilter, } from '@/lib/billing/core/usage-analytics' import { getBillingEntityUsageLogs } from '@/lib/billing/core/usage-log' @@ -19,12 +26,14 @@ export interface OrganizationUsageEventsInput { source?: InternalUsageLogSource[] limit: number cursor?: string + maxWindowDays?: number + keyset?: { sortOrder: ListSortOrder; cursorKeys?: CursorKey[] } } export interface OrganizationUsageEvent { id: string createdAt: string - source: string + source: InternalUsageLogSource description: string workflowName: string | null credits: number @@ -34,6 +43,7 @@ export interface OrganizationUsageEvent { export interface OrganizationUsageEventsResult { events: OrganizationUsageEvent[] nextCursor?: string + nextCursorKeys?: CursorKey[] | null hasMore: boolean } @@ -50,13 +60,23 @@ export const listOrganizationUsageEvents = defineAuthorizedOrganizationUsageUseC operation: organizationUsageOperations.listEvents, organizationId: (input: OrganizationUsageEventsInput) => input.organizationId, async execute({ input, context }): Promise { - const window = resolveUsageAnalyticsWindow({ - preset: input.preset, - period: context.period, - customStart: input.startDate, - customEnd: input.endDate, - timezone: input.timezone, - }) + const resolveWindow = () => + resolveUsageAnalyticsWindow({ + preset: input.preset, + period: context.period, + customStart: input.startDate, + customEnd: input.endDate, + timezone: input.timezone, + }) + const continuation = input.keyset?.cursorKeys + ? readUsageEventCursor( + input.keyset.cursorKeys, + input.maxWindowDays, + input.preset === 'custom' ? usageWindowBounds(resolveWindow()) : undefined + ) + : undefined + const window = continuation?.window ?? resolveWindow() + requireBoundedUsageWindow(window, input.maxWindowDays) const result = await getBillingEntityUsageLogs(context.billingEntity, { // One derivation for both predicates, so this list covers exactly the rows the // summary and breakdowns aggregate over. @@ -64,6 +84,9 @@ export const listOrganizationUsageEvents = defineAuthorizedOrganizationUsageUseC ...(input.source?.length ? { source: input.source } : {}), limit: input.limit, ...(input.cursor ? { cursor: input.cursor } : {}), + ...(input.keyset + ? { keyset: { sortOrder: input.keyset.sortOrder, cursorKeys: continuation?.cursorKeys } } + : {}), includeSummary: false, }) @@ -78,6 +101,11 @@ export const listOrganizationUsageEvents = defineAuthorizedOrganizationUsageUseC hasCost: log.cost > 0, })), ...(result.pagination.nextCursor ? { nextCursor: result.pagination.nextCursor } : {}), + ...(input.keyset + ? { + nextCursorKeys: writeUsageEventCursor(window, result.pagination.nextCursorKeys ?? null), + } + : {}), hasMore: result.pagination.hasMore, } }, diff --git a/apps/sim/lib/billing/application/organization-usage/operations.ts b/apps/sim/lib/billing/application/organization-usage/operations.ts index 8038552f325..9a73b59c66a 100644 --- a/apps/sim/lib/billing/application/organization-usage/operations.ts +++ b/apps/sim/lib/billing/application/organization-usage/operations.ts @@ -1,45 +1,34 @@ import type { Principal } from '@sim/auth/principal' -import type { ApplicationOperation } from '@/lib/core/application' -import { assertOperationCapability } from '@/lib/core/application' +import { + defineOrganizationOperation, + type OrganizationOperation, +} from '@/lib/core/application/organization-operation' -/** - * Session only. - * - * An organization's pooled ledger discloses every member's model spend, which is why - * `workspace-billing-authority` treats organization membership alone as insufficient - * for it. There is no API-key consumer of this surface today, and adding one should - * be a deliberate decision rather than something inherited from a default. - */ -export type OrganizationUsagePrincipal = Extract +export type OrganizationUsagePrincipal = Extract< + Principal, + { kind: 'session' | 'personal_api_key' | 'oauth_access_token' } +> -export interface OrganizationUsageOperation - extends ApplicationOperation { +export interface OrganizationUsageOperation extends OrganizationOperation { readonly authority: 'organization_billing_admin' readonly organizationRoles: readonly ['admin', 'owner'] readonly workspaceApiKey: 'deny' - readonly principalKinds: readonly ['session'] -} - -function defineOrganizationUsageOperation( - operation: OrganizationUsageOperation -): OrganizationUsageOperation { - if ((operation.principalKinds as readonly string[]).some((kind) => kind !== 'session')) { - throw new Error( - `Organization usage operation ${operation.id} may only be performed by a session` - ) - } - assertOperationCapability(operation) - Object.freeze(operation.organizationRoles) - Object.freeze(operation.principalKinds) - return Object.freeze(operation) + readonly principalKinds: readonly OrganizationUsagePrincipal['kind'][] } const BASE = { authority: 'organization_billing_admin', organizationRoles: ['admin', 'owner'], + minimumRole: 'admin', workspaceApiKey: 'deny', principalKinds: ['session'], -} as const satisfies Omit +} as const + +const PUBLIC_READ = { + ...BASE, + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', +} as const /** * Every one takes `capability: 'none'`, written out at each call site rather than @@ -48,38 +37,50 @@ const BASE = { * nothing outside the type system ever sees. */ export const organizationUsageOperations = { - // permission-group-exempt: aggregate organization activity is governed by organization admin authority, not a workspace permission group - readActivitySummary: defineOrganizationUsageOperation({ + /** + * permission-group-exempt: aggregate organization activity is governed by organization admin authority, not a workspace permission group + */ + readActivitySummary: defineOrganizationOperation({ id: 'organization_usage.activity.summary.read', capability: 'none', ...BASE, }), - // permission-group-exempt: organization activity breakdowns require the same organization admin authority as the summary - readActivityBreakdown: defineOrganizationUsageOperation({ + /** + * permission-group-exempt: organization activity breakdowns require the same organization admin authority as the summary + */ + readActivityBreakdown: defineOrganizationOperation({ id: 'organization_usage.activity.breakdown.read', capability: 'none', ...BASE, }), - // permission-group-exempt: the organization's pooled ledger is authorized by organization billing-admin authority, which no workspace-shaped group key names - readSummary: defineOrganizationUsageOperation({ + /** + * permission-group-exempt: the organization's pooled ledger is authorized by organization billing-admin authority, which no workspace-shaped group key names + */ + readSummary: defineOrganizationOperation({ id: 'organization_usage.summary.read', capability: 'none', - ...BASE, + ...PUBLIC_READ, }), - // permission-group-exempt: the same pooled ledger, broken down; organization billing-admin authority governs it - readBreakdown: defineOrganizationUsageOperation({ + /** + * permission-group-exempt: the same pooled ledger, broken down; organization billing-admin authority governs it + */ + readBreakdown: defineOrganizationOperation({ id: 'organization_usage.breakdown.read', capability: 'none', - ...BASE, + ...PUBLIC_READ, }), - // permission-group-exempt: organization billing events, governed by organization billing-admin authority rather than a workspace group - listEvents: defineOrganizationUsageOperation({ + /** + * permission-group-exempt: organization billing events, governed by organization billing-admin authority rather than a workspace group + */ + listEvents: defineOrganizationOperation({ id: 'organization_usage.events.list', capability: 'none', - ...BASE, + ...PUBLIC_READ, }), - // permission-group-exempt: exports the same organization billing events; logs.export names workflow run logs, not the billing ledger - exportEvents: defineOrganizationUsageOperation({ + /** + * permission-group-exempt: exports the same organization billing events; logs.export names workflow run logs, not the billing ledger + */ + exportEvents: defineOrganizationOperation({ id: 'organization_usage.events.export', capability: 'none', ...BASE, diff --git a/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts b/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts index 22120ef90ff..92d3a5cdea1 100644 --- a/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts +++ b/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts @@ -6,15 +6,15 @@ import { setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - canUserManageBillingEntity: vi.fn(), + authorizeOrganizationOperation: vi.fn(), isOrganizationFeatureEntitled: vi.fn(), getOrganizationSubscription: vi.fn(), readUsageTotals: vi.fn(), readUsageTimeSeries: vi.fn(), })) -vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({ - canUserManageBillingEntity: mocks.canUserManageBillingEntity, +vi.mock('@/lib/core/application/organization-authorization', () => ({ + authorizeOrganizationOperation: mocks.authorizeOrganizationOperation, })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationFeatureEntitled: mocks.isOrganizationFeatureEntitled, @@ -64,7 +64,7 @@ describe('organization usage authorization', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isBillingEnabled: true, isHosted: true }) - mocks.canUserManageBillingEntity.mockResolvedValue(true) + mocks.authorizeOrganizationOperation.mockResolvedValue(true) mocks.isOrganizationFeatureEntitled.mockResolvedValue(true) mocks.getOrganizationSubscription.mockResolvedValue({ plan: 'enterprise', @@ -79,15 +79,23 @@ describe('organization usage authorization', () => { setEnvFlags({ isBillingEnabled: false, isHosted: false }) }) - it('refuses an API key: pooled usage discloses every member’s spend', async () => { - // The operation names `session` alone. Widening it is a deliberate decision, not - // something that should fall out of a principal shape happening to carry a userId. - expect(await codeOf(run(personalKey))).toBe('PRINCIPAL_KIND_NOT_PERMITTED') - expect(mocks.canUserManageBillingEntity).not.toHaveBeenCalled() + it('admits personal keys through the shared current-organization authorization', async () => { + await run(personalKey) + expect(mocks.authorizeOrganizationOperation).toHaveBeenCalledWith( + personalKey, + expect.objectContaining({ + id: 'organization_usage.summary.read', + minimumRole: 'admin', + oauthScope: 'api:read', + }), + { organizationId: ORG } + ) }) it('refuses a member who is not an organization admin', async () => { - mocks.canUserManageBillingEntity.mockResolvedValue(false) + mocks.authorizeOrganizationOperation.mockRejectedValue( + new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') + ) expect(await codeOf(run())).toBe('ORGANIZATION_ADMIN_REQUIRED') }) @@ -99,7 +107,9 @@ describe('organization usage authorization', () => { }) it('checks authority before entitlement, so a non-admin learns nothing about the plan', async () => { - mocks.canUserManageBillingEntity.mockResolvedValue(false) + mocks.authorizeOrganizationOperation.mockRejectedValue( + new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Admin required') + ) mocks.isOrganizationFeatureEntitled.mockResolvedValue(false) expect(await codeOf(run())).toBe('ORGANIZATION_ADMIN_REQUIRED') diff --git a/apps/sim/lib/billing/core/organization-usage-pagination.postgres.test.ts b/apps/sim/lib/billing/core/organization-usage-pagination.postgres.test.ts new file mode 100644 index 00000000000..2cb9a352ea9 --- /dev/null +++ b/apps/sim/lib/billing/core/organization-usage-pagination.postgres.test.ts @@ -0,0 +1,109 @@ +/** @vitest-environment node */ +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { databaseUrl, select } = vi.hoisted(() => { + const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL + if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Usage integration tests require a disposable local database') + } + return { databaseUrl, select: vi.fn() } +}) +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') +vi.mock('@sim/db', () => ({ db: { select }, dbReplica: { select } })) + +import { usageLog } from '@sim/db/schema' +import { readUsageBreakdown } from '@/lib/billing/core/usage-analytics-queries' +import { getBillingEntityUsageLogs } from '@/lib/billing/core/usage-log' + +const schemaName = `usage_pagination_${generateId().replaceAll('-', '')}` +const connection = databaseUrl + ? postgres(databaseUrl, { + max: 1, + prepare: false, + connection: { search_path: schemaName }, + onnotice: () => undefined, + }) + : undefined + +beforeAll(async () => { + if (!connection) return + await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) + await connection.unsafe(`CREATE TABLE usage_log ( + id text PRIMARY KEY, user_id text, category text, source text, description text, + metadata jsonb, cost numeric, billing_entity_type text, billing_entity_id text, + billing_period_start timestamp, billing_period_end timestamp, workspace_id text, + workflow_id text, execution_id text, created_at timestamp NOT NULL + ); CREATE TABLE workflow (id text PRIMARY KEY, name text);`) + const database = drizzle(connection) + select.mockImplementation((fields) => database.select(fields)) +}) +beforeEach(async () => { + if (!connection) return + await connection`TRUNCATE usage_log` + await connection`INSERT INTO usage_log (id, user_id, category, source, description, cost, + billing_entity_type, billing_entity_id, created_at) + VALUES ('a', 'a', 'model', 'workspace-chat', 'Model', 0.1, 'organization', 'org', '2026-06-01 00:00:00.123999'), + ('b', 'b', 'model', 'copilot', 'Model', 0.2, 'organization', 'org', '2026-06-01 00:00:00.123001'), + ('c', 'c', 'model', 'workflow', 'Model', 0.3, 'organization', 'org', '2026-06-01 00:00:01'), + ('x', 'x', 'model', 'workflow', 'Model', 999, 'organization', 'other', '2026-06-01 00:00:00.123500')` +}) +afterAll(async () => { + if (!connection) return + await connection.unsafe(`DROP SCHEMA "${schemaName}" CASCADE`) + await connection.end() +}) + +describe.skipIf(!databaseUrl)('organization usage pagination SQL', () => { + it.each(['asc', 'desc'] as const)( + 'pages millisecond ties exactly once in %s order after the anchor is deleted', + async (sortOrder) => { + const options = { limit: 1, includeSummary: false, keyset: { sortOrder } } + const first = await getBillingEntityUsageLogs({ type: 'organization', id: 'org' }, options) + const seen = first.logs.map((row) => row.id) + await connection!`DELETE FROM usage_log WHERE id = ${seen[0]}` + let cursorKeys = first.pagination.nextCursorKeys + while (cursorKeys) { + const page = await getBillingEntityUsageLogs( + { type: 'organization', id: 'org' }, + { + ...options, + keyset: { sortOrder, cursorKeys }, + } + ) + seen.push(...page.logs.map((row) => row.id)) + cursorKeys = page.pagination.nextCursorKeys + } + expect(seen).toEqual(sortOrder === 'asc' ? ['a', 'b', 'c'] : ['c', 'b', 'a']) + } + ) + + it('rejects malformed keysets before executing SQL', async () => { + select.mockClear() + await expect( + getBillingEntityUsageLogs( + { type: 'organization', id: 'org' }, + { + includeSummary: false, + keyset: { sortOrder: 'desc', cursorKeys: ['not-a-date', 'a'] }, + } + ) + ).rejects.toMatchObject({ code: 'validation' }) + expect(select).not.toHaveBeenCalled() + }) + + it('caps grouped row materialization without changing legacy aggregate results', async () => { + const scope = [eq(usageLog.billingEntityId, 'org')] + expect(await readUsageBreakdown(scope, 'member', undefined, 1)).toHaveLength(2) + const all = await readUsageBreakdown(scope, 'member') + expect(all).toHaveLength(3) + expect(all.reduce((total, row) => total + Number(row.cost), 0)).toBeCloseTo(0.6) + expect(await readUsageBreakdown(scope, 'model', undefined, 1)).toEqual([ + { key: 'Model', cost: '0.6', events: 3, inputTokens: 0, outputTokens: 0 }, + ]) + }) +}) diff --git a/apps/sim/lib/billing/core/usage-analytics-queries.ts b/apps/sim/lib/billing/core/usage-analytics-queries.ts index 7b734ec8f42..9a9eb17e622 100644 --- a/apps/sim/lib/billing/core/usage-analytics-queries.ts +++ b/apps/sim/lib/billing/core/usage-analytics-queries.ts @@ -121,7 +121,8 @@ function breakdownColumn(dimension: UsageBreakdownDimension) { export async function readUsageBreakdown( scope: SQL[], dimension: UsageBreakdownDimension, - executor: DbClient = dbReplica + executor: DbClient = dbReplica, + maxRows?: number ): Promise { const column = breakdownColumn(dimension) const conditions = [...scope] @@ -141,34 +142,33 @@ export async function readUsageBreakdown( if (dimension === 'workflow') conditions.push(isNotNull(usageLog.workflowId)) if (!MODEL_DIMENSIONS.has(dimension)) { - return ( - executor - .select({ - key: sql`${column}`, - cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, - events: sql`COUNT(*)`.mapWith(Number), - }) - .from(usageLog) - .where(and(...conditions)) - .groupBy(column) - /** - * Drops groups whose every row is reporting-only. Unbilled rows carry a user, - * a workspace, a workflow and `source = 'workflow'` like any other, so without - * this a BYOK-only member appeared in a credit-denominated list at 0 credits. - * - * As a `HAVING` on the aggregate rather than a `category` predicate on purpose: - * `category` is not in `usage_log_billing_entity_created_at_cost_idx`, so - * filtering on it would force a heap fetch on `member` and `source` — the two - * dimensions that are index-only today. `cost` is in that index, and only an - * unbilled row can sum to zero, since `recordUsage` admits nothing else at zero. - */ - .having(sql`COALESCE(SUM(${usageLog.cost}), 0) > 0`) - ) + const query = executor + .select({ + key: sql`${column}`, + cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, + events: sql`COUNT(*)`.mapWith(Number), + }) + .from(usageLog) + .where(and(...conditions)) + .groupBy(column) + /** + * Drops groups whose every row is reporting-only. Unbilled rows carry a user, + * a workspace, a workflow and `source = 'workflow'` like any other, so without + * this a BYOK-only member appeared in a credit-denominated list at 0 credits. + * + * As a `HAVING` on the aggregate rather than a `category` predicate on purpose: + * `category` is not in `usage_log_billing_entity_created_at_cost_idx`, so + * filtering on it would force a heap fetch on `member` and `source` — the two + * dimensions that are index-only today. `cost` is in that index, and only an + * unbilled row can sum to zero, since `recordUsage` admits nothing else at zero. + */ + .having(sql`COALESCE(SUM(${usageLog.cost}), 0) > 0`) + return maxRows === undefined ? query : query.limit(maxRows + 1) } // Already heap-reading `description`, so summing `metadata` costs nothing extra — // and BYOK rows carry no cost at all, making tokens the only usage they can show. - return executor + const query = executor .select({ key: sql`${column}`, cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, @@ -185,6 +185,7 @@ export async function readUsageBreakdown( .from(usageLog) .where(and(...conditions)) .groupBy(column) + return maxRows === undefined ? query : query.limit(maxRows + 1) } /** diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 07ed2580368..b79653f0e49 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -5,6 +5,16 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, desc, eq, gte, inArray, lt, lte, notInArray, or, sql } from 'drizzle-orm' +import { + type CursorKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { @@ -983,6 +993,7 @@ export interface GetUsageLogsOptions { * Skips the row lookup that would otherwise resolve it from `cursor`. */ cursorCreatedAt?: Date + keyset?: { sortOrder: ListSortOrder; cursorKeys?: CursorKey[] } /** * Whether to compute the full-filter `summary` aggregate (default `true`). * A cursor-paginated caller collecting every page (e.g. a CSV export) only @@ -1023,10 +1034,16 @@ export interface UsageLogsResult { } pagination: { nextCursor?: string + nextCursorKeys?: CursorKey[] | null hasMore: boolean } } +const USAGE_LOG_KEYS = [ + timestampKey(usageLog.createdAt, (row: { createdAt: Date; id: string }) => row.createdAt), + textKey(usageLog.id, (row: { createdAt: Date; id: string }) => row.id), +] + /** * Gets one bounded usage-log page for an explicit actor or workspace scope. */ @@ -1044,6 +1061,7 @@ async function getUsageLogs( limit = 50, cursor, cursorCreatedAt, + keyset, includeSummary = true, } = options @@ -1057,7 +1075,10 @@ async function getUsageLogs( billingPeriod, }) - if (cursor) { + if (keyset) { + const after = resumeKeyset(USAGE_LOG_KEYS, keyset.cursorKeys, keyset.sortOrder) + if (after) conditions.push(after) + } else if (cursor) { let resolvedCursorCreatedAt = cursorCreatedAt if (!resolvedCursorCreatedAt) { @@ -1100,11 +1121,16 @@ async function getUsageLogs( .from(usageLog) .leftJoin(workflow, eq(usageLog.workflowId, workflow.id)) .where(and(...conditions)) - .orderBy(desc(usageLog.createdAt), desc(usageLog.id)) + .orderBy( + ...(keyset + ? listOrderBy(keysetColumns(USAGE_LOG_KEYS), keyset.sortOrder) + : [desc(usageLog.createdAt), desc(usageLog.id)]) + ) .limit(limit + 1) const hasMore = logs.length > limit - const resultLogs = hasMore ? logs.slice(0, limit) : logs + const page = keyset ? keysetPage(USAGE_LOG_KEYS, logs, limit) : undefined + const resultLogs = page?.data ?? (hasMore ? logs.slice(0, limit) : logs) const transformedLogs: UsageLogEntry[] = resultLogs.map((log) => ({ id: log.id, @@ -1156,6 +1182,7 @@ async function getUsageLogs( bySource, }, pagination: { + ...(page ? { nextCursorKeys: page.nextCursorKeys } : {}), nextCursor: hasMore && resultLogs.length > 0 ? resultLogs[resultLogs.length - 1].id : undefined, hasMore, diff --git a/apps/sim/lib/billing/organizations/member-limits.postgres.test.ts b/apps/sim/lib/billing/organizations/member-limits.postgres.test.ts new file mode 100644 index 00000000000..b5d6b9f0063 --- /dev/null +++ b/apps/sim/lib/billing/organizations/member-limits.postgres.test.ts @@ -0,0 +1,178 @@ +/** @vitest-environment node */ + +import { recordAudit } from '@sim/audit' +import * as schema from '@sim/db/schema' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const { databaseUrl, select, transaction } = vi.hoisted(() => { + const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL + if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Usage integration tests require a disposable local database') + } + return { databaseUrl, select: vi.fn(), transaction: vi.fn() } +}) +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') +vi.mock('@sim/db', () => ({ db: { select, transaction }, dbReplica: { select } })) +vi.mock('@sim/audit', async (original) => ({ + ...(await original()), + recordAudit: vi.fn(), +})) +vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: vi.fn() })) + +import { updateOrganizationMemberUsageLimit } from '@/lib/billing/application/member-usage-limits/use-cases' +import { isOrgMemberUsageLimitTarget } from '@/lib/billing/organizations/member-limits' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' + +const schemaName = `member_limits_${generateId().replaceAll('-', '')}` +const connection = databaseUrl + ? postgres(databaseUrl, { + max: 3, + prepare: false, + connection: { search_path: schemaName, application_name: schemaName }, + onnotice: () => undefined, + }) + : undefined + +const database = connection ? drizzle(connection, { schema }) : undefined + +beforeAll(async () => { + if (!connection) return + await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) + await connection.unsafe(` + CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text, role text DEFAULT 'member'); + CREATE TABLE workspace (id text PRIMARY KEY, organization_id text, archived_at timestamp); + CREATE TABLE permissions (id text PRIMARY KEY, user_id text, entity_type text, entity_id text); + CREATE TABLE "user" (id text PRIMARY KEY); + INSERT INTO "user" VALUES ('unrelated'); + INSERT INTO member (id, organization_id, user_id) VALUES + ('m1', 'org', 'member'), ('m2', 'other', 'external'), ('m3', 'other', 'foreign-member'); + INSERT INTO member VALUES ('actor', 'org', 'actor', 'admin'); + CREATE TABLE organization_member_usage_limit ( + id text PRIMARY KEY, organization_id text, user_id text, usage_limit numeric, + set_by text, created_at timestamp DEFAULT now(), updated_at timestamp DEFAULT now(), + UNIQUE(organization_id, user_id) + ); + INSERT INTO workspace VALUES + ('local', 'org', null), ('foreign', 'other', null), ('archived', 'org', now()); + INSERT INTO permissions VALUES + ('p1', 'external', 'workspace', 'local'), + ('p2', 'archived-external', 'workspace', 'archived'), + ('p3', 'foreign-external', 'workspace', 'foreign'), + ('p4', 'workflow-only', 'workflow', 'local'), + ('p5', 'missing-workspace', 'workspace', 'missing'), + ('p6', 'revoked', 'workspace', 'local'); + `) + select.mockImplementation((fields) => database!.select(fields)) + transaction.mockImplementation((callback) => database!.transaction(callback)) + setEnvFlags({ isHosted: true }) +}) + +afterAll(async () => { + resetEnvFlagsMock() + if (!connection) return + await connection.unsafe(`DROP SCHEMA "${schemaName}" CASCADE`) + await connection.end() +}) + +describe.skipIf(!databaseUrl)('organization credit-limit target SQL', () => { + it.each([ + ['member', true], + ['external', true], + ['archived-external', true], + ['foreign-member', false], + ['foreign-external', false], + ['workflow-only', false], + ['missing-workspace', false], + ['unrelated', false], + ['missing-user', false], + ] as const)('resolves target %s within the organization', async (userId, expected) => { + expect(await isOrgMemberUsageLimitTarget('org', userId)).toBe(expected) + }) + + it('requires a current relationship after external access is revoked', async () => { + expect(await isOrgMemberUsageLimitTarget('org', 'revoked')).toBe(true) + await connection!`DELETE FROM permissions WHERE user_id = 'revoked'` + expect(await isOrgMemberUsageLimitTarget('org', 'revoked')).toBe(false) + }) +}) + +describe.skipIf(!databaseUrl)('organization credit-limit mutation races', () => { + it.each(['member', 'external', 'archived-external'])( + 'sets and clears a cap for the eligible target %s', + async (userId) => { + const principal = { kind: 'session', userId: 'actor', sessionId: 'session' } as const + await expect( + updateOrganizationMemberUsageLimit.execute({ + principal, + input: { organizationId: 'org', userId, creditLimit: 400 }, + }) + ).resolves.toEqual({ creditLimit: 400 }) + const [cap] = + await connection!`SELECT usage_limit, set_by FROM organization_member_usage_limit WHERE user_id = ${userId}` + expect(Number(cap.usage_limit)).toBe(2) + expect(cap.set_by).toBe('actor') + await expect( + updateOrganizationMemberUsageLimit.execute({ + principal, + input: { organizationId: 'org', userId, creditLimit: null }, + }) + ).resolves.toEqual({ creditLimit: null }) + expect( + await connection!`SELECT id FROM organization_member_usage_limit WHERE user_id = ${userId}` + ).toHaveLength(0) + } + ) + + it.each(['organization-removal', 'workspace-revocation'] as const)( + 'rejects a target revoked by %s while the update waits', + async (removalKind) => { + const userId = `target-${removalKind}` + await connection!`INSERT INTO permissions VALUES (${userId}, ${userId}, 'workspace', 'local')` + vi.mocked(recordAudit).mockClear() + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + const removal = database!.transaction(async (tx) => { + if (removalKind === 'organization-removal') { + await acquireOrganizationUserMutationLocks(tx, { userId, organizationIds: ['org'] }) + } + await tx.delete(schema.permissions).where(eq(schema.permissions.userId, userId)) + ready.resolve() + await release.promise + }) + await ready.promise + const update = updateOrganizationMemberUsageLimit + .execute({ + principal: { kind: 'session', userId: 'actor', sessionId: 'session' }, + input: { organizationId: 'org', userId, creditLimit: 400 }, + }) + .then( + (result) => ({ result }), + (error: unknown) => ({ error }) + ) + try { + await vi.waitFor( + async () => { + const [waiting] = await connection!`SELECT count(*)::int AS count FROM pg_stat_activity + WHERE application_name = ${schemaName} AND wait_event_type = 'Lock'` + expect(waiting.count).toBeGreaterThan(0) + }, + { timeout: 2000 } + ) + } finally { + release.resolve() + await removal + } + expect(await update).toMatchObject({ error: { code: 'not_found' } }) + const caps = + await connection!`SELECT id FROM organization_member_usage_limit WHERE user_id = ${userId}` + expect(caps).toHaveLength(0) + expect(recordAudit).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/billing/organizations/member-limits.ts b/apps/sim/lib/billing/organizations/member-limits.ts index 16caadf6313..94b6c294ece 100644 --- a/apps/sim/lib/billing/organizations/member-limits.ts +++ b/apps/sim/lib/billing/organizations/member-limits.ts @@ -1,5 +1,11 @@ import { db } from '@sim/db' -import { organizationMemberUsageLimit, usageLog, workspace } from '@sim/db/schema' +import { + member, + organizationMemberUsageLimit, + permissions, + usageLog, + workspace, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, gte, isNull, lt, or, sql } from 'drizzle-orm' @@ -12,6 +18,44 @@ import type { DbOrTx } from '@/lib/db/types' const logger = createLogger('OrgMemberLimits') +/** + * Includes external collaborators whose explicit workspace access belongs to the organization. + * Retained grants on archived workspaces still qualify so their caps remain manageable. + * Mutations hold the organization fence before requesting a shared relationship lock; + * together these stabilize workspace scope and access through the write. + */ +export async function isOrgMemberUsageLimitTarget( + organizationId: string, + userId: string, + options: { executor?: DbOrTx; forShare?: boolean } = {} +): Promise { + const executor = options.executor ?? db + const memberQuery = executor + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + .limit(1) + const [organizationMember] = options.forShare ? await memberQuery.for('share') : await memberQuery + if (organizationMember) return true + + const workspaceQuery = executor + .select({ id: permissions.id }) + .from(permissions) + .innerJoin(workspace, eq(workspace.id, permissions.entityId)) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(workspace.organizationId, organizationId) + ) + ) + .limit(1) + const [workspaceMember] = options.forShare + ? await workspaceQuery.for('share', { of: permissions }) + : await workspaceQuery + return Boolean(workspaceMember) +} + /** * Read a member's per-organization usage limit (dollars). Returns `null` when no * cap is set for the `(organization, user)` pair — meaning only the pooled org diff --git a/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts b/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts index 1c9af16437a..288d4223010 100644 --- a/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts +++ b/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts @@ -93,7 +93,8 @@ describe('fresh chat callback authorization', () => { 'actor', 'workspace', 'copilot.use', - 'current-organization' + 'current-organization', + undefined ) expect(mocks.permission.mock.invocationCallOrder[0]).toBeLessThan( mocks.capability.mock.invocationCallOrder[0] diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 996618b1793..f16401d2e88 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -41,6 +41,10 @@ export const FORBIDDEN_DETAIL_CODES = [ 'ORGANIZATION_PLAN_REQUIRED', /** Audit logging is switched off for this deployment. */ 'AUDIT_LOGS_DISABLED', + /** The organization or deployment has paused access requests. */ + 'ACCESS_REQUESTS_DISABLED', + /** Access requests need an organization-owned workspace. */ + 'ACCESS_REQUEST_ORGANIZATION_REQUIRED', /** The caller holds workspace write but is not an editor of this skill. */ 'SKILL_EDITOR_ACCESS_REQUIRED', /** The caller holds workspace write but is not an admin of this secret. */ diff --git a/apps/sim/lib/core/application/locked-credential-authorization.test.ts b/apps/sim/lib/core/application/locked-credential-authorization.test.ts new file mode 100644 index 00000000000..3d006c2ef59 --- /dev/null +++ b/apps/sim/lib/core/application/locked-credential-authorization.test.ts @@ -0,0 +1,208 @@ +/** @vitest-environment node */ +import type { OAuthAccessTokenPrincipal, PersonalApiKeyPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { member, permissions, user, workspace } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + workspaceConfig: vi.fn(), + organizationConfig: vi.fn(), + role: vi.fn(), + lock: vi.fn(), + audit: vi.fn(), +})) +vi.mock('@sim/platform-authz/workspace', async (original) => ({ + ...(await original()), + resolveEffectiveWorkspacePermission: mocks.role, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + resolveVerifiedUserAccessControlContext: mocks.workspaceConfig, + getUserPermissionConfigForOrganization: mocks.organizationConfig, + getUserPermissionConfig: vi.fn(), +})) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.lock, +})) +vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ + recordProjectedUseCaseAuditEntries: mocks.audit, +})) +vi.mock('@/lib/core/network/context.server', () => ({ + runWithOutboundOrganization: (_id: string, execute: () => Promise) => execute(), +})) + +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { authorizeWorkspaceOperation } from '@/lib/core/application/workspace-authorization' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' +import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' + +const personal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'person', + keyId: 'key', +} +const oauth: OAuthAccessTokenPrincipal = { + kind: 'oauth_access_token', + userId: 'person', + tokenId: 'token', + clientId: 'client', + scopes: ['api:write'], + expiresAt: new Date('2099-01-01'), +} +const credentialCases = [ + { + name: 'personal API key', + principal: personal, + field: 'disablePersonalApiKeys', + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }, + { + name: 'OAuth personal-key policy', + principal: oauth, + field: 'disablePersonalApiKeys', + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }, + { + name: 'OAuth app', + principal: oauth, + field: 'disableOAuthAppAccess', + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }, + { + name: 'CLI', + principal: { ...oauth, clientId: SIM_CLI_CLIENT_ID }, + field: 'disableCliAccess', + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }, +] as const +const context = { + workspaceId: 'workspace', + workspaceOrganizationId: 'org', + allowPersonalApiKeys: true, +} + +function queueScope(scope: AccessRequestScope) { + queueTableRows(user, [{ suspendedAt: null, banned: false, banExpires: null }]) + queueTableRows(member, [{ id: 'membership', role: 'admin' }]) + if (scope.kind === 'workspace') { + queueTableRows(workspace, [ + { id: 'workspace', organizationId: 'org', allowPersonalApiKeys: true }, + ]) + queueTableRows(permissions, [{ id: 'grant' }]) + } else { + queueTableRows(member, [{ role: 'admin' }]) + } +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.role.mockResolvedValue('admin') + mocks.workspaceConfig.mockResolvedValue({ config: DEFAULT_PERMISSION_GROUP_CONFIG }) + mocks.organizationConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) +}) + +describe('locked credential reauthorization', () => { + describe.each([ + { kind: 'workspace', workspaceId: 'workspace' }, + { kind: 'organization', organizationId: 'org' }, + ])('$kind access-request mutations', (scope) => { + it.each(credentialCases)( + 'refuses a $name disabled after preflight without mutating or auditing', + async ({ principal, field, detailCode }) => { + queueScope(scope) + queueScope(scope) + const updated = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [field]: true } + mocks.lock.mockImplementation(async () => { + mocks.workspaceConfig.mockResolvedValue({ config: updated }) + mocks.organizationConfig.mockImplementation(async (_organizationId, executor) => { + expect(executor).toBe(db) + return updated + }) + }) + const execute = vi.fn() + const projectAudit = vi.fn().mockReturnValue([]) + const useCase = defineAuthorizedAccessRequestUseCase({ + operation: accessRequestOperations.create, + scope: () => scope, + mutation: true, + execute, + projectAudit, + }) + await withPermissionGroupScope(async () => { + await resolvePermissionGroupConfig('person', 'workspace', 'org') + await expect(useCase.execute({ principal, input: {} })).rejects.toMatchObject({ + detailCode, + }) + expect(await resolvePermissionGroupConfig('person', 'workspace', 'org')).toEqual( + DEFAULT_PERMISSION_GROUP_CONFIG + ) + }) + expect(mocks.lock).toHaveBeenCalledExactlyOnceWith(db, 'org') + expect(execute).not.toHaveBeenCalled() + expect(projectAudit).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + if (scope.kind === 'workspace') { + expect(mocks.workspaceConfig).toHaveBeenLastCalledWith('person', 'workspace', 'org', db) + } + } + ) + }) + + it('rechecks an operation capability through the locked executor for sessions', async () => { + const operation = defineWorkspaceOperation({ + id: 'test.tables', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'tables.use', + }) + const principal = { kind: 'session', userId: 'person', sessionId: 'session' } as const + await withPermissionGroupScope(async () => { + await authorizeWorkspaceOperation(principal, operation, context) + mocks.workspaceConfig.mockResolvedValue({ + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideTablesTab: true }, + }) + await expect( + authorizeWorkspaceOperation(principal, operation, context, { + executor: db, + forUpdate: true, + }) + ).rejects.toMatchObject({ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + }) + expect(mocks.workspaceConfig).toHaveBeenLastCalledWith('person', 'workspace', 'org', db) + }) + + it('reads an organization operation capability on the same executor as membership', async () => { + const operation = defineOrganizationOperation({ + id: 'test.knowledge', + minimumRole: 'member', + principalKinds: ['session'], + capability: 'knowledge.use', + }) + const principal = { kind: 'session', userId: 'person', sessionId: 'session' } as const + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, [{ role: 'admin' }]) + await authorizeOrganizationOperation(principal, operation, { organizationId: 'org' }) + mocks.organizationConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + await expect( + authorizeOrganizationOperation( + principal, + operation, + { organizationId: 'org' }, + { executor: db, forUpdate: true } + ) + ).rejects.toMatchObject({ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + expect(mocks.organizationConfig).toHaveBeenLastCalledWith('org', db) + }) +}) diff --git a/apps/sim/lib/core/application/organization-authorization.test.ts b/apps/sim/lib/core/application/organization-authorization.test.ts index b89d6e0755c..840be1d9314 100644 --- a/apps/sim/lib/core/application/organization-authorization.test.ts +++ b/apps/sim/lib/core/application/organization-authorization.test.ts @@ -68,7 +68,11 @@ describe('organization operation authorization', () => { query.from.mockReturnValue(query) query.where.mockReturnValue(query) query.for.mockReturnValue(query) - const executor = { select: vi.fn().mockReturnValue(query) } + const select = vi.fn().mockReturnValue(query) + const executor = new Proxy(db, { + get: (target, key, receiver) => + key === 'select' ? select : Reflect.get(target, key, receiver), + }) const review = defineOrganizationOperation({ id: 'access_requests.resolve', minimumRole: 'admin', diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index 45ca8eb2557..30b115b5096 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -17,6 +17,7 @@ import type { OperationDeclarableCapability } from '@/lib/core/application/opera import type { OrganizationOperation } from '@/lib/core/application/organization-operation' import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' import { refuseCapability } from '@/lib/permission-groups/capabilities' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' @@ -37,7 +38,7 @@ export interface OrganizationMembershipContext extends OrganizationAuthorization } export interface OrganizationAuthorizationOptions { - executor?: Pick + executor?: DbOrTx forUpdate?: boolean } @@ -86,7 +87,9 @@ async function requireOrganizationSubjectMembership( const config = capability === 'none' && !userCredential ? null - : await getUserPermissionConfigForOrganization(organizationId) + : options.executor + ? await getUserPermissionConfigForOrganization(organizationId, options.executor) + : await getUserPermissionConfigForOrganization(organizationId) if (userCredential && capabilityDeniedBy('personal_api_key.use', config)) refuseCapability('personal_api_key.use') if (userCredential?.kind === 'oauth_access_token') { diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 42f76c186bd..c3884911ccb 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -5,7 +5,6 @@ import { type Principal, resolvePrincipalSubject, } from '@sim/auth/principal' -import type { db } from '@sim/db' import { type PermissionType, permissionSatisfies, @@ -19,6 +18,7 @@ import type { WorkspaceOperation, } from '@/lib/core/application/workspace-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' import { assertWorkspaceCapability, capabilityDeniedBy, @@ -76,7 +76,7 @@ export interface WorkspaceDelegationPolicy { - executor?: Pick + executor?: DbOrTx forUpdate?: boolean delegation?: WorkspaceDelegationPolicy } @@ -208,7 +208,8 @@ function requirePermission(permission: PermissionType | null, required: Permissi async function requireCapability( userId: string, context: WorkspaceAuthorizationContext, - operation: WorkspaceOperation + operation: WorkspaceOperation, + executor?: DbOrTx ): Promise { const capability = operation.capability if (capability === 'none') return @@ -218,7 +219,8 @@ async function requireCapability( userId, context.workspaceId, capability, - context.workspaceOrganizationId + context.workspaceOrganizationId, + executor ) } @@ -230,9 +232,8 @@ async function requireCapability( * authorization skip the consent endpoint, and a refresh token keeps minting * access tokens for a month. Withdrawing the capability has to stop the * credential in use, not only the next fresh grant, so it is asked again here, - * on the request. The group config is request-cached and the personal-key check - * that runs just before this one has already read it, so it costs no extra - * query. + * on the request. Without an explicit executor, the personal-key check that runs + * just before this one has already cached the group config, so it costs no extra query. * * Three surfaces authorize themselves instead of entering through the funnel. * Billing and audit-log reads repeat this check at their own call sites, since @@ -243,7 +244,8 @@ async function requireCapability( export async function requireCliAccessAllowed( clientId: string, userId: string, - context: WorkspaceAuthorizationContext + context: WorkspaceAuthorizationContext, + executor?: DbOrTx ): Promise { if (clientId !== SIM_CLI_CLIENT_ID) return if (context.workspaceOrganizationId === null) return @@ -252,7 +254,8 @@ export async function requireCliAccessAllowed( userId, context.workspaceId, 'cli.use', - context.workspaceOrganizationId + context.workspaceOrganizationId, + executor ) } @@ -262,9 +265,10 @@ export async function requireCliAccessAllowed( */ export async function requireUserCredentialCapabilities( principal: PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal, - context: WorkspaceAuthorizationContext + context: WorkspaceAuthorizationContext, + executor?: DbOrTx ): Promise { - await requirePersonalApiKeysAllowed(principal.userId, context) + await requirePersonalApiKeysAllowed(principal.userId, context, executor) if (principal.kind === 'oauth_access_token') { /** permission-group-enforced: oauth_apps.use — applies to every OAuth principal after the role check. */ if (context.workspaceOrganizationId !== null) { @@ -272,24 +276,33 @@ export async function requireUserCredentialCapabilities( principal.userId, context.workspaceId, 'oauth_apps.use', - context.workspaceOrganizationId + context.workspaceOrganizationId, + executor ) } - await requireCliAccessAllowed(principal.clientId, principal.userId, context) + await requireCliAccessAllowed(principal.clientId, principal.userId, context, executor) } } export async function requirePersonalApiKeysAllowed( userId: string, - context: WorkspaceAuthorizationContext + context: WorkspaceAuthorizationContext, + executor?: DbOrTx ): Promise { if (context.workspaceOrganizationId === null) return - const config = await resolvePermissionGroupConfig( - userId, - context.workspaceId, - context.workspaceOrganizationId - ) + const config = executor + ? await resolvePermissionGroupConfig( + userId, + context.workspaceId, + context.workspaceOrganizationId, + executor + ) + : await resolvePermissionGroupConfig( + userId, + context.workspaceId, + context.workspaceOrganizationId + ) if (capabilityDeniedBy('personal_api_key.use', config)) throw new PersonalApiKeysDisabledError() } @@ -334,7 +347,7 @@ async function requireCurrentHumanAccess ): Promise { await requireCurrentHumanRole(userId, context, operation.minimumRole, options) - await requireCapability(userId, context, operation) + await requireCapability(userId, context, operation, options?.executor) } export async function authorizeWorkspaceOperation( @@ -378,8 +391,8 @@ export async function authorizeWorkspaceOperation ({ workspaceContext: vi.fn(), orgSend: vi.fn(), workspaceSend: vi.fn(), + canonicalWorkspace: vi.fn(), + workspaceRole: vi.fn(), + config: vi.fn(), + invitePolicy: vi.fn(), +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.canonicalWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({ + ...(await importOriginal()), + resolveEffectiveWorkspacePermission: mocks.workspaceRole, +})) +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.config, +})) +vi.mock('@/lib/workspaces/policy', () => ({ + getWorkspaceInvitePolicy: mocks.invitePolicy, })) vi.mock('@/lib/invitations/organization-invitations', () => ({ prepareOrganizationInvitationContext: mocks.orgContext, @@ -36,10 +54,39 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ InvitationsNotAllowedError: class extends Error {}, })) +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' import { sendInvitationBatch } from '@/lib/invitations/application/send-invitation-batch' -import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' +import { + type createWorkspaceInvitation, + WorkspaceInvitationError, +} from '@/lib/invitations/workspace-invitations' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const principal = { kind: 'session', userId: 'admin-user', sessionId: 'session' } as const +const personal = { kind: 'personal_api_key', userId: 'admin-user', keyId: 'key' } as const +const oauth = { + kind: 'oauth_access_token', + userId: 'admin-user', + clientId: 'client', + tokenId: 'token', + scopes: ['api:read', 'api:write'], + expiresAt: new Date('2099-01-01'), +} as const +const canonicalWorkspace = { + workspaceId: 'workspace', + workspaceOrganizationId: 'org-target', + allowPersonalApiKeys: true, + billedAccountUserId: 'other-user', +} +const lockedWorkspace = { + id: 'workspace', + name: 'Workspace', + ownerId: 'other-user', + organizationId: 'org-target', + workspaceMode: 'organization' as const, + allowPersonalApiKeys: true, + billedAccountUserId: 'other-user', +} const orgInput = { workspaceIds: [], organizationId: 'org-target', @@ -65,6 +112,16 @@ beforeEach(() => { }) mocks.orgSend.mockResolvedValue(invitation) mocks.workspaceSend.mockResolvedValue({ ...invitation, workspaceIds: ['workspace'] }) + mocks.canonicalWorkspace.mockResolvedValue(canonicalWorkspace) + mocks.workspaceRole.mockResolvedValue('admin') + mocks.config.mockResolvedValue(null) + mocks.invitePolicy.mockResolvedValue({ + allowed: true, + requiresSeat: false, + reason: null, + organizationId: 'org-target', + upgradeRequired: false, + }) }) afterEach(resetDbChainMock) @@ -92,7 +149,7 @@ describe('invitation batch application boundary', () => { principal: { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, input: orgInput, }) - ).rejects.toThrow('principal kind workspace_api_key') + ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(mocks.orgContext).not.toHaveBeenCalled() }) @@ -127,6 +184,142 @@ describe('invitation batch application boundary', () => { expect.objectContaining({ permission: 'write' }) ) expect(mocks.orgSend).not.toHaveBeenCalled() + expect(mocks.canonicalWorkspace).not.toHaveBeenCalled() + }) + + it.each([personal, oauth])( + 'authorizes the actual $kind caller and carries audit identity', + async (principal) => { + await sendInvitationBatch.execute({ + principal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + expect(mocks.workspaceRole).toHaveBeenCalledWith( + 'admin-user', + 'workspace', + 'org-target', + db, + { forUpdate: undefined } + ) + expect(mocks.config).toHaveBeenCalledWith('admin-user', 'workspace', 'org-target', db) + expect(mocks.workspaceContext).toHaveBeenCalledWith( + expect.objectContaining({ + inviterId: 'admin-user', + auditActor: expect.objectContaining({ + id: 'admin-user', + metadata: { + actor: expect.objectContaining({ kind: principal.kind, userId: 'admin-user' }), + operation: 'invitations.send_batch', + }, + }), + }) + ) + } + ) + + it('rejects read-only OAuth before any protected lookup', async () => { + await expect( + sendInvitationBatch.execute({ + principal: { ...oauth, scopes: ['api:read'] }, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + ).rejects.toMatchObject({ detailCode: 'INSUFFICIENT_SCOPE' }) + expect(mocks.canonicalWorkspace).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it.each([null, 'write', 'read'])( + 'refuses workspace role %s before invitation preparation', + async (role) => { + mocks.workspaceRole.mockResolvedValue(role) + await expect( + sendInvitationBatch.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + ).rejects.toThrow() + expect(mocks.workspaceContext).not.toHaveBeenCalled() + expect(mocks.workspaceSend).not.toHaveBeenCalled() + } + ) + + it.each([ + [personal, { disablePersonalApiKeys: true }], + [oauth, { disableOAuthAppAccess: true }], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], + ] as const)( + 'enforces credential policy before preparing a workspace invitation', + async (principal, restriction) => { + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, ...restriction }) + await expect( + sendInvitationBatch.execute({ + principal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + ).rejects.toThrow() + expect(mocks.workspaceContext).not.toHaveBeenCalled() + expect(mocks.workspaceSend).not.toHaveBeenCalled() + } + ) + + it('refuses a workspace that disabled personal keys before invitation preparation', async () => { + mocks.canonicalWorkspace.mockResolvedValue({ + ...canonicalWorkspace, + allowPersonalApiKeys: false, + }) + await expect( + sendInvitationBatch.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + ).rejects.toMatchObject({ detailCode: 'PERSONAL_API_KEYS_DISABLED' }) + expect(mocks.workspaceContext).not.toHaveBeenCalled() + }) + + it('preserves the first recipient when workspace credential policy changes before the next', async () => { + mocks.workspaceSend.mockImplementationOnce(async () => { + mocks.canonicalWorkspace.mockResolvedValue({ + ...canonicalWorkspace, + allowPersonalApiKeys: false, + }) + return { ...invitation, workspaceIds: ['workspace'] } + }) + const result = await sendInvitationBatch.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com', 'other@example.com'] }, + }) + expect(result).toMatchObject({ + success: false, + successful: ['person@example.com'], + failed: [ + { + email: 'other@example.com', + error: 'Personal API keys are not allowed for this workspace', + }, + ], + }) + expect(mocks.workspaceSend).toHaveBeenCalledTimes(1) + }) + + it('stops later recipients when permission-group invitation access is withdrawn', async () => { + mocks.workspaceSend.mockImplementationOnce(async () => { + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableInvitations: true, + }) + return { ...invitation, workspaceIds: ['workspace'] } + }) + const result = await sendInvitationBatch.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com', 'other@example.com'] }, + }) + expect(result).toMatchObject({ + success: false, + successful: ['person@example.com'], + failed: [{ email: 'other@example.com', error: expect.stringMatching(/invitation/i) }], + }) + expect(mocks.workspaceSend).toHaveBeenCalledTimes(1) + expect(mocks.config).toHaveBeenLastCalledWith('admin-user', 'workspace', 'org-target', db) }) it('rejects mismatched asserted org scope after canonical workspace authorization', async () => { @@ -140,6 +333,116 @@ describe('invitation batch application boundary', () => { expect(mocks.workspaceSend).not.toHaveBeenCalled() }) + it.each([ + [personal, { disableInvitations: true }], + [oauth, { disableInvitations: true }], + [personal, { disablePersonalApiKeys: true }], + [oauth, { disableOAuthAppAccess: true }], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], + ] as const)( + 'rechecks $0.kind admission using the locked transaction after allowed preflight', + async (principal, restriction) => { + const tx = new Proxy(db, {}) + const write = vi.fn() + mocks.workspaceSend.mockImplementationOnce( + async (input: Parameters[0]) => { + mocks.config.mockImplementation(async (_user, _workspace, _organization, executor) => + executor === tx ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...restriction } : null + ) + expect(input.validateLockedWorkspace).toBeTypeOf('function') + await input.validateLockedWorkspace?.(tx, lockedWorkspace) + write() + return invitation + } + ) + const result = await sendInvitationBatch.execute({ + principal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + expect(result).toMatchObject({ + success: false, + successful: [], + added: [], + invitations: [], + failed: [{ email: 'person@example.com', error: expect.any(String) }], + }) + expect(write).not.toHaveBeenCalled() + expect(mocks.workspaceRole).toHaveBeenLastCalledWith( + 'admin-user', + 'workspace', + 'org-target', + tx, + { forUpdate: true } + ) + expect(mocks.config).toHaveBeenLastCalledWith('admin-user', 'workspace', 'org-target', tx) + } + ) + + it.each([ + [{ ...lockedWorkspace, allowPersonalApiKeys: false }, 'Personal API keys are not allowed'], + [{ ...lockedWorkspace, organizationId: 'moved-org' }, 'changed organizations'], + ])( + 'uses the locked workspace policy and original organization scope', + async (workspace, error) => { + const write = vi.fn() + mocks.workspaceSend.mockImplementationOnce( + async (input: Parameters[0]) => { + expect(input.validateLockedWorkspace).toBeTypeOf('function') + await input.validateLockedWorkspace?.(db, workspace) + write() + return invitation + } + ) + const result = await sendInvitationBatch.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + expect(result.success).toBe(false) + expect(result.failed[0].error).toContain(error) + expect(write).not.toHaveBeenCalled() + } + ) + + it('leaves session invitation admission on the existing path', async () => { + await sendInvitationBatch.execute({ + principal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + expect(mocks.workspaceSend).toHaveBeenCalledWith( + expect.objectContaining({ validateLockedWorkspace: undefined }) + ) + }) + + it('refuses a plan disabled after preflight using the locked billing context', async () => { + const tx = new Proxy(db, {}) + const write = vi.fn() + mocks.invitePolicy.mockResolvedValueOnce({ + allowed: false, + requiresSeat: false, + reason: 'Upgrade to invite teammates', + organizationId: 'org-target', + upgradeRequired: true, + }) + mocks.workspaceSend.mockImplementationOnce( + async (input: Parameters[0]) => { + expect(input.validateLockedWorkspace).toBeTypeOf('function') + await input.validateLockedWorkspace?.(tx, lockedWorkspace) + write() + return invitation + } + ) + const result = await sendInvitationBatch.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + expect(result).toMatchObject({ + success: false, + failed: [{ email: 'person@example.com', error: 'Upgrade to invite teammates' }], + }) + expect(mocks.invitePolicy).toHaveBeenCalledExactlyOnceWith(lockedWorkspace, tx) + expect(write).not.toHaveBeenCalled() + }) + it('preserves earlier successes and reports later failures without leaking infrastructure details', async () => { mocks.orgSend .mockResolvedValueOnce(invitation) @@ -157,6 +460,26 @@ describe('invitation batch application boundary', () => { }) }) + it.each([ + new WorkspaceInvitationError({ message: 'Private delivery provider failure', status: 502 }), + new Error('Private persistence failure after a grant'), + ])( + 'does not expose public delivery infrastructure details or advise a blind retry', + async (error) => { + mocks.workspaceSend.mockRejectedValue(error) + const result = await sendInvitationBatch.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + expect(result.success).toBe(false) + expect(result.failed[0].error).toMatch( + 'Check workspace members and invitations before retrying.' + ) + expect(result.failed[0].error).not.toContain('Private') + expect(result.invitations).toEqual([]) + } + ) + it('deduplicates normalized addresses and preserves actionable per-email refusals', async () => { mocks.orgSend.mockRejectedValue( new WorkspaceInvitationError({ diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.ts b/apps/sim/lib/invitations/application/send-invitation-batch.ts index 2fe22dc217e..3492c18ac54 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal, toPrincipalActor } from '@sim/auth/principal' import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -8,7 +9,16 @@ import { ForbiddenOperationError, type OperationUseCase, } from '@/lib/core/application' -import { invitationOperations } from '@/lib/invitations/application/operations' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, +} from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + invitationAuthorityOperations, + invitationOperations, +} from '@/lib/invitations/application/operations' import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' import { prepareOrganizationInvitationContext } from '@/lib/invitations/organization-invitations' import { @@ -19,6 +29,9 @@ import { type WorkspaceInvitationResult, } from '@/lib/invitations/workspace-invitations' import { createOrganizationInvitation } from '@/lib/organizations/application/invitations' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' const logger = createLogger('InvitationBatch') @@ -52,6 +65,7 @@ export const sendInvitationBatch: OperationUseCase< > = { operation: invitationOperations.sendBatch, async execute({ principal, input, request }) { + requireAllowedWorkspacePrincipal(principal, invitationAuthorityOperations.workspace) assertOperationPrincipal(principal, invitationOperations.sendBatch) if ( input.emails.length === 0 || @@ -67,6 +81,36 @@ export const sendInvitationBatch: OperationUseCase< status: 400, }) } + const authorizeCredential = async () => { + if (!isUserCredentialPrincipal(principal)) return + if (organizationOnly && input.organizationId) { + await authorizeOrganizationOperation( + principal, + invitationAuthorityOperations.organization, + { + organizationId: input.organizationId, + }, + { executor: db } + ) + } + for (const workspaceId of new Set(input.workspaceIds)) { + const context = await resolveActiveWorkspaceApplicationContext(workspaceId) + await authorizeWorkspaceOperation( + principal, + invitationAuthorityOperations.workspace, + context, + { executor: db } + ) + await assertWorkspaceCapability( + principal.userId, + workspaceId, + 'invitations.send', + context.workspaceOrganizationId, + db + ) + } + } + await authorizeCredential() const [inviter] = await db .select({ name: user.name, email: user.email }) .from(user) @@ -78,6 +122,19 @@ export const sendInvitationBatch: OperationUseCase< inviterId: principal.userId, inviterName: inviter.name || inviter.email || 'A user', inviterEmail: inviter.email, + ...(isUserCredentialPrincipal(principal) + ? { + auditActor: { + id: principal.userId, + name: inviter.name || inviter.email || 'A user', + email: inviter.email, + metadata: { + actor: toPrincipalActor(principal), + operation: invitationOperations.sendBatch.id, + }, + }, + } + : {}), } const organizationContext = organizationOnly && input.organizationId @@ -88,7 +145,24 @@ export const sendInvitationBatch: OperationUseCase< : null const workspaceContext = organizationOnly ? null - : await prepareWorkspaceInvitationContext({ ...identity, workspaceIds: input.workspaceIds }) + : await prepareWorkspaceInvitationContext({ + ...identity, + workspaceIds: input.workspaceIds, + }).catch((error: unknown) => { + if (isUserCredentialPrincipal(principal) && error instanceof WorkspaceInvitationError) { + if (error.status === 403) { + throw new ForbiddenOperationError( + error.upgradeRequired + ? 'ORGANIZATION_PLAN_REQUIRED' + : 'INSUFFICIENT_WORKSPACE_ROLE', + error.message + ) + } + if (error.status === 404) + throw new OrchestrationError('not_found', 'Workspace not found') + } + throw error + }) if ( workspaceContext && input.organizationId && @@ -118,6 +192,7 @@ export const sendInvitationBatch: OperationUseCase< } seenEmails.add(normalizedEmail) try { + await authorizeCredential() const invitation = organizationContext ? await createOrganizationInvitation.execute({ principal, @@ -135,6 +210,43 @@ export const sendInvitationBatch: OperationUseCase< permission: input.permission, membership: input.membership, request, + validateLockedWorkspace: isUserCredentialPrincipal(principal) + ? async (tx, workspace) => { + if (workspace.organizationId !== workspaceContext.organizationId) { + throw new WorkspaceInvitationError({ + message: + 'A selected workspace changed organizations. Review the selection and try again.', + status: 409, + }) + } + await authorizeWorkspaceOperation( + principal, + invitationAuthorityOperations.workspace, + { + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }, + { executor: tx, forUpdate: true } + ) + await assertWorkspaceCapability( + principal.userId, + workspace.id, + 'invitations.send', + workspace.organizationId, + tx + ) + const policy = await getWorkspaceInvitePolicy(workspace, tx) + if (!policy.allowed) { + throw new WorkspaceInvitationError({ + message: policy.reason ?? 'Invites are disabled for this workspace.', + status: 403, + upgradeRequired: policy.upgradeRequired, + }) + } + return policy + } + : undefined, }) : null if (!invitation) throw new Error('Invitation batch has no authorized context') @@ -155,13 +267,20 @@ export const sendInvitationBatch: OperationUseCase< error instanceof WorkspaceInvitationError ? (error.email ?? normalizedEmail) : normalizedEmail, - error: error.message, + error: + isUserCredentialPrincipal(principal) && + error instanceof WorkspaceInvitationError && + error.status >= 500 + ? 'Unable to confirm invitation delivery. Check workspace members and invitations before retrying.' + : error.message, }) } else { logger.error('Invitation batch item failed', { email: normalizedEmail, error }) result.failed.push({ email: normalizedEmail, - error: 'Failed to create invitation. Please try again.', + error: isUserCredentialPrincipal(principal) + ? 'Unable to confirm the invitation outcome. Check workspace members and invitations before retrying.' + : 'Failed to create invitation. Please try again.', }) } } diff --git a/apps/sim/lib/invitations/direct-grant.test.ts b/apps/sim/lib/invitations/direct-grant.test.ts index e66b9f1a590..cf225e72b66 100644 --- a/apps/sim/lib/invitations/direct-grant.test.ts +++ b/apps/sim/lib/invitations/direct-grant.test.ts @@ -10,6 +10,7 @@ import { resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' const { mockAcquireInvitationMutationLocks, @@ -177,6 +178,35 @@ describe('grantWorkspaceAccessDirectly', () => { }) }) + it('rechecks application admission after locks and before any grant or side effect', async () => { + const refusal = new ForbiddenOperationError('PERMISSION_DENIED', 'Invitations disabled') + const validateLockedWorkspace = vi.fn(async () => { + throw refusal + }) + await expect( + grantWorkspaceAccessDirectly({ + ...baseInput, + validateLockedWorkspace, + }) + ).rejects.toBe(refusal) + expect(validateLockedWorkspace).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + expect.objectContaining({ id: 'ws-1', organizationId: 'org-1' }) + ) + expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + validateLockedWorkspace.mock.invocationCallOrder[0] + ) + expect(mockGetEffectiveWorkspacePermission.mock.invocationCallOrder[0]).toBeLessThan( + validateLockedWorkspace.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockRevokeInvitationWorkspaceGrantTx).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + it('retries provider-declined notification delivery instead of dropping it', async () => { mockSendWorkspaceAddedEmail.mockResolvedValueOnce({ success: false, @@ -252,6 +282,25 @@ describe('grantWorkspaceAccessDirectly', () => { expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled() }) + it('retains public credential identity on the existing direct-grant audit', async () => { + const metadata = { + actor: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + operation: 'invitations.send_batch', + } as const + await grantWorkspaceAccessDirectly({ + ...baseInput, + auditActor: { id: 'user-1', name: 'Owner', email: 'owner@example.com', metadata }, + }) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + actorId: 'user-1', + action: 'member.added', + metadata: expect.objectContaining(metadata), + }) + ) + expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) + }) + it('does not upgrade an existing lower permission (invites never modify access)', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'read' }]) diff --git a/apps/sim/lib/invitations/direct-grant.ts b/apps/sim/lib/invitations/direct-grant.ts index e19463c2b2a..e23f27da23d 100644 --- a/apps/sim/lib/invitations/direct-grant.ts +++ b/apps/sim/lib/invitations/direct-grant.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit' +import type { PrincipalActor } from '@sim/auth/principal' import { db } from '@sim/db' import { foldedEmail, @@ -35,7 +36,9 @@ import { getEffectiveWorkspacePermission, getWorkspaceWithOwner, type PermissionType, + type WorkspaceWithOwner, } from '@/lib/workspaces/permissions/utils' +import type { WorkspaceInvitePolicy } from '@/lib/workspaces/policy' import { assertMembershipNotScimManaged } from '@/ee/scim/lib/managed-membership' const logger = createLogger('InvitationDirectGrant') @@ -73,7 +76,12 @@ export interface GrantWorkspaceAccessDirectlyInput { actorName: string actorEmail?: string | null /** Audit attribution may differ from the authorized product actor for admin tooling. */ - auditActor?: { id: string | null; name: string; email: string | null } + auditActor?: { + id: string | null + name: string + email: string | null + metadata?: { actor: PrincipalActor; operation: string } + } request?: OrchestrationRequestContext /** Send the lightweight "you've been added" email. Defaults to true. */ notify?: boolean @@ -83,6 +91,11 @@ export interface GrantWorkspaceAccessDirectlyInput { sourceOperationId?: string /** Makes the semantic audit recoverable when the caller itself is durable. */ auditOperationId?: string + /** Revalidates application admission and returns the current policy under canonical locks. */ + validateLockedWorkspace?: ( + tx: DbOrTx, + workspace: WorkspaceWithOwner + ) => Promise } async function getPendingWorkspaceInvitationIds( @@ -199,6 +212,8 @@ export async function grantWorkspaceAccessDirectly( throw new DirectGrantContextChangedError() } + await input.validateLockedWorkspace?.(tx, workspaceRow) + const [existing] = await tx .select({ id: permissions.id, permissionType: permissions.permissionType }) .from(permissions) @@ -354,6 +369,7 @@ export async function grantWorkspaceAccessDirectly( ? `Changed ${normalizedEmail} from ${result.previousPermission} to ${result.permission}` : `Added existing organization member ${normalizedEmail} as ${input.permission}`, metadata: { + ...input.auditActor?.metadata, targetEmail: normalizedEmail, targetRole: input.permission, organizationId: input.organizationId, diff --git a/apps/sim/lib/invitations/workspace-invitations.test.ts b/apps/sim/lib/invitations/workspace-invitations.test.ts index afe9de882cc..d3eaa085368 100644 --- a/apps/sim/lib/invitations/workspace-invitations.test.ts +++ b/apps/sim/lib/invitations/workspace-invitations.test.ts @@ -1,9 +1,11 @@ /** * @vitest-environment node */ +import { db } from '@sim/db' import { member, user as userTable } from '@sim/db/schema' import { auditMock, + auditMockFns, createMockRequest, dbChainMock, dbChainMockFns, @@ -13,12 +15,15 @@ import { setEnvFlags, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import type { DbOrTx } from '@/lib/db/types' +import type { GrantWorkspaceAccessDirectlyInput } from '@/lib/invitations/direct-grant' import type { CreatePendingInvitationInput } from '@/lib/invitations/send' const { MockConflictingPendingInvitationError, MockDirectGrantContextChangedError, + mockAcquireInvitationMutationLocks, mockAcquireOrganizationMutationLock, mockAcquireOrganizationUserMutationLocks, mockGetUserOrganization, @@ -39,6 +44,7 @@ const { } = vi.hoisted(() => ({ MockConflictingPendingInvitationError: class extends Error {}, MockDirectGrantContextChangedError: class extends Error {}, + mockAcquireInvitationMutationLocks: vi.fn(), mockAcquireOrganizationMutationLock: vi.fn(), mockAcquireOrganizationUserMutationLocks: vi.fn(), mockGetUserOrganization: vi.fn(), @@ -60,6 +66,10 @@ const { vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/invitations/locks', () => ({ + acquireInvitationMutationLocks: mockAcquireInvitationMutationLocks, +})) + vi.mock('@/lib/billing/organizations/membership', () => ({ acquireOrganizationMutationLock: mockAcquireOrganizationMutationLock, acquireOrganizationUserMutationLocks: mockAcquireOrganizationUserMutationLocks, @@ -179,6 +189,7 @@ describe('createWorkspaceInvitation', () => { /** Production default; the billing-disabled case opts out explicitly. */ setEnvFlags({ isBillingEnabled: true }) mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) + mockAcquireInvitationMutationLocks.mockResolvedValue(undefined) mockAcquireOrganizationMutationLock.mockResolvedValue(undefined) mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) mockGetWorkspaceWithOwner.mockResolvedValue({ @@ -366,6 +377,146 @@ describe('createWorkspaceInvitation', () => { ) }) + it('records OAuth actor metadata once after successful invitation delivery', async () => { + queueWhereResponses([[]]) + const metadata = { + actor: { + kind: 'oauth_access_token', + userId: 'user-1', + tokenId: 'token-1', + clientId: 'client-1', + }, + operation: 'invitations.send_batch', + } as const + await createWorkspaceInvitation({ + context: { + ...makeContext(), + auditActor: { id: 'user-1', name: 'Owner', email: 'owner@example.com', metadata }, + }, + email: 'new@example.com', + }) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + actorId: 'user-1', + action: 'member.invited', + metadata: expect.objectContaining(metadata), + }) + ) + expect(mockSendInvitationEmail.mock.invocationCallOrder[0]).toBeLessThan( + auditMockFns.mockRecordAudit.mock.invocationCallOrder[0] + ) + }) + + it('runs application admission under the organization lock before creating an invitation', async () => { + queueTableRows(userTable, []) + const refusal = new ForbiddenOperationError('PERMISSION_DENIED', 'Invitations disabled') + const validateLockedWorkspace = vi.fn(async () => { + throw refusal + }) + const write = vi.fn() + mockCreatePendingInvitation.mockImplementationOnce( + async (input: CreatePendingInvitationInput) => { + await db.transaction(async (tx) => { + expect(input.validateLockedContext).toBeTypeOf('function') + await input.validateLockedContext?.({ + tx, + organizationId: 'org-1', + workspaceIds: ['ws-1'], + }) + write() + }) + } + ) + await expect( + createWorkspaceInvitation({ + context: makeContext(), + email: 'new@example.com', + validateLockedWorkspace, + }) + ).rejects.toBe(refusal) + expect(validateLockedWorkspace).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + expect.objectContaining({ id: 'ws-1', organizationId: 'org-1' }) + ) + expect(mockAcquireOrganizationMutationLock.mock.invocationCallOrder[0]).toBeLessThan( + validateLockedWorkspace.mock.invocationCallOrder[0] + ) + expect(mockGetEffectiveWorkspacePermission.mock.invocationCallOrder[0]).toBeLessThan( + validateLockedWorkspace.mock.invocationCallOrder[0] + ) + expect(write).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('forwards locked admission into direct grants for existing organization members', async () => { + queueTableRows(userTable, [{ id: 'user-2' }]) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'member' }) + const refusal = new ForbiddenOperationError('PERMISSION_DENIED', 'Invitations disabled') + const validateLockedWorkspace = vi.fn(async () => { + throw refusal + }) + mockGrantWorkspaceAccessDirectly.mockImplementationOnce( + async (input: GrantWorkspaceAccessDirectlyInput) => { + expect(input.validateLockedWorkspace).toBe(validateLockedWorkspace) + await input.validateLockedWorkspace?.(db, makeContext().targets[0].workspaceDetails) + throw new Error('unreachable') + } + ) + await expect( + createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + validateLockedWorkspace, + }) + ).rejects.toBe(refusal) + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) + + it('rechecks reconciliation admission after invitation and organization locks before promotion', async () => { + queueTableRows(userTable, [{ id: 'user-2' }]) + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(member, [{ role: 'member' }]) + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + memberId: 'member-2', + role: 'member', + }) + const refusal = new ForbiddenOperationError('PERMISSION_DENIED', 'Invitations disabled') + const validateLockedWorkspace = vi.fn(async () => { + throw refusal + }) + await expect( + createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + membership: 'admin', + existingAccessPolicy: 'ensure-at-least', + validateLockedWorkspace, + }) + ).rejects.toBe(refusal) + expect(mockAcquireInvitationMutationLocks).toHaveBeenCalledWith(expect.anything(), { + invitationIds: [], + workspaceIds: ['ws-1'], + }) + expect(mockAcquireInvitationMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0] + ) + expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + mockGetWorkspaceWithOwner.mock.invocationCallOrder[0] + ) + expect(mockGetWorkspaceWithOwner.mock.invocationCallOrder[0]).toBeLessThan( + validateLockedWorkspace.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + }) + it('stamps an admin organization role when an org admin picks Admin membership', async () => { mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) queueWhereResponses([[]]) @@ -624,6 +775,57 @@ describe('createWorkspaceInvitation', () => { ) }) + it.each([false, true])( + 'uses live seat policy when preflight requiresSeat was %s', + async (preflightRequiresSeat) => { + queueTableRows(userTable, []) + const context = makeContext() + context.targets[0].invitePolicy.requiresSeat = preflightRequiresSeat + const validateLockedWorkspace = vi.fn(async () => ({ + ...context.targets[0].invitePolicy, + requiresSeat: !preflightRequiresSeat, + })) + const write = vi.fn() + mockValidateSeatAvailability.mockResolvedValueOnce({ + canInvite: false, + reason: 'No available seats.', + }) + mockCreatePendingInvitation.mockImplementationOnce( + async (input: CreatePendingInvitationInput) => { + await db.transaction(async (tx) => { + await input.validateLockedContext?.({ + tx, + organizationId: 'org-1', + workspaceIds: ['ws-1'], + }) + write() + }) + return { + invitationId: 'inv-1', + token: 'tok-1', + grants: [{ workspaceId: 'ws-1', permission: 'read' }], + } + } + ) + const result = createWorkspaceInvitation({ + context, + email: 'new@example.com', + validateLockedWorkspace, + }) + if (preflightRequiresSeat) { + await expect(result).resolves.toMatchObject({ id: 'inv-1' }) + expect(mockValidateSeatAvailability).not.toHaveBeenCalled() + expect(write).toHaveBeenCalledTimes(1) + } else { + await expect(result).rejects.toMatchObject({ message: 'No available seats.', status: 400 }) + expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1, { executor: db }) + expect(write).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + } + } + ) + it('rejects when every selected workspace is already invited', async () => { queueWhereResponses([[]]) mockFindPendingGrantWorkspaceIds.mockResolvedValueOnce(new Set(['ws-1', 'ws-2'])) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index 37b46194cc2..0cf92749162 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -24,8 +24,10 @@ import type { DbOrTx } from '@/lib/db/types' import { DirectGrantContextChangedError, type DirectGrantOutcome, + type GrantWorkspaceAccessDirectlyInput, grantWorkspaceAccessDirectly, } from '@/lib/invitations/direct-grant' +import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { ConflictingPendingInvitationError, cancelPendingInvitation, @@ -76,8 +78,8 @@ export interface WorkspaceInvitationContext { targets: WorkspaceInvitationTarget[] /** The organization all targets belong to, or null for a personal workspace. */ organizationId: string | null - /** The platform admin to attribute audit entries to; inviter still authorizes product access. */ - auditActor?: { id: string | null; name: string; email: string | null } + /** Audit attribution is separate from the inviter used to authorize product access. */ + auditActor?: GrantWorkspaceAccessDirectlyInput['auditActor'] } export interface WorkspaceInvitationResult { @@ -126,6 +128,7 @@ async function ensureExistingMemberOrganizationRole({ requestedRole, email, request, + validateLockedWorkspace, }: { context: WorkspaceInvitationContext organizationId: string @@ -135,12 +138,19 @@ async function ensureExistingMemberOrganizationRole({ requestedRole: 'admin' | 'member' email: string request?: OrchestrationRequestContext + validateLockedWorkspace?: GrantWorkspaceAccessDirectlyInput['validateLockedWorkspace'] }): Promise<{ role: string; updated: boolean }> { if (requestedRole !== 'admin' || isOrgAdminRole(currentRole)) { return { role: currentRole, updated: false } } const updated = await db.transaction(async (tx) => { + if (validateLockedWorkspace) { + await acquireInvitationMutationLocks(tx, { + invitationIds: [], + workspaceIds: context.targets.map((target) => target.workspaceId), + }) + } await acquireOrganizationUserMutationLocks(tx, { userId, organizationIds: [organizationId], @@ -171,6 +181,23 @@ async function ensureExistingMemberOrganizationRole({ }) } if (isOrgAdminRole(targetMembership.role)) return false + if (validateLockedWorkspace) { + for (const workspaceId of context.targets.map((target) => target.workspaceId).sort()) { + const workspaceDetails = await getWorkspaceWithOwner(workspaceId, { + executor: tx, + forUpdate: true, + }) + if (!workspaceDetails || workspaceDetails.organizationId !== organizationId) { + throw new WorkspaceInvitationError({ + message: + 'A selected workspace changed organizations. Review the selection and try again.', + status: 409, + email, + }) + } + await validateLockedWorkspace(tx, workspaceDetails) + } + } await tx.update(member).set({ role: 'admin' }).where(eq(member.id, memberId)) return true }) @@ -185,7 +212,13 @@ async function ensureExistingMemberOrganizationRole({ resourceId: organizationId, resourceName: email, description: `Promoted ${email} to organization admin during invitation reconciliation`, - metadata: { targetUserId: userId, memberId, previousRole: currentRole, newRole: 'admin' }, + metadata: { + ...context.auditActor?.metadata, + targetUserId: userId, + memberId, + previousRole: currentRole, + newRole: 'admin', + }, request, }) } @@ -208,7 +241,7 @@ export async function prepareWorkspaceInvitationContext({ inviterId: string inviterName: string inviterEmail?: string | null - auditActor?: { id: string | null; name: string; email: string | null } + auditActor?: WorkspaceInvitationContext['auditActor'] }): Promise { const uniqueWorkspaceIds = [...new Set(workspaceIds)] if (uniqueWorkspaceIds.length === 0) { @@ -297,8 +330,9 @@ async function validateLockedWorkspaceInvitationContext({ existingUserId, observedInviteeOrganizationId, requiresOrganizationAdmin, - requiresSeatReservation, + membershipIntent, inviteeEmail, + validateLockedWorkspace, }: { tx: DbOrTx context: WorkspaceInvitationContext @@ -307,8 +341,9 @@ async function validateLockedWorkspaceInvitationContext({ existingUserId?: string observedInviteeOrganizationId: string | null requiresOrganizationAdmin: boolean - requiresSeatReservation: boolean + membershipIntent: InvitationMembershipIntent inviteeEmail: string + validateLockedWorkspace?: GrantWorkspaceAccessDirectlyInput['validateLockedWorkspace'] }): Promise { /** * Sending already holds the invitation/workspace advisory locks. Take the @@ -340,6 +375,7 @@ async function validateLockedWorkspaceInvitationContext({ .for('update') } + let requiresSeat = validateLockedWorkspace ? false : context.targets[0].invitePolicy.requiresSeat for (const workspaceId of [...new Set(workspaceIds)].sort()) { const workspaceDetails = await getWorkspaceWithOwner(workspaceId, { executor: tx, @@ -377,6 +413,8 @@ async function validateLockedWorkspaceInvitationContext({ status: 409, }) } + const currentPolicy = await validateLockedWorkspace?.(tx, workspaceDetails) + if (currentPolicy?.requiresSeat) requiresSeat = true } if (requiresOrganizationAdmin) { @@ -395,7 +433,8 @@ async function validateLockedWorkspaceInvitationContext({ if ( organizationId && - requiresSeatReservation && + membershipIntent === 'internal' && + requiresSeat && !(await findPendingOrganizationInvitation(tx, organizationId, inviteeEmail)) ) { const seatValidation = await validateSeatAvailability(organizationId, 1, { executor: tx }) @@ -453,6 +492,7 @@ export async function createWorkspaceInvitation({ sourceOperationId, auditOperationId, request, + validateLockedWorkspace, }: { context: WorkspaceInvitationContext email: string @@ -473,6 +513,8 @@ export async function createWorkspaceInvitation({ /** Makes invitation/direct-grant audits idempotent for durable callers. */ auditOperationId?: string request?: OrchestrationRequestContext + /** Rechecks application admission and resolves live seat policy inside each write transaction. */ + validateLockedWorkspace?: GrantWorkspaceAccessDirectlyInput['validateLockedWorkspace'] }): Promise { const validPermissions: PermissionType[] = ['admin', 'write', 'read'] if (!validPermissions.includes(permission as PermissionType)) { @@ -527,6 +569,7 @@ export async function createWorkspaceInvitation({ requestedRole: membership === 'admin' ? 'admin' : 'member', email: normalizedEmail, request, + validateLockedWorkspace, }) existingOrganizationRole = ensuredRole.role organizationRoleUpdated = ensuredRole.updated @@ -612,6 +655,7 @@ export async function createWorkspaceInvitation({ existingPermissionPolicy: existingAccessPolicy, sourceOperationId, auditOperationId, + validateLockedWorkspace, }) } catch (error) { if (error instanceof DirectGrantContextChangedError) { @@ -741,9 +785,9 @@ export async function createWorkspaceInvitation({ existingUserId: existingUser?.id, observedInviteeOrganizationId: existingMembership?.organizationId ?? null, requiresOrganizationAdmin: membershipIntent === 'internal' && membership === 'admin', - requiresSeatReservation: - membershipIntent === 'internal' && context.targets[0].invitePolicy.requiresSeat, + membershipIntent, inviteeEmail: normalizedEmail, + validateLockedWorkspace, }), }) } catch (error) { @@ -850,6 +894,7 @@ export async function createWorkspaceInvitation({ resourceName: normalizedEmail, description: `Invited ${normalizedEmail} as ${invitationPermission}`, metadata: { + ...context.auditActor?.metadata, targetEmail: normalizedEmail, targetRole: invitationPermission, membershipIntent, diff --git a/apps/sim/lib/organizations/application/operations.ts b/apps/sim/lib/organizations/application/operations.ts index b4dc3dfd84d..bf4302700de 100644 --- a/apps/sim/lib/organizations/application/operations.ts +++ b/apps/sim/lib/organizations/application/operations.ts @@ -81,6 +81,16 @@ export const organizationOperations = { principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], oauthScope: 'api:read', }), + /** + * permission-group-exempt: administrators can inspect existing invitation grants when invitations are disabled. + */ + listInvitationWorkspaces: defineOrganizationOperation({ + id: 'organizations.invitations.workspaces.list', + minimumRole: 'admin', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + }), createInvitation: defineOrganizationOperation({ id: 'organizations.invitations.create', minimumRole: 'admin', diff --git a/apps/sim/lib/organizations/application/reads.ts b/apps/sim/lib/organizations/application/reads.ts index b4ad006ef7d..01fd6d00904 100644 --- a/apps/sim/lib/organizations/application/reads.ts +++ b/apps/sim/lib/organizations/application/reads.ts @@ -17,6 +17,7 @@ import { } from '@/lib/organizations/member-queries' import { listOrganizationInvitationRecords, + listOrganizationInvitationWorkspaceRecords, listOrganizationRecordsForUser, listOrganizationWorkspaceRecords, type OrganizationInvitationSortBy, @@ -162,3 +163,19 @@ export const getOrganizationInvitation = defineAuthorizedOrganizationUseCase({ execute: ({ input }: { input: OrganizationInvitationInput }) => requireOrganizationInvitationRecord(input.organizationId, input.invitationId), }) + +export const listOrganizationInvitationWorkspaces = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.listInvitationWorkspaces, + async execute({ + input, + }: { + input: OrganizationInvitationInput & OrganizationListOptions + }) { + await requireOrganizationInvitationRecord(input.organizationId, input.invitationId) + return listOrganizationInvitationWorkspaceRecords( + input.organizationId, + input.invitationId, + input + ) + }, +}) diff --git a/apps/sim/lib/organizations/queries.ts b/apps/sim/lib/organizations/queries.ts index c1425129b94..81746b55ce0 100644 --- a/apps/sim/lib/organizations/queries.ts +++ b/apps/sim/lib/organizations/queries.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { type InvitationStatus, invitation, + invitationWorkspaceGrant, member, organization, user, @@ -229,3 +230,39 @@ export async function requireOrganizationInvitationRecord( if (!row) throw new OrchestrationError('not_found', 'Invitation not found') return row } + +/** Includes retained archived grants without exposing workspaces moved to another organization. */ +export async function listOrganizationInvitationWorkspaceRecords( + organizationId: string, + invitationId: string, + options: OrganizationListOptions +) { + type InvitationWorkspaceRow = { 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, + permission: invitationWorkspaceGrant.permission, + archivedAt: workspace.archivedAt, + }) + .from(invitationWorkspaceGrant) + .innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId)) + .innerJoin(workspace, eq(workspace.id, invitationWorkspaceGrant.workspaceId)) + .where( + and( + eq(invitation.organizationId, organizationId), + eq(invitationWorkspaceGrant.invitationId, invitationId), + eq(workspace.organizationId, organizationId), + 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) +} diff --git a/apps/sim/lib/permission-groups/application/operations.ts b/apps/sim/lib/permission-groups/application/operations.ts index fb2d7177d05..6511cb83d0c 100644 --- a/apps/sim/lib/permission-groups/application/operations.ts +++ b/apps/sim/lib/permission-groups/application/operations.ts @@ -1,4 +1,5 @@ import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' function definePermissionGroupOperation( id: Id, @@ -55,3 +56,17 @@ export const permissionGroupOperations = { */ listWorkspaces: definePermissionGroupOperation('permission_groups.workspaces.list', 'api:read'), } as const + +export const permissionGroupWorkspaceOperations = { + /** + * permission-group-exempt: Members must be able to read their own restrictions. + */ + readUserConfig: defineWorkspaceOperation({ + id: 'permission_groups.read_user_config', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', + capability: 'none', + }), +} as const diff --git a/apps/sim/lib/permission-groups/application/read-user-config.test.ts b/apps/sim/lib/permission-groups/application/read-user-config.test.ts new file mode 100644 index 00000000000..32d1700f0fc --- /dev/null +++ b/apps/sim/lib/permission-groups/application/read-user-config.test.ts @@ -0,0 +1,157 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + context: vi.fn(), + role: vi.fn(), + config: vi.fn(), + regime: vi.fn(), + group: vi.fn(), + admin: vi.fn(), +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.context, +})) +vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({ + ...(await importOriginal()), + resolveEffectiveWorkspacePermission: mocks.role, +})) +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.config, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: mocks.regime, + resolveWorkspaceGroup: mocks.group, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mocks.admin })) + +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' +import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const session = { kind: 'session', userId: 'caller', sessionId: 'session' } as const +const personal = { kind: 'personal_api_key', userId: 'caller', keyId: 'key' } as const +const oauth = { + kind: 'oauth_access_token', + userId: 'caller', + clientId: 'client', + tokenId: 'token', + scopes: ['api:read'], + expiresAt: new Date('2099-01-01'), +} as const +const context = { + workspaceId: 'workspace', + workspaceOrganizationId: 'organization', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} +const input = { workspaceId: 'workspace' } + +beforeEach(() => { + vi.clearAllMocks() + mocks.context.mockResolvedValue(context) + mocks.role.mockResolvedValue('read') + mocks.config.mockResolvedValue(null) + mocks.regime.mockResolvedValue(true) + mocks.admin.mockResolvedValue(false) + mocks.group.mockResolvedValue({ + permissionGroupId: 'group', + groupName: 'Readers', + config: DEFAULT_PERMISSION_GROUP_CONFIG, + }) +}) + +describe('effective caller permission configuration', () => { + it.each([session, personal, oauth])( + 'resolves the actual $kind caller within the canonical workspace', + async (principal) => { + const result = await readUserPermissionConfig.execute({ principal, input }) + expect(mocks.group).toHaveBeenCalledExactlyOnceWith('caller', 'organization', 'workspace') + expect(mocks.admin).toHaveBeenCalledExactlyOnceWith('caller', 'organization') + expect(result).toEqual({ + permissionGroupId: 'group', + groupName: 'Readers', + config: DEFAULT_PERMISSION_GROUP_CONFIG, + entitled: true, + organizationId: 'organization', + isOrgAdmin: false, + }) + } + ) + + it('rejects workspace keys before canonical loading', async () => { + await expect( + readUserPermissionConfig.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, + input, + }) + ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) + expect(mocks.context).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) + + it('does not disclose a group to a caller without workspace access', async () => { + mocks.role.mockResolvedValue(null) + await expect(readUserPermissionConfig.execute({ principal: personal, input })).rejects.toThrow() + expect(mocks.regime).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) + + it.each([ + [personal, { disablePersonalApiKeys: true }], + [oauth, { disableOAuthAppAccess: true }], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], + ] as const)( + 'retains credential-wide restrictions even for a capability-exempt read', + async (principal, restriction) => { + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, ...restriction }) + await expect(readUserPermissionConfig.execute({ principal, input })).rejects.toThrow() + expect(mocks.group).not.toHaveBeenCalled() + } + ) + + it('enforces the workspace personal-key switch', async () => { + mocks.context.mockResolvedValue({ ...context, allowPersonalApiKeys: false }) + await expect( + readUserPermissionConfig.execute({ principal: personal, input }) + ).rejects.toMatchObject({ detailCode: 'PERSONAL_API_KEYS_DISABLED' }) + expect(mocks.group).not.toHaveBeenCalled() + }) + + it.each([ + { ...oauth, scopes: ['search:read'] }, + { ...oauth, expiresAt: new Date('2000-01-01') }, + ])('rejects invalid OAuth authority before loading the workspace', async (principal) => { + await expect(readUserPermissionConfig.execute({ principal, input })).rejects.toThrow() + expect(mocks.context).not.toHaveBeenCalled() + }) + + it('preserves null configuration outside active organization governance', async () => { + mocks.regime.mockResolvedValue(false) + const result = await readUserPermissionConfig.execute({ principal: personal, input }) + expect(result).toEqual({ + permissionGroupId: null, + groupName: null, + config: null, + entitled: false, + organizationId: 'organization', + isOrgAdmin: false, + }) + expect(mocks.group).not.toHaveBeenCalled() + }) + + it('preserves personal workspaces without consulting organization membership', async () => { + mocks.context.mockResolvedValue({ ...context, workspaceOrganizationId: null }) + expect(await readUserPermissionConfig.execute({ principal: personal, input })).toEqual({ + permissionGroupId: null, + groupName: null, + config: null, + entitled: false, + organizationId: null, + isOrgAdmin: false, + }) + expect(mocks.admin).not.toHaveBeenCalled() + expect(mocks.regime).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-groups/application/read-user-config.ts b/apps/sim/lib/permission-groups/application/read-user-config.ts index b2a7776aebd..aec3c504132 100644 --- a/apps/sim/lib/permission-groups/application/read-user-config.ts +++ b/apps/sim/lib/permission-groups/application/read-user-config.ts @@ -1,5 +1,5 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' -import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { permissionGroupWorkspaceOperations } from '@/lib/permission-groups/application/operations' import { isOrganizationPermissionRegimeActive, resolveWorkspaceGroup, @@ -7,19 +7,8 @@ import { import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' -/** - * permission-group-exempt: Members must be able to read their own restrictions. - */ -export const readUserPermissionConfigOperation = defineWorkspaceOperation({ - id: 'permission_groups.read_user_config', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['session'], - capability: 'none', -}) - export const readUserPermissionConfig = defineAuthorizedWorkspaceUseCase({ - operation: readUserPermissionConfigOperation, + operation: permissionGroupWorkspaceOperations.readUserConfig, resolveContext: ({ input }: { input: { workspaceId: string } }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), authorizationOptions: {}, diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index 5ea30faf313..41555b4e2b1 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -44,16 +44,19 @@ export function capabilityDeniedBy( * * A no-op when no group governs the user, so a personal workspace or a * non-enterprise organization is unaffected. Pass `organizationId` when the - * caller has already loaded the workspace; omitting it costs one lookup, and - * both forms share the same per-request memo either way. + * caller has already loaded the workspace. An explicit executor reads current + * policy directly; otherwise both forms share the per-request memo. */ export async function assertWorkspaceCapability( userId: string, workspaceId: string, capability: StaticPermissionGroupCapability, - organizationId?: string | null + organizationId?: string | null, + executor?: DbOrTx ): Promise { - const config = await resolvePermissionGroupConfig(userId, workspaceId, organizationId) + const config = executor + ? await resolvePermissionGroupConfig(userId, workspaceId, organizationId, executor) + : await resolvePermissionGroupConfig(userId, workspaceId, organizationId) if (capabilityDeniedBy(capability, config)) refuseCapability(capability) } diff --git a/apps/sim/lib/permission-groups/config-scope.server.test.ts b/apps/sim/lib/permission-groups/config-scope.server.test.ts index 7f4f473e885..ebb1d785cc1 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.test.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { db } from '@sim/db' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetUserPermissionConfig, mockResolveVerifiedContext } = vi.hoisted(() => ({ @@ -62,4 +64,34 @@ describe('resolvePermissionGroupConfig scope memo', () => { expect(mockResolveVerifiedContext).toHaveBeenCalledTimes(2) }) + + it('bypasses the scope memo for explicit executors without replacing the cached request result', async () => { + const updated = { ...CONFIG, disablePersonalApiKeys: true } + await withPermissionGroupScope(async () => { + expect(await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1')).toEqual(CONFIG) + mockResolveVerifiedContext.mockResolvedValue({ config: updated }) + expect(await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1', db)).toEqual( + updated + ) + expect(mockResolveVerifiedContext).toHaveBeenLastCalledWith( + 'user-1', + 'workspace-1', + 'org-1', + db + ) + expect(await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1')).toEqual(CONFIG) + mockResolveVerifiedContext.mockResolvedValue({ config: null }) + expect(await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1', db)).toBeNull() + }) + expect(mockResolveVerifiedContext).toHaveBeenCalledTimes(3) + }) + + it('forwards an explicit executor when the organization must be loaded', async () => { + await withPermissionGroupScope(async () => { + await resolvePermissionGroupConfig('user-1', 'workspace-1', undefined) + mockGetUserPermissionConfig.mockResolvedValue(null) + expect(await resolvePermissionGroupConfig('user-1', 'workspace-1', undefined, db)).toBeNull() + }) + expect(mockGetUserPermissionConfig).toHaveBeenLastCalledWith('user-1', 'workspace-1', db) + }) }) diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts index 42d480c46c7..4b259c99266 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -1,4 +1,5 @@ import { cache } from 'react' +import type { DbOrTx } from '@/lib/db/types' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import type { PermissionGroupScopeKey } from '@/lib/permission-groups/request-scope.server' import { getPermissionGroupConfigStore } from '@/lib/permission-groups/request-scope.server' @@ -25,7 +26,8 @@ const resolveCached = cache( /** * The permission-group config governing `userId` in `workspaceId`, resolved at - * most once per scope. + * most once per scope. An explicit executor bypasses both caches so transaction + * reauthorization cannot reuse policy read before acquiring its locks. * * Caches the promise rather than the value, so concurrent callers share one * query instead of racing to start several. Caches `null` too — "no group @@ -53,8 +55,16 @@ const resolveCached = cache( export function resolvePermissionGroupConfig( userId: string, workspaceId: string, - organizationId: string | null | undefined + organizationId: string | null | undefined, + executor?: DbOrTx ): Promise { + if (executor) { + return organizationId === undefined + ? getUserPermissionConfig(userId, workspaceId, executor) + : resolveVerifiedUserAccessControlContext(userId, workspaceId, organizationId, executor).then( + (context) => context.config + ) + } const store = getPermissionGroupConfigStore() if (!store) return resolveCached(userId, workspaceId, organizationId) diff --git a/apps/sim/lib/permission-groups/resolve.server.test.ts b/apps/sim/lib/permission-groups/resolve.server.test.ts index 0e313109396..d5756a53cac 100644 --- a/apps/sim/lib/permission-groups/resolve.server.test.ts +++ b/apps/sim/lib/permission-groups/resolve.server.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { db } from '@sim/db' import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' @@ -110,6 +112,15 @@ describe('permission-group resolution under a failed entitlement read', () => { expect(mockIsOrganizationGovernanceActive).toHaveBeenLastCalledWith(ORGANIZATION_ID, executor) }) + it('reads a verified workspace entitlement through the explicitly supplied executor', async () => { + mockIsOrganizationGovernanceActive.mockResolvedValue(false) + await expect( + resolveVerifiedUserAccessControlContext(USER_ID, WORKSPACE_ID, ORGANIZATION_ID, db) + ).resolves.toMatchObject({ entitled: false, config: null }) + expect(mockIsOrganizationGovernanceActive).toHaveBeenCalledWith(ORGANIZATION_ID, db) + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + }) + it('propagates a transaction entitlement read failure instead of disabling restrictions', async () => { const executor = {} as DbOrTx entitlementReadFails() diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index 2edc401aff0..ed732609571 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -259,12 +259,18 @@ async function resolveUserAccessControlContextForOrganization( export async function resolveVerifiedUserAccessControlContext( userId: string, workspaceId: string, - organizationId: string | null + organizationId: string | null, + executor?: DbOrTx ): Promise { if (!isHosted && !isAccessControlEnabled) { return inactiveUserAccessControlContext(null) } - return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId) + return resolveUserAccessControlContextForOrganization( + userId, + workspaceId, + organizationId, + executor + ) } /** diff --git a/apps/sim/lib/workspaces/organization-workspaces.postgres.test.ts b/apps/sim/lib/workspaces/organization-workspaces.postgres.test.ts new file mode 100644 index 00000000000..bddfade0bb2 --- /dev/null +++ b/apps/sim/lib/workspaces/organization-workspaces.postgres.test.ts @@ -0,0 +1,206 @@ +/** @vitest-environment node */ + +import * as schema from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { databaseUrl } = vi.hoisted(() => { + const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL + if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { + throw new Error('Workspace detachment integration tests require a disposable local database') + } + return { databaseUrl } +}) +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') + +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' + +const schemaName = `workspace_detach_${generateId().replaceAll('-', '')}` +const connection = databaseUrl + ? postgres(databaseUrl, { + max: 3, + prepare: false, + connection: { search_path: schemaName, application_name: schemaName }, + onnotice: () => undefined, + }) + : undefined +const database = connection ? drizzle(connection, { schema }) : undefined + +beforeAll(async () => { + if (!connection) return + await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) + await connection.unsafe(` + CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text, role text); + CREATE TABLE organization (id text PRIMARY KEY, storage_used_bytes bigint NOT NULL); + CREATE TABLE invitation ( + id text PRIMARY KEY, organization_id text REFERENCES organization(id) ON DELETE CASCADE + ); + CREATE TABLE user_stats (user_id text PRIMARY KEY, storage_used_bytes bigint NOT NULL); + CREATE TABLE workspace ( + id text PRIMARY KEY, name text, owner_id text, organization_id text, workspace_mode text, + billed_account_user_id text, allow_personal_api_keys boolean DEFAULT true, + archived_at timestamp, organization_assigned_at timestamp, updated_at timestamp, + storage_used_bytes bigint NOT NULL + ); + CREATE TABLE permissions ( + id text PRIMARY KEY, user_id text, entity_type text, entity_id text, permission_type text, + created_at timestamp, updated_at timestamp, UNIQUE(user_id, entity_type, entity_id) + ); + CREATE TABLE workspace_files (workspace_id text, context text, size_bytes bigint); + CREATE TABLE knowledge_base (id text PRIMARY KEY, workspace_id text); + CREATE TABLE document ( + knowledge_base_id text, file_size bigint, connector_id text, deleted_at timestamp + ); + `) +}) + +beforeEach(async () => { + if (!connection) return + await connection.unsafe(` + TRUNCATE member, organization, invitation, user_stats, workspace, permissions, + workspace_files, knowledge_base, document; + INSERT INTO member VALUES ('owner-membership', 'org', 'org-owner', 'owner'); + INSERT INTO organization VALUES ('org', 40); + INSERT INTO invitation VALUES ('invitation', 'org'); + INSERT INTO user_stats VALUES ('org-owner', 5); + INSERT INTO workspace ( + id, name, owner_id, organization_id, workspace_mode, billed_account_user_id, + organization_assigned_at, storage_used_bytes + ) VALUES ('workspace', 'Workspace', 'workspace-owner', 'org', 'organization', 'org-owner', now(), 40); + INSERT INTO workspace_files VALUES ('workspace', 'workspace', 40); + `) +}) + +afterAll(async () => { + if (!connection) return + await connection.unsafe(`DROP SCHEMA "${schemaName}" CASCADE`) + await connection.end() +}) + +describe.skipIf(!databaseUrl)('organization workspace detachment lock order', () => { + it.each(['standalone', 'organization-delete'] as const)( + 'lets acceptance finish before %s without inverting invitation, workspace, or organization locks', + async (mode) => { + const acceptanceReady = Promise.withResolvers() + const continueAcceptance = Promise.withResolvers() + const acceptance = database! + .transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '4s'`) + await acquireInvitationMutationLocks(tx, { + invitationIds: ['invitation'], + workspaceIds: [], + }) + await tx + .select({ id: schema.invitation.id }) + .from(schema.invitation) + .where(eq(schema.invitation.id, 'invitation')) + .for('update') + if (mode === 'organization-delete') { + acceptanceReady.resolve() + await continueAcceptance.promise + } + await acquireInvitationMutationLocks(tx, { + invitationIds: [], + workspaceIds: ['workspace'], + }) + const current = await getWorkspaceWithOwner('workspace', { + executor: tx, + forUpdate: true, + }) + expect(current?.organizationId).toBe('org') + if (mode === 'standalone') { + acceptanceReady.resolve() + await continueAcceptance.promise + } + await acquireOrganizationMutationLock(tx, 'org') + }) + .catch((error: unknown) => { + acceptanceReady.reject(error) + throw error + }) + const acceptanceOutcome = Promise.allSettled([acceptance]) + await acceptanceReady.promise + + const detachPid = Promise.withResolvers() + const detachment = database! + .transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '4s'`) + const [backend] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + detachPid.resolve(backend.pid) + const result = await detachOrganizationWorkspacesTx(tx, 'org') + if (mode === 'organization-delete') { + await tx.delete(schema.organization).where(eq(schema.organization.id, 'org')) + } + return result + }) + .catch((error: unknown) => { + detachPid.reject(error) + throw error + }) + const detachOutcome = Promise.allSettled([detachment]) + + try { + const pid = await detachPid.promise + await vi.waitFor( + async () => { + const [waiting] = await connection!` + SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pid} + ` + expect(waiting.wait_event_type).toBe('Lock') + }, + { timeout: 2000 } + ) + } finally { + continueAcceptance.resolve() + await Promise.all([acceptanceOutcome, detachOutcome]) + } + + await expect(acceptance).resolves.toBeUndefined() + await expect(detachment).resolves.toMatchObject({ + detachedWorkspaceIds: ['workspace'], + billedAccountUserId: 'org-owner', + auditEntries: [{ resourceId: 'workspace' }], + }) + const [detached] = await connection!` + SELECT organization_id, workspace_mode, billed_account_user_id, + organization_assigned_at, storage_used_bytes::int + FROM workspace WHERE id = 'workspace' + ` + expect(detached).toEqual({ + organization_id: null, + workspace_mode: 'grandfathered_shared', + billed_account_user_id: 'org-owner', + organization_assigned_at: null, + storage_used_bytes: 40, + }) + expect( + await connection!`SELECT storage_used_bytes::int FROM organization WHERE id = 'org'` + ).toEqual(mode === 'organization-delete' ? [] : [{ storage_used_bytes: 0 }]) + expect(await connection!`SELECT id FROM invitation`).toEqual( + mode === 'organization-delete' ? [] : [{ id: 'invitation' }] + ) + expect( + await connection!`SELECT storage_used_bytes::int FROM user_stats WHERE user_id = 'org-owner'` + ).toEqual([{ storage_used_bytes: 45 }]) + expect( + await connection!` + SELECT user_id, entity_type, entity_id, permission_type FROM permissions + ` + ).toEqual([ + { + user_id: 'org-owner', + entity_type: 'workspace', + entity_id: 'workspace', + permission_type: 'admin', + }, + ]) + } + ) +}) diff --git a/apps/sim/lib/workspaces/organization-workspaces.test.ts b/apps/sim/lib/workspaces/organization-workspaces.test.ts index 5054af2f3a2..1eee6a1fd55 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.test.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { db } from '@sim/db' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,6 +53,7 @@ vi.mock('@sim/utils/id', () => ({ import { attachOwnedWorkspacesToOrganization, detachOrganizationWorkspaces, + detachOrganizationWorkspacesTx, WorkspaceOrganizationMembershipConflictError, } from '@/lib/workspaces/organization-workspaces' @@ -313,43 +315,164 @@ describe('organization workspace helpers', () => { ).resolves.toMatchObject({ attachedWorkspaceIds: ['ws-1'] }) }) - it('detaches organization workspaces into grandfathered shared mode', async () => { + it.each(['standalone', 'enlisted'] as const)( + 'detaches with invitation locks before the organization fence and workspace rows in a %s transaction', + async (mode) => { + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.invitation, [{ id: 'invite-pending' }, { id: 'invite-terminal' }]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', ownerId: 'creator-1', billedAccountUserId: 'old-owner' }, + ]) + queueTableRows(schemaMock.invitation, [{ id: 'invite-pending' }, { id: 'invite-terminal' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + + const result = + mode === 'standalone' + ? await detachOrganizationWorkspaces('org-1') + : await db.transaction((tx) => detachOrganizationWorkspacesTx(tx, 'org-1')) + + expect(result.detachedWorkspaceIds).toEqual(['ws-1']) + expect(result.billedAccountUserId).toBe('owner-1') + expect(mockAcquireInvitationMutationLocks).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + { + invitationIds: ['invite-pending', 'invite-terminal'], + workspaceIds: ['ws-1'], + } + ) + expect(mockAcquireOrganizationMutationLock).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + 'org-1' + ) + expect(dbChainMockFns.select.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquireInvitationMutationLocks.mock.invocationCallOrder[0] + ) + expect(mockAcquireInvitationMutationLocks.mock.invocationCallOrder[0]).toBeLessThan( + mockAcquireOrganizationMutationLock.mock.invocationCallOrder[0] + ) + expect(mockAcquireOrganizationMutationLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[2] + ) + expect(mockAcquireOrganizationMutationLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.for.mock.invocationCallOrder[0] + ) + expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan( + mockChangeWorkspaceStoragePayersInTx.mock.invocationCallOrder[0] + ) + expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledWith(expect.anything(), [ + { + workspaceId: 'ws-1', + organizationId: null, + billedAccountUserId: 'owner-1', + expectedCurrentPayer: { + organizationId: 'org-1', + billedAccountUserId: 'old-owner', + }, + }, + ]) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceMode: 'grandfathered_shared', + organizationAssignedAt: null, + }) + ) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.values).toHaveBeenCalledWith([ + expect.objectContaining({ entityId: 'ws-1', userId: 'owner-1' }), + ]) + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled() + } + ) + + it.each(['standalone', 'enlisted'] as const)( + 'refuses newly attached workspaces before detachment writes in a %s transaction', + async (mode) => { + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', ownerId: 'creator-1', billedAccountUserId: 'old-owner' }, + { id: 'ws-2', ownerId: 'creator-2', billedAccountUserId: 'old-owner' }, + ]) + + const result = + mode === 'standalone' + ? detachOrganizationWorkspaces('org-1') + : db.transaction((tx) => detachOrganizationWorkspacesTx(tx, 'org-1')) + + await expect(result).rejects.toMatchObject({ + code: 'conflict', + message: 'Organization workspaces changed during detachment; retry', + }) + expect(mockAcquireInvitationMutationLocks).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + { + invitationIds: [], + workspaceIds: ['ws-1'], + } + ) + expect(dbChainMockFns.for).not.toHaveBeenCalled() + expect(mockChangeWorkspaceStoragePayersInTx).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + + it.each(['standalone', 'enlisted'] as const)( + 'refuses newly created organization invitations before detachment writes in a %s transaction', + async (mode) => { + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.invitation, [{ id: 'invite-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.workspace, [ + { id: 'ws-1', ownerId: 'creator-1', billedAccountUserId: 'old-owner' }, + ]) + queueTableRows(schemaMock.invitation, [{ id: 'invite-1' }, { id: 'invite-2' }]) + + const result = + mode === 'standalone' + ? detachOrganizationWorkspaces('org-1') + : db.transaction((tx) => detachOrganizationWorkspacesTx(tx, 'org-1')) + + await expect(result).rejects.toMatchObject({ + code: 'conflict', + message: 'Organization invitations changed during detachment; retry', + }) + expect(mockAcquireInvitationMutationLocks).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + { + invitationIds: ['invite-1'], + workspaceIds: ['ws-1'], + } + ) + expect(dbChainMockFns.for).not.toHaveBeenCalled() + expect(mockChangeWorkspaceStoragePayersInTx).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + + it('detaches only the locked workspaces that still belong to the organization', async () => { + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }, { id: 'ws-2' }]) + queueTableRows(schemaMock.invitation, [{ id: 'invite-1' }, { id: 'invite-2' }]) queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) queueTableRows(schemaMock.workspace, [ - { id: 'ws-1', ownerId: 'creator-1', billedAccountUserId: 'old-owner' }, + { id: 'ws-2', ownerId: 'creator-2', billedAccountUserId: 'old-owner' }, ]) - queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + queueTableRows(schemaMock.invitation, [{ id: 'invite-2' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-2' }]) const result = await detachOrganizationWorkspaces('org-1') - expect(result.detachedWorkspaceIds).toEqual(['ws-1']) - expect(result.billedAccountUserId).toBe('owner-1') - expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan( - mockChangeWorkspaceStoragePayersInTx.mock.invocationCallOrder[0] - ) + expect(result.detachedWorkspaceIds).toEqual(['ws-2']) + expect(mockAcquireInvitationMutationLocks).toHaveBeenCalledExactlyOnceWith(expect.anything(), { + invitationIds: ['invite-1', 'invite-2'], + workspaceIds: ['ws-1', 'ws-2'], + }) expect(mockChangeWorkspaceStoragePayersInTx).toHaveBeenCalledWith(expect.anything(), [ - { - workspaceId: 'ws-1', - organizationId: null, - billedAccountUserId: 'owner-1', - expectedCurrentPayer: { - organizationId: 'org-1', - billedAccountUserId: 'old-owner', - }, - }, - ]) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceMode: 'grandfathered_shared', - organizationAssignedAt: null, - }) - ) - expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.values).toHaveBeenCalledWith([ - expect.objectContaining({ entityId: 'ws-1', userId: 'owner-1' }), + expect.objectContaining({ workspaceId: 'ws-2' }), ]) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workspaces/organization-workspaces.ts b/apps/sim/lib/workspaces/organization-workspaces.ts index ac36c8efbb9..95324c679f7 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAuditBatch } from '@sim/audit' import { db } from '@sim/db' -import { member, permissions, workspace } from '@sim/db/schema' +import { invitation, member, permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, ne } from 'drizzle-orm' @@ -11,6 +11,7 @@ import { reapplyPaidOrgJoinBillingForExistingMemberTx, } from '@/lib/billing/organizations/membership' import { changeWorkspaceStoragePayersInTx } from '@/lib/billing/storage/payer-transfer' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { invalidateWorkspaceTableLimitsCache } from '@/lib/table/billing' @@ -426,6 +427,32 @@ export async function detachOrganizationWorkspacesTx( tx: DbOrTx, organizationId: string ): Promise { + const organizationWorkspacesWhere = and( + eq(workspace.organizationId, organizationId), + eq(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION) + ) + const workspaceSnapshot = await tx + .select({ id: workspace.id }) + .from(workspace) + .where(organizationWorkspacesWhere) + const organizationInvitationsWhere = eq(invitation.organizationId, organizationId) + const invitationSnapshot = await tx + .select({ id: invitation.id }) + .from(invitation) + .where(organizationInvitationsWhere) + const lockedWorkspaceIds = new Set(workspaceSnapshot.map(({ id }) => id)) + const lockedInvitationIds = new Set(invitationSnapshot.map(({ id }) => id)) + + /** + * Acceptance locks invitations before workspaces; enlisted organization deletion + * also cascades invitation rows. Resend checks organization policy even for + * terminal invitations, so their locks also precede the organization fence. + */ + await acquireInvitationMutationLocks(tx, { + invitationIds: [...lockedInvitationIds], + workspaceIds: [...lockedWorkspaceIds], + }) + await acquireOrganizationMutationLock(tx, organizationId) const organizationOwnerId = await getOrganizationOwnerId(organizationId, tx) if (!organizationOwnerId) { logger.warn( @@ -441,12 +468,24 @@ export async function detachOrganizationWorkspacesTx( billedAccountUserId: workspace.billedAccountUserId, }) .from(workspace) - .where( - and( - eq(workspace.organizationId, organizationId), - eq(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION) - ) + .where(organizationWorkspacesWhere) + + if (organizationWorkspaces.some(({ id }) => !lockedWorkspaceIds.has(id))) { + throw new OrchestrationError( + 'conflict', + 'Organization workspaces changed during detachment; retry' ) + } + const organizationInvitations = await tx + .select({ id: invitation.id }) + .from(invitation) + .where(organizationInvitationsWhere) + if (organizationInvitations.some(({ id }) => !lockedInvitationIds.has(id))) { + throw new OrchestrationError( + 'conflict', + 'Organization invitations changed during detachment; retry' + ) + } const detachedWorkspaceIds = await (async () => { const now = new Date() diff --git a/apps/sim/lib/workspaces/public-queries.test.ts b/apps/sim/lib/workspaces/public-queries.test.ts index 5e681c96865..79dd260c59c 100644 --- a/apps/sim/lib/workspaces/public-queries.test.ts +++ b/apps/sim/lib/workspaces/public-queries.test.ts @@ -178,6 +178,37 @@ describe('queryPublicWorkspaceMembers', () => { ]) }) + it('retains canonical user IDs for inherited administrators without explicit grants', async () => { + queueTableRows(schemaMock.workspace, [{ ownerId: 'owner', organizationId: 'org-1' }]) + queueTableRows(schemaMock.permissions, []) + queueTableRows(schemaMock.member, [ + { + userId: 'inherited-user', + email: 'admin@example.com', + name: 'Admin', + image: null, + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + const page = await queryPublicWorkspaceMembers('workspace-1', { limit: 10 }) + + expect(page?.members).toEqual([ + { + userId: 'inherited-user', + email: 'admin@example.com', + name: 'Admin', + image: null, + role: 'admin', + isExternal: false, + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + expect(dbChainMockFns.select).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: schemaMock.user.id }) + ) + }) + it('returns null when the workspace is not active', async () => { queueTableRows(schemaMock.workspace, []) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index dc6632172ed..6e4c9ee04df 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -12,6 +12,14 @@ const DEFAULT_FLAG = { isDefault: { name: 'default', boolean: true, negatable: true }, } as const +const ACCESS_REQUEST_COLUMNS: ColumnSpec[] = [ + { header: 'id' }, + { header: 'target', path: 'targetLabel' }, + { header: 'status' }, + { header: 'requester', path: 'requester.email' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, +] + 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' @@ -946,6 +954,112 @@ export const CLI_CONTRACT: CliContract = { { header: 'built-in', path: 'readOnly', format: 'bool' }, ], }, + discoverWorkspaceAccessRequests: { + command: 'workspaces access-requests discover', + profileWorkspacePath: true, + columns: [ + { header: 'label' }, + { header: 'target' }, + { header: 'state' }, + { header: 'pending', path: 'pendingRequestId' }, + ], + }, + listMyWorkspaceAccessRequests: { + command: 'workspaces access-requests mine', + profileWorkspacePath: true, + columns: ACCESS_REQUEST_COLUMNS, + }, + createWorkspaceAccessRequest: { + command: 'workspaces access-requests create', + profileWorkspacePath: true, + }, + cancelWorkspaceAccessRequest: { + command: 'workspaces access-requests cancel', + profileWorkspacePath: true, + }, + discoverOrganizationAccessRequests: { + command: 'organizations access-requests discover', + pathFlags: ORGANIZATION_FLAG, + columns: [ + { header: 'label' }, + { header: 'target' }, + { header: 'state' }, + { header: 'pending', path: 'pendingRequestId' }, + ], + }, + listMyOrganizationAccessRequests: { + command: 'organizations access-requests mine', + pathFlags: ORGANIZATION_FLAG, + columns: ACCESS_REQUEST_COLUMNS, + }, + createOrganizationAccessRequest: { + command: 'organizations access-requests create', + pathFlags: ORGANIZATION_FLAG, + }, + cancelOrganizationAccessRequest: { + command: 'organizations access-requests cancel', + pathFlags: ORGANIZATION_FLAG, + }, + listOrganizationAccessRequests: { + command: 'organizations access-requests list', + pathFlags: ORGANIZATION_FLAG, + columns: ACCESS_REQUEST_COLUMNS, + }, + previewOrganizationAccessRequest: { + command: 'organizations access-requests preview', + pathFlags: ORGANIZATION_FLAG, + }, + resolveOrganizationAccessRequest: { + command: 'organizations access-requests resolve', + pathFlags: ORGANIZATION_FLAG, + }, + getOrganizationAccessRequestSettings: { + command: 'organizations access-requests settings get', + pathFlags: ORGANIZATION_FLAG, + }, + updateOrganizationAccessRequestSettings: { + command: 'organizations access-requests settings update', + pathFlags: ORGANIZATION_FLAG, + }, + createWorkspaceInvitations: { + command: 'workspaces invitations create', + profileWorkspacePath: true, + flags: { emails: { list: true } }, + }, + getWorkspacePermissionConfig: { + command: 'workspaces permission-config', + profileWorkspacePath: true, + }, + listOrganizationInvitationWorkspaces: { + command: 'organizations invitations workspaces', + pathFlags: ORGANIZATION_FLAG, + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'permission' }, + { header: 'archived', path: 'archivedAt', format: 'timestamp' }, + ], + }, + getOrganizationMemberUsageLimit: { + command: 'organizations members usage-limit get', + pathFlags: ORGANIZATION_FLAG, + }, + updateOrganizationMemberUsageLimit: { + command: 'organizations members usage-limit update', + pathFlags: ORGANIZATION_FLAG, + }, + getOrganizationUsageSummary: { + command: 'organizations usage summary', + pathFlags: ORGANIZATION_FLAG, + }, + getOrganizationUsageBreakdown: { + command: 'organizations usage breakdown', + pathFlags: ORGANIZATION_FLAG, + }, + listOrganizationUsageEvents: { + command: 'organizations usage events', + pathFlags: ORGANIZATION_FLAG, + }, listOrganizations: { command: 'organizations list', columns: [{ header: 'id' }, { header: 'name' }, { header: 'role' }], diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index fa2639292b7..f12de55b0a8 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -859,6 +859,108 @@ export type BulkUpdateTableRowsResponse = { data: BulkUpdateTableRowsResponseRef0 } +/** `POST /api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel` */ +export type CancelOrganizationAccessRequestParams = { + organizationId: string + requestId: string +} + +export type CancelOrganizationAccessRequestQuery = Record + +type CancelOrganizationAccessRequestResponseRef0 = { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } +} + +export type CancelOrganizationAccessRequestResponse = { + data: CancelOrganizationAccessRequestResponseRef0 +} + /** `DELETE /api/v2/tables/[tableId]/dispatches/[dispatchId]` */ export type CancelTableDispatchParams = { tableId: string @@ -1105,6 +1207,108 @@ export type CancelWorkflowRunResponse = { data: CancelWorkflowRunResponseRef0 } +/** `POST /api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel` */ +export type CancelWorkspaceAccessRequestParams = { + workspaceId: string + requestId: string +} + +export type CancelWorkspaceAccessRequestQuery = Record + +type CancelWorkspaceAccessRequestResponseRef0 = { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } +} + +export type CancelWorkspaceAccessRequestResponse = { + data: CancelWorkspaceAccessRequestResponseRef0 +} + /** `POST /api/v2/chat` */ export type ChatQuery = Record @@ -1958,6 +2162,183 @@ export type CreateMcpServerResponse = { data: CreateMcpServerResponseRef0 } +/** `POST /api/v2/organizations/[organizationId]/access-requests` */ +export type CreateOrganizationAccessRequestParams = { + organizationId: string +} + +export type CreateOrganizationAccessRequestQuery = Record + +export type CreateOrganizationAccessRequestBody = { + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + reason?: string +} + +type CreateOrganizationAccessRequestResponseRef0 = { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } +} + +export type CreateOrganizationAccessRequestResponse = { + data: CreateOrganizationAccessRequestResponseRef0 +} + /** `POST /api/v2/organizations/[organizationId]/invitations` */ export type CreateOrganizationInvitationParams = { organizationId: string @@ -2907,6 +3288,219 @@ export type CreateWorkflowMcpServerResponse = { data: CreateWorkflowMcpServerResponseRef0 } +/** `POST /api/v2/workspaces/[workspaceId]/access-requests` */ +export type CreateWorkspaceAccessRequestParams = { + workspaceId: string +} + +export type CreateWorkspaceAccessRequestQuery = Record + +export type CreateWorkspaceAccessRequestBody = { + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + reason?: string +} + +type CreateWorkspaceAccessRequestResponseRef0 = { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } +} + +export type CreateWorkspaceAccessRequestResponse = { + data: CreateWorkspaceAccessRequestResponseRef0 +} + +/** `POST /api/v2/workspaces/[workspaceId]/invitations` */ +export type CreateWorkspaceInvitationsParams = { + workspaceId: string +} + +export type CreateWorkspaceInvitationsQuery = Record + +export type CreateWorkspaceInvitationsBody = { + emails: Array + permission?: 'admin' | 'write' | 'read' + membership?: 'member' | 'admin' | 'external' +} + +type CreateWorkspaceInvitationsResponseRef0 = { + success: boolean + successful: Array + added: Array + failed: Array<{ + email: string + error: string + }> + invitations: Array<{ + id: string + email: string + workspaceIds: Array + permission: 'admin' | 'write' | 'read' + membershipIntent: 'internal' | 'external' + instantAdd?: boolean + outcome?: 'added' | 'updated' | 'unchanged' + }> +} + +export type CreateWorkspaceInvitationsResponse = { + data: CreateWorkspaceInvitationsResponseRef0 +} + /** `DELETE /api/v2/credentials/[credentialId]` */ export type DeleteCredentialParams = { credentialId: string @@ -3678,32 +4272,248 @@ export type DeployWorkflowMcpToolResponse = { data: DeployWorkflowMcpToolResponseRef0 } -/** `GET /api/v2/files/[fileId]` */ -export type DownloadFileParams = { - fileId: string -} - -export type DownloadFileQuery = { - workspaceId: string -} - -/** Non-JSON response (`binary`). */ -export type DownloadFileResponse = never - -/** `GET /api/v2/files/[fileId]/versions/[version]/content` */ -export type DownloadFileVersionParams = { - fileId: string - version: number +/** `GET /api/v2/organizations/[organizationId]/access-requests/discovery` */ +export type DiscoverOrganizationAccessRequestsParams = { + organizationId: string } -export type DownloadFileVersionQuery = { - workspaceId: string +export type DiscoverOrganizationAccessRequestsQuery = { + search?: string + targetKind?: + | 'feature' + | 'integration' + | 'provider' + | 'model' + | 'tool' + | 'knowledge_connector' + | 'file_share_auth' + | 'chat_deploy_auth' + | 'usage_limit' + state?: 'allowed' | 'requestable' | 'unavailable' + sortBy?: 'label' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string } -/** Non-JSON response (`binary`). */ -export type DownloadFileVersionResponse = never - -/** `GET /api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]` */ +type DiscoverOrganizationAccessRequestsResponseRef0 = { + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + label: string + state: 'allowed' | 'requestable' | 'unavailable' + reason: string | null + pendingRequestId: string | null +} + +export type DiscoverOrganizationAccessRequestsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workspaces/[workspaceId]/access-requests/discovery` */ +export type DiscoverWorkspaceAccessRequestsParams = { + workspaceId: string +} + +export type DiscoverWorkspaceAccessRequestsQuery = { + search?: string + targetKind?: + | 'feature' + | 'integration' + | 'provider' + | 'model' + | 'tool' + | 'knowledge_connector' + | 'file_share_auth' + | 'chat_deploy_auth' + | 'usage_limit' + state?: 'allowed' | 'requestable' | 'unavailable' + sortBy?: 'label' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type DiscoverWorkspaceAccessRequestsResponseRef0 = { + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + label: string + state: 'allowed' | 'requestable' | 'unavailable' + reason: string | null + pendingRequestId: string | null +} + +export type DiscoverWorkspaceAccessRequestsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/files/[fileId]` */ +export type DownloadFileParams = { + fileId: string +} + +export type DownloadFileQuery = { + workspaceId: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadFileResponse = never + +/** `GET /api/v2/files/[fileId]/versions/[version]/content` */ +export type DownloadFileVersionParams = { + fileId: string + version: number +} + +export type DownloadFileVersionQuery = { + workspaceId: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadFileVersionResponse = never + +/** `GET /api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]` */ export type DownloadRunFileParams = { workflowId: string runId: string @@ -4889,6 +5699,21 @@ export type GetOrganizationResponse = { data: GetOrganizationResponseRef0 } +/** `GET /api/v2/organizations/[organizationId]/access-requests/settings` */ +export type GetOrganizationAccessRequestSettingsParams = { + organizationId: string +} + +export type GetOrganizationAccessRequestSettingsQuery = Record + +type GetOrganizationAccessRequestSettingsResponseRef0 = { + allowRequests: boolean +} + +export type GetOrganizationAccessRequestSettingsResponse = { + data: GetOrganizationAccessRequestSettingsResponseRef0 +} + /** `GET /api/v2/organizations/[organizationId]/invitations/[invitationId]` */ export type GetOrganizationInvitationParams = { organizationId: string @@ -4913,6 +5738,100 @@ export type GetOrganizationInvitationResponse = { data: GetOrganizationInvitationResponseRef0 } +/** `GET /api/v2/organizations/[organizationId]/members/[userId]/usage-limit` */ +export type GetOrganizationMemberUsageLimitParams = { + organizationId: string + userId: string +} + +export type GetOrganizationMemberUsageLimitQuery = Record + +type GetOrganizationMemberUsageLimitResponseRef0 = { + creditsUsed: number + creditLimit: number | null + billingInterval: 'month' | 'year' +} + +export type GetOrganizationMemberUsageLimitResponse = { + data: GetOrganizationMemberUsageLimitResponseRef0 +} + +/** `GET /api/v2/organizations/[organizationId]/usage/breakdown` */ +export type GetOrganizationUsageBreakdownParams = { + organizationId: string +} + +export type GetOrganizationUsageBreakdownQuery = { + preset?: 'current-period' | 'previous-period' | '7d' | '30d' | 'custom' + startDate?: string + endDate?: string + timezone?: string + workspaceId?: string + dimension: 'member' | 'workspace' | 'workflow' | 'model' | 'byok' | 'source' + limit?: number +} + +type GetOrganizationUsageBreakdownResponseRef0 = { + dimension: 'member' | 'workspace' | 'workflow' | 'model' | 'byok' | 'source' + rows: Array<{ + id: string + label: string + credits: number + events: number + share: number + providerId?: string + tokens?: number + }> + other: { + credits: number + events: number + rowCount: number + tokens: number + } + totalCredits: number +} + +export type GetOrganizationUsageBreakdownResponse = { + data: GetOrganizationUsageBreakdownResponseRef0 +} + +/** `GET /api/v2/organizations/[organizationId]/usage/summary` */ +export type GetOrganizationUsageSummaryParams = { + organizationId: string +} + +export type GetOrganizationUsageSummaryQuery = { + preset?: 'current-period' | 'previous-period' | '7d' | '30d' | 'custom' + startDate?: string + endDate?: string + timezone?: string + workspaceId?: string +} + +type GetOrganizationUsageSummaryResponseRef0 = { + window: { + start: string + end: string + source: 'reporting' | 'stripe' | 'default' | 'range' + } + bucket: 'day' | 'week' | 'month' + totals: { + credits: number + } + previousTotals: { + credits: number + } | null + series: Array<{ + timestamp: string + credits: number + events: number + }> +} + +export type GetOrganizationUsageSummaryResponse = { + data: GetOrganizationUsageSummaryResponseRef0 +} + /** `GET /api/v2/organizations/[organizationId]/permission-groups/[groupId]` */ export type GetPermissionGroupParams = { organizationId: string @@ -6033,6 +6952,69 @@ export type GetWorkspaceOperationResponse = { data: GetWorkspaceOperationResponseRef0 } +/** `GET /api/v2/workspaces/[workspaceId]/permission-config` */ +export type GetWorkspacePermissionConfigParams = { + workspaceId: string +} + +export type GetWorkspacePermissionConfigQuery = Record + +type GetWorkspacePermissionConfigResponseRef0 = { + permissionGroupId: string | null + groupName: 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 + } | null + entitled: boolean + organizationId: string | null + isOrgAdmin: boolean +} + +export type GetWorkspacePermissionConfigResponse = { + data: GetWorkspacePermissionConfigResponseRef0 +} + /** `POST /api/v2/skills/[skillId]/editors` */ export type GrantSkillEditorParams = { skillId: string @@ -7083,6 +8065,331 @@ export type ListMcpServerToolsResponse = { nextCursor: string | null } +/** `GET /api/v2/organizations/[organizationId]/access-requests/mine` */ +export type ListMyOrganizationAccessRequestsParams = { + organizationId: string +} + +export type ListMyOrganizationAccessRequestsQuery = { + status?: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + sortBy?: 'createdAt' | 'targetLabel' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListMyOrganizationAccessRequestsResponseRef0 = { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } +} + +export type ListMyOrganizationAccessRequestsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workspaces/[workspaceId]/access-requests` */ +export type ListMyWorkspaceAccessRequestsParams = { + workspaceId: string +} + +export type ListMyWorkspaceAccessRequestsQuery = { + status?: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + sortBy?: 'createdAt' | 'targetLabel' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListMyWorkspaceAccessRequestsResponseRef0 = { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } +} + +export type ListMyWorkspaceAccessRequestsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/organizations/[organizationId]/access-requests` */ +export type ListOrganizationAccessRequestsParams = { + organizationId: string +} + +export type ListOrganizationAccessRequestsQuery = { + status?: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + sortBy?: 'createdAt' | 'targetLabel' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string + search?: string +} + +type ListOrganizationAccessRequestsResponseRef0 = { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } +} + +export type ListOrganizationAccessRequestsResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/organizations/[organizationId]/invitations` */ export type ListOrganizationInvitationsParams = { organizationId: string @@ -7114,6 +8421,32 @@ export type ListOrganizationInvitationsResponse = { nextCursor: string | null } +/** `GET /api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces` */ +export type ListOrganizationInvitationWorkspacesParams = { + organizationId: string + invitationId: string +} + +export type ListOrganizationInvitationWorkspacesQuery = { + search?: string + sortBy?: 'name' | 'id' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListOrganizationInvitationWorkspacesResponseRef0 = { + id: string + name: string + permission: 'admin' | 'write' | 'read' + archivedAt: string | null +} + +export type ListOrganizationInvitationWorkspacesResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/organizations/[organizationId]/members` */ export type ListOrganizationMembersParams = { organizationId: string @@ -7163,6 +8496,58 @@ export type ListOrganizationsResponse = { nextCursor: string | null } +/** `GET /api/v2/organizations/[organizationId]/usage/events` */ +export type ListOrganizationUsageEventsParams = { + organizationId: string +} + +export type ListOrganizationUsageEventsQuery = { + preset?: 'current-period' | 'previous-period' | '7d' | '30d' | 'custom' + startDate?: string + endDate?: string + timezone?: string + source?: + | 'workflow' + | 'wand' + | 'sim-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + | 'api-tool' + limit?: number + cursor?: string + sortBy?: 'createdAt' + sortOrder?: 'asc' | 'desc' +} + +type ListOrganizationUsageEventsResponseRef0 = { + id: string + createdAt: string + source: + | 'workflow' + | 'wand' + | 'sim-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + | 'api-tool' + description: string + workflowName: string | null + credits: number + hasCost: boolean +} + +export type ListOrganizationUsageEventsResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/organizations/[organizationId]/workspaces` */ export type ListOrganizationWorkspacesParams = { organizationId: string @@ -8048,6 +9433,7 @@ export type ListWorkspaceMembersQuery = { } type ListWorkspaceMembersResponseRef0 = { + userId: string email: string name: string image: string | null @@ -8223,6 +9609,327 @@ export type MoveWorkflowsResponse = { data: MoveWorkflowsResponseRef0 } +/** `GET /api/v2/organizations/[organizationId]/access-requests/[requestId]/preview` */ +export type PreviewOrganizationAccessRequestParams = { + organizationId: string + requestId: string +} + +export type PreviewOrganizationAccessRequestQuery = Record + +type PreviewOrganizationAccessRequestResponseRef0 = { + newLimitCredits: number | null + request: { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } + } + changes: Array<{ + configKey: + | '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' + label: string + before: boolean | Array | null + after: boolean | Array | null + }> + impact: { + memberCount: number + workspaceCount: number + workspaceNames: Array + truncated: boolean + } + fingerprint: string + canApply: boolean + unavailableReason: string | null + resolutionKind: 'permission' + group: { + id: string + name: string + } | null + currentLimitCredits: null +} + +type PreviewOrganizationAccessRequestResponseRef1 = { + newLimitCredits: number | null + request: { + id: string + organizationId: string + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null + createdAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } + } + changes: Array<{ + configKey: + | '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' + label: string + before: boolean | Array | null + after: boolean | Array | null + }> + impact: { + memberCount: number + workspaceCount: number + workspaceNames: Array + truncated: boolean + } + fingerprint: string + canApply: boolean + unavailableReason: string | null + resolutionKind: 'usage_limit' + group: null + currentLimitCredits: number | null +} + +export type PreviewOrganizationAccessRequestResponse = { + data: PreviewOrganizationAccessRequestResponseRef0 | PreviewOrganizationAccessRequestResponseRef1 +} + /** `POST /api/v2/workflows/import/preview` */ export type PreviewWorkflowImportQuery = Record @@ -9603,24 +11310,137 @@ export type ResendOrganizationInvitationParams = { invitationId: string } -export type ResendOrganizationInvitationQuery = Record +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/organizations/[organizationId]/access-requests/[requestId]/resolve` */ +export type ResolveOrganizationAccessRequestParams = { + organizationId: string + requestId: string +} + +export type ResolveOrganizationAccessRequestQuery = Record -export type ResendOrganizationInvitationBody = Record +export type ResolveOrganizationAccessRequestBody = + | { + action: 'apply' + expectedFingerprint: string + newLimitCredits?: number + } + | { + action: 'decline' + reason: string + } -type ResendOrganizationInvitationResponseRef0 = { +type ResolveOrganizationAccessRequestResponseRef0 = { id: string organizationId: string - email: string - role: 'member' | 'admin' - kind: 'organization' | 'workspace' - membershipIntent: 'internal' | 'external' - status: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired' + workspaceId: string | null + target: + | { + kind: 'feature' + configKey: + | 'hideTraceSpans' + | 'hideKnowledgeBaseTab' + | 'hideTablesTab' + | 'hideCopilot' + | 'hideIntegrationsTab' + | 'hideSecretsTab' + | 'hideApiKeysTab' + | 'hideInboxTab' + | 'hideFilesTab' + | 'disableMcpTools' + | 'disableCustomTools' + | 'disableSkills' + | 'disableInvitations' + | 'disablePublicApi' + | 'disablePublicFileSharing' + | 'hideDeployApi' + | 'hideDeployMcp' + | 'hideDeployChatbot' + | 'disablePersonalApiKeys' + | 'disableLogExport' + | 'hideCostInfo' + | 'disableKnowledgeBaseCreation' + | 'disableKnowledgeBaseFileUpload' + | 'disableTableCreation' + | 'disableTableExport' + | 'disableBulkFileDownload' + | 'disablePersonalCredentials' + | 'disableWorkspaceCreation' + | 'hideOrgMemberDirectory' + | 'disableCliAccess' + | 'disableWebhookTriggers' + | 'disableToolAutoApproval' + | 'hideSandboxesTab' + | 'disableOAuthAppAccess' + | 'disableKnowledgeBaseExport' + } + | { + kind: 'integration' + id: string + } + | { + kind: 'provider' + id: string + } + | { + kind: 'model' + id: string + } + | { + kind: 'tool' + id: string + } + | { + kind: 'knowledge_connector' + id: string + } + | { + kind: 'file_share_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'chat_deploy_auth' + id: 'public' | 'password' | 'email' | 'sso' + } + | { + kind: 'usage_limit' + id: 'member' + } + targetLabel: string + reason: string + status: 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' + decisionReason: string | null createdAt: string - expiresAt: string + decidedAt: string | null + groupName: string | null + requester: { + id: string + name: string | null + email: string + } } -export type ResendOrganizationInvitationResponse = { - data: ResendOrganizationInvitationResponseRef0 +export type ResolveOrganizationAccessRequestResponse = { + data: ResolveOrganizationAccessRequestResponseRef0 } /** `POST /api/v2/files/[fileId]/restore` */ @@ -10910,6 +12730,25 @@ export type UpdateMcpServerResponse = { data: UpdateMcpServerResponseRef0 } +/** `PATCH /api/v2/organizations/[organizationId]/access-requests/settings` */ +export type UpdateOrganizationAccessRequestSettingsParams = { + organizationId: string +} + +export type UpdateOrganizationAccessRequestSettingsQuery = Record + +export type UpdateOrganizationAccessRequestSettingsBody = { + allowRequests: boolean +} + +type UpdateOrganizationAccessRequestSettingsResponseRef0 = { + allowRequests: boolean +} + +export type UpdateOrganizationAccessRequestSettingsResponse = { + data: UpdateOrganizationAccessRequestSettingsResponseRef0 +} + /** `PATCH /api/v2/organizations/[organizationId]/members/[userId]` */ export type UpdateOrganizationMemberParams = { organizationId: string @@ -10934,6 +12773,26 @@ export type UpdateOrganizationMemberResponse = { data: UpdateOrganizationMemberResponseRef0 } +/** `PATCH /api/v2/organizations/[organizationId]/members/[userId]/usage-limit` */ +export type UpdateOrganizationMemberUsageLimitParams = { + organizationId: string + userId: string +} + +export type UpdateOrganizationMemberUsageLimitQuery = Record + +export type UpdateOrganizationMemberUsageLimitBody = { + creditLimit: number | null +} + +type UpdateOrganizationMemberUsageLimitResponseRef0 = { + creditLimit: number | null +} + +export type UpdateOrganizationMemberUsageLimitResponse = { + data: UpdateOrganizationMemberUsageLimitResponseRef0 +} + /** `PATCH /api/v2/organizations/[organizationId]/permission-groups/[groupId]` */ export type UpdatePermissionGroupParams = { organizationId: string @@ -11908,8 +13767,8 @@ export type UpsertTableRowResponse = { * `query`, `body`, and `headers` describe each field well enough for the CLI * to build a flag for it and coerce the string argv gives back: its kind, * whether it is required, its enum values, and its server-side default. A slot - * the contract does not declare — or one whose shape is a union with no flat - * field list — is absent, and the runtime falls back to taking it as JSON. + * the contract does not declare is absent. Discriminated bodies carry branch + * field maps; other unions fall back to taking their variant data as JSON. * Headers the CLI sets itself, such as the API key, are never listed. * * `summary` is the operation's one-line description, lifted from the OpenAPI @@ -12284,6 +14143,18 @@ export const V2_OPERATIONS = { }, }, }, + cancelOrganizationAccessRequest: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/access-requests/[requestId]/cancel', + pathParams: ['organizationId', 'requestId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the access requests.', + requestId: 'Access request identifier.', + }, + responseMode: 'json', + summary: 'Cancel Organization Access Request', + workspaceKeyUnsupported: true, + }, cancelTableDispatch: { method: 'DELETE', path: '/api/v2/tables/[tableId]/dispatches/[dispatchId]', @@ -12376,6 +14247,18 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel Workflow Run', }, + cancelWorkspaceAccessRequest: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/access-requests/[requestId]/cancel', + pathParams: ['workspaceId', 'requestId'] as const, + pathParamDocs: { + workspaceId: 'Workspace in which the acting user requests access.', + requestId: 'Access request identifier.', + }, + responseMode: 'json', + summary: 'Cancel Workspace Access Request', + workspaceKeyUnsupported: true, + }, chat: { method: 'POST', path: '/api/v2/chat', @@ -12891,6 +14774,24 @@ export const V2_OPERATIONS = { }, }, }, + createOrganizationAccessRequest: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/access-requests', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the access requests.' }, + responseMode: 'json', + summary: 'Create Organization Access Request', + workspaceKeyUnsupported: true, + body: { + target: { + kind: 'unknown', + required: true, + describe: + 'Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable.', + }, + reason: { kind: 'string', default: '', describe: 'Why the acting user needs this access.' }, + }, + }, createOrganizationInvitation: { method: 'POST', path: '/api/v2/organizations/[organizationId]/invitations', @@ -13273,6 +15174,54 @@ export const V2_OPERATIONS = { }, }, }, + createWorkspaceAccessRequest: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/access-requests', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Workspace in which the acting user requests access.' }, + responseMode: 'json', + summary: 'Create Workspace Access Request', + workspaceKeyUnsupported: true, + body: { + target: { + kind: 'unknown', + required: true, + describe: + 'Target returned by Discover Workspace Access Requests or Discover Organization Access Requests. The target must currently be requestable.', + }, + reason: { kind: 'string', default: '', describe: 'Why the acting user needs this access.' }, + }, + }, + createWorkspaceInvitations: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/invitations', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Unique workspace identifier.' }, + responseMode: 'json', + summary: 'Create Workspace Invitations', + workspaceKeyUnsupported: true, + body: { + emails: { + kind: 'array', + required: true, + describe: + 'Email addresses to invite. Each address is processed separately; inspect failed for unsuccessful recipients.', + }, + permission: { + kind: 'enum', + values: ['admin', 'write', 'read'] as const, + default: 'read', + describe: 'Workspace permission to grant. Existing workspace access is preserved.', + }, + membership: { + kind: 'enum', + values: ['member', 'admin', 'external'] as const, + default: 'member', + describe: + 'Organization membership: member or admin uses a seat when billing is enabled. External grants workspace access only and requires an eligible paid account when billing is enabled. Existing members of another organization remain external.', + }, + }, + }, deleteCredential: { method: 'DELETE', path: '/api/v2/credentials/[credentialId]', @@ -13791,6 +15740,124 @@ export const V2_OPERATIONS = { }, }, }, + discoverOrganizationAccessRequests: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/discovery', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the access requests.' }, + responseMode: 'json', + summary: 'Discover Organization Access Requests', + workspaceKeyUnsupported: true, + query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the access item label.', + }, + targetKind: { + kind: 'enum', + values: [ + 'feature', + 'integration', + 'provider', + 'model', + 'tool', + 'knowledge_connector', + 'file_share_auth', + 'chat_deploy_auth', + 'usage_limit', + ] as const, + describe: 'Category of access to discover.', + }, + state: { + kind: 'enum', + values: ['allowed', 'requestable', 'unavailable'] as const, + describe: + 'Filter by the acting user’s current access. Requestable items can be submitted for review.', + }, + sortBy: { + kind: 'enum', + values: ['label'] as const, + default: 'label', + 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 access items 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.', + }, + }, + }, + discoverWorkspaceAccessRequests: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/access-requests/discovery', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Workspace in which the acting user requests access.' }, + responseMode: 'json', + summary: 'Discover Workspace Access Requests', + workspaceKeyUnsupported: true, + query: { + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the access item label.', + }, + targetKind: { + kind: 'enum', + values: [ + 'feature', + 'integration', + 'provider', + 'model', + 'tool', + 'knowledge_connector', + 'file_share_auth', + 'chat_deploy_auth', + 'usage_limit', + ] as const, + describe: 'Category of access to discover.', + }, + state: { + kind: 'enum', + values: ['allowed', 'requestable', 'unavailable'] as const, + describe: + 'Filter by the acting user’s current access. Requestable items can be submitted for review.', + }, + sortBy: { + kind: 'enum', + values: ['label'] as const, + default: 'label', + 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 access items 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.', + }, + }, + }, downloadFile: { method: 'GET', path: '/api/v2/files/[fileId]', @@ -14346,6 +16413,15 @@ export const V2_OPERATIONS = { summary: 'Get Organization', workspaceKeyUnsupported: true, }, + getOrganizationAccessRequestSettings: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/settings', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the access requests.' }, + responseMode: 'json', + summary: 'Get Organization Access Request Settings', + workspaceKeyUnsupported: true, + }, getOrganizationInvitation: { method: 'GET', path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]', @@ -14354,9 +16430,104 @@ export const V2_OPERATIONS = { organizationId: 'Organization identifier.', invitationId: 'Invitation identifier.', }, - responseMode: 'json', - summary: 'Get Organization Invitation', - workspaceKeyUnsupported: true, + responseMode: 'json', + summary: 'Get Organization Invitation', + workspaceKeyUnsupported: true, + }, + getOrganizationMemberUsageLimit: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/members/[userId]/usage-limit', + pathParams: ['organizationId', 'userId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + userId: + 'User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.', + }, + responseMode: 'json', + summary: 'Get Organization Member Credit Limit', + workspaceKeyUnsupported: true, + }, + getOrganizationUsageBreakdown: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/usage/breakdown', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, + responseMode: 'json', + summary: 'Get Organization Usage Breakdown', + workspaceKeyUnsupported: true, + query: { + preset: { + kind: 'enum', + values: ['current-period', 'previous-period', '7d', '30d', 'custom'] as const, + default: '30d', + describe: + 'Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.', + }, + startDate: { + kind: 'string', + describe: 'First calendar date included, in the selected timezone. Requires preset=custom.', + }, + endDate: { + kind: 'string', + describe: 'Last calendar date included, in the selected timezone. Requires preset=custom.', + }, + timezone: { + kind: 'string', + default: 'UTC', + describe: 'IANA timezone for calendar boundaries; defaults to UTC.', + }, + workspaceId: { + kind: 'string', + describe: 'Restrict usage to one workspace owned by the organization.', + }, + dimension: { + kind: 'enum', + required: true, + values: ['member', 'workspace', 'workflow', 'model', 'byok', 'source'] as const, + describe: 'Usage grouping dimension.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.', + }, + }, + }, + getOrganizationUsageSummary: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/usage/summary', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, + responseMode: 'json', + summary: 'Get Organization Usage Summary', + workspaceKeyUnsupported: true, + query: { + preset: { + kind: 'enum', + values: ['current-period', 'previous-period', '7d', '30d', 'custom'] as const, + default: '30d', + describe: + 'Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.', + }, + startDate: { + kind: 'string', + describe: 'First calendar date included, in the selected timezone. Requires preset=custom.', + }, + endDate: { + kind: 'string', + describe: 'Last calendar date included, in the selected timezone. Requires preset=custom.', + }, + timezone: { + kind: 'string', + default: 'UTC', + describe: 'IANA timezone for calendar boundaries; defaults to UTC.', + }, + workspaceId: { + kind: 'string', + describe: 'Restrict usage to one workspace owned by the organization.', + }, + }, }, getPermissionGroup: { method: 'GET', @@ -14821,6 +16992,15 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workspace Operation', }, + getWorkspacePermissionConfig: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/permission-config', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Unique workspace identifier.' }, + responseMode: 'json', + summary: 'Get Workspace Permission Config', + workspaceKeyUnsupported: true, + }, grantSkillEditor: { method: 'POST', path: '/api/v2/skills/[skillId]/editors', @@ -15887,6 +18067,128 @@ export const V2_OPERATIONS = { }, }, }, + listMyOrganizationAccessRequests: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/mine', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the access requests.' }, + responseMode: 'json', + summary: 'List My Organization Access Requests', + workspaceKeyUnsupported: true, + query: { + status: { + kind: 'enum', + values: ['pending', 'fulfilled', 'declined', 'cancelled', 'closed'] as const, + describe: 'Filter by request status; omit to include all statuses.', + }, + sortBy: { + kind: 'enum', + values: ['createdAt', 'targetLabel'] 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 access requests 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.', + }, + }, + }, + listMyWorkspaceAccessRequests: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/access-requests', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Workspace in which the acting user requests access.' }, + responseMode: 'json', + summary: 'List My Workspace Access Requests', + workspaceKeyUnsupported: true, + query: { + status: { + kind: 'enum', + values: ['pending', 'fulfilled', 'declined', 'cancelled', 'closed'] as const, + describe: 'Filter by request status; omit to include all statuses.', + }, + sortBy: { + kind: 'enum', + values: ['createdAt', 'targetLabel'] 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 access requests 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.', + }, + }, + }, + listOrganizationAccessRequests: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the access requests.' }, + responseMode: 'json', + summary: 'List Organization Access Requests', + workspaceKeyUnsupported: true, + query: { + status: { + kind: 'enum', + values: ['pending', 'fulfilled', 'declined', 'cancelled', 'closed'] as const, + describe: 'Filter by request status; omit to include all statuses.', + }, + sortBy: { + kind: 'enum', + values: ['createdAt', 'targetLabel'] 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 access requests 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.', + }, + search: { + kind: 'string', + describe: + 'Case-insensitive substring match against the target label or requester name or email.', + }, + }, + }, listOrganizationInvitations: { method: 'GET', path: '/api/v2/organizations/[organizationId]/invitations', @@ -15930,6 +18232,48 @@ export const V2_OPERATIONS = { }, }, }, + listOrganizationInvitationWorkspaces: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces', + pathParams: ['organizationId', 'invitationId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + invitationId: 'Invitation identifier.', + }, + responseMode: 'json', + summary: 'List Organization Invitation 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.', + }, + }, + }, listOrganizationMembers: { method: 'GET', path: '/api/v2/organizations/[organizationId]/members', @@ -16007,6 +18351,76 @@ export const V2_OPERATIONS = { }, }, }, + listOrganizationUsageEvents: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/usage/events', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization identifier.' }, + responseMode: 'json', + summary: 'List Organization Usage Events', + workspaceKeyUnsupported: true, + query: { + preset: { + kind: 'enum', + values: ['current-period', 'previous-period', '7d', '30d', 'custom'] as const, + default: '30d', + describe: + 'Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.', + }, + startDate: { + kind: 'string', + describe: 'First calendar date included, in the selected timezone. Requires preset=custom.', + }, + endDate: { + kind: 'string', + describe: 'Last calendar date included, in the selected timezone. Requires preset=custom.', + }, + timezone: { + kind: 'string', + default: 'UTC', + describe: 'IANA timezone for calendar boundaries; defaults to UTC.', + }, + source: { + kind: 'enum', + values: [ + 'workflow', + 'wand', + 'sim-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + 'voice-output', + 'api-tool', + ] as const, + describe: 'Restrict events to one product surface.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum usage events 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.', + }, + sortBy: { + kind: 'enum', + values: ['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.', + }, + }, + }, listOrganizationWorkspaces: { method: 'GET', path: '/api/v2/organizations/[organizationId]/workspaces', @@ -17052,6 +19466,18 @@ export const V2_OPERATIONS = { }, }, }, + previewOrganizationAccessRequest: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/access-requests/[requestId]/preview', + pathParams: ['organizationId', 'requestId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the access requests.', + requestId: 'Access request identifier.', + }, + responseMode: 'json', + summary: 'Preview Organization Access Request', + workspaceKeyUnsupported: true, + }, previewWorkflowImport: { method: 'POST', path: '/api/v2/workflows/import/preview', @@ -17587,6 +20013,77 @@ export const V2_OPERATIONS = { summary: 'Resend Organization Invitation', workspaceKeyUnsupported: true, }, + resolveOrganizationAccessRequest: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/access-requests/[requestId]/resolve', + pathParams: ['organizationId', 'requestId'] as const, + pathParamDocs: { + organizationId: 'Organization that owns the access requests.', + requestId: 'Access request identifier.', + }, + responseMode: 'json', + summary: 'Resolve Organization Access Request', + workspaceKeyUnsupported: true, + body: { + action: { + kind: 'enum', + required: true, + values: ['apply', 'decline'] as const, + describe: + 'apply: Apply the reviewed change to the governing group or member credit cap. decline: Decline the request without changing permissions or credit limits.', + }, + expectedFingerprint: { + kind: 'string', + describe: + 'Fingerprint from Preview Organization Access Request. Review its changes and impact before applying; a stale preview returns a conflict. Available when action is apply. Required when action is apply.', + }, + newLimitCredits: { + kind: 'integer', + describe: + 'Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests. Available when action is apply.', + }, + reason: { + kind: 'string', + describe: + 'Required explanation for declining this request. Available when action is decline. Required when action is decline.', + }, + }, + bodyDiscriminator: { + field: 'action', + variants: { + apply: { + action: { + kind: 'string', + required: true, + describe: 'Apply the reviewed change to the governing group or member credit cap.', + }, + expectedFingerprint: { + kind: 'string', + required: true, + describe: + 'Fingerprint from Preview Organization Access Request. Review its changes and impact before applying; a stale preview returns a conflict.', + }, + newLimitCredits: { + kind: 'integer', + describe: + 'Required only for a usage-limit request: a whole-number credit cap greater than the current cap. Omit for permission requests.', + }, + }, + decline: { + action: { + kind: 'string', + required: true, + describe: 'Decline the request without changing permissions or credit limits.', + }, + reason: { + kind: 'string', + required: true, + describe: 'Required explanation for declining this request.', + }, + }, + }, + }, + }, restoreFile: { method: 'POST', path: '/api/v2/files/[fileId]/restore', @@ -18392,6 +20889,23 @@ export const V2_OPERATIONS = { }, }, }, + updateOrganizationAccessRequestSettings: { + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/access-requests/settings', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization that owns the access requests.' }, + responseMode: 'json', + summary: 'Update Organization Access Request Settings', + workspaceKeyUnsupported: true, + body: { + allowRequests: { + kind: 'boolean', + required: true, + describe: + 'Allow new requests and approvals. Disabling requests preserves history and still allows cancellation and decline.', + }, + }, + }, updateOrganizationMember: { method: 'PATCH', path: '/api/v2/organizations/[organizationId]/members/[userId]', @@ -18412,6 +20926,28 @@ export const V2_OPERATIONS = { }, }, }, + updateOrganizationMemberUsageLimit: { + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/members/[userId]/usage-limit', + pathParams: ['organizationId', 'userId'] as const, + pathParamDocs: { + organizationId: 'Organization identifier.', + userId: + 'User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.', + }, + responseMode: 'json', + summary: 'Update Organization Member Credit Limit', + workspaceKeyUnsupported: true, + body: { + creditLimit: { + kind: 'integer', + nullable: true, + required: true, + describe: + 'Credit cap for this person. Send null to clear the cap or 0 to prevent further credit-consuming usage. Organization limits still apply.', + }, + }, + }, updatePermissionGroup: { method: 'PATCH', path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index d243006041e..d58ecb461a4 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1026,6 +1026,15 @@ describe('destructive operations are gated', () => { * decision on anything new. */ const NON_DESTRUCTIVE = new Set([ + /** These amend access or request history without discarding a resource. */ + 'cancelOrganizationAccessRequest', + 'cancelWorkspaceAccessRequest', + 'createOrganizationAccessRequest', + 'createWorkspaceAccessRequest', + 'createWorkspaceInvitations', + 'resolveOrganizationAccessRequest', + 'updateOrganizationAccessRequestSettings', + 'updateOrganizationMemberUsageLimit', 'createOrganizationInvitation', 'resendOrganizationInvitation', 'updateOrganizationMember', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 7a7e9ff20a6..cf7d3805f40 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -127,6 +127,127 @@ describe('commands parsed through commander', () => { profileState.workspaceId = 'ws_local' }) + describe('organization access-request decisions', () => { + it('sends apply flags through the generated operation without opaque JSON', async () => { + profileState.workspaceId = null + const [path, options] = await run( + [ + 'organizations', + 'access-requests', + 'resolve', + 'request-1', + '--organization', + 'org-1', + '--action', + 'apply', + '--expected-fingerprint', + 'preview', + '--new-limit-credits', + '100', + ], + { data: { id: 'request-1' } } + ) + expect(path).toBe('/api/v2/organizations/org-1/access-requests/request-1/resolve') + expect(options.body).toEqual({ + action: 'apply', + expectedFingerprint: 'preview', + newLimitCredits: 100, + }) + }) + + it('sends decline without requiring apply fields', async () => { + const [, options] = await run( + [ + 'organizations', + 'access-requests', + 'resolve', + 'request-1', + '--organization', + 'org-1', + '--action', + 'decline', + '--reason', + 'Not needed', + ], + { data: { id: 'request-1' } } + ) + expect(options.body).toEqual({ action: 'decline', reason: 'Not needed' }) + }) + + it('rejects flags from another decision branch before calling the API', async () => { + await expect( + run([ + 'organizations', + 'access-requests', + 'resolve', + 'request-1', + '--organization', + 'org-1', + '--action', + 'decline', + '--reason', + 'Not needed', + '--expected-fingerprint', + 'preview', + ]) + ).rejects.toThrow('--expected-fingerprint is not available when --action is decline') + expect(mockRequest).not.toHaveBeenCalled() + }) + }) + + describe('organization member credit caps', () => { + it.each([ + ['100', 100], + ['0', 0], + ['null', null], + ] as const)('sends --credit-limit %s without changing its meaning', async (value, expected) => { + profileState.workspaceId = null + const [path, options] = await run( + [ + 'organizations', + 'members', + 'usage-limit', + 'update', + 'user-1', + '--organization', + 'org-1', + '--credit-limit', + value, + ], + { data: { creditLimit: expected } } + ) + expect(path).toBe('/api/v2/organizations/org-1/members/user-1/usage-limit') + expect(options.body).toEqual({ creditLimit: expected }) + }) + + it.each(['many', '1.5', 'Infinity', ''])( + 'rejects an invalid credit cap %s before sending', + async (value) => { + await expect( + run([ + 'organizations', + 'members', + 'usage-limit', + 'update', + 'user-1', + '--organization', + 'org-1', + '--credit-limit', + value, + ]) + ).rejects.toThrow(/--credit-limit/) + expect(mockRequest).not.toHaveBeenCalled() + } + ) + + it('explains the numeric null spelling accurately in generated help', () => { + const help = commandAt('organizations', 'members', 'usage-limit', 'update').helpInformation() + expect(help).toContain('--credit-limit ') + expect(help).toMatch(/Send null to clear\s+the cap/) + expect(help).not.toContain('sends the word') + }) + }) + describe('permission groups', () => { it('lists organization groups without a workspace', async () => { profileState.workspaceId = null diff --git a/packages/sim-cli/src/runtime/discriminated-body.test.ts b/packages/sim-cli/src/runtime/discriminated-body.test.ts new file mode 100644 index 00000000000..149ae2165d4 --- /dev/null +++ b/packages/sim-cli/src/runtime/discriminated-body.test.ts @@ -0,0 +1,134 @@ +import { Command } from 'commander' +import { describe, expect, it, vi } from 'vitest' +import type { OperationSpec } from '#sim-cli/runtime/types' + +const { operation } = vi.hoisted(() => ({ + operation: { + method: 'POST', + path: '/api/v2/requests/[requestId]/resolve', + pathParams: ['requestId'], + body: { + action: { kind: 'enum', required: true, values: ['apply', 'decline'] }, + expectedFingerprint: { kind: 'string' }, + newLimitCredits: { kind: 'integer' }, + reason: { kind: 'string' }, + mode: { kind: 'enum', values: ['automatic', 'manual'] }, + }, + bodyDiscriminator: { + field: 'action', + variants: { + apply: { + action: { kind: 'string', required: true }, + expectedFingerprint: { kind: 'string', required: true }, + newLimitCredits: { kind: 'integer' }, + mode: { kind: 'enum', values: ['automatic'], default: 'automatic' }, + }, + decline: { + action: { kind: 'string', required: true }, + reason: { kind: 'string', required: true }, + mode: { kind: 'enum', values: ['manual'], default: 'manual' }, + }, + }, + }, + } satisfies OperationSpec, +})) + +vi.mock('#sim-cli/generated/v2-api', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, V2_OPERATIONS: { ...actual.V2_OPERATIONS, updateTable: operation } } +}) + +import { addOperationOptions } from '#sim-cli/runtime/options' +import { buildRequest } from '#sim-cli/runtime/request' + +function request(args: string[]) { + const command = new Command('resolve').exitOverride().configureOutput({ writeErr: () => {} }) + command.argument('') + addOperationOptions(command, 'updateTable', {}, operation) + command.parse(['request', ...args], { from: 'user' }) + return buildRequest('updateTable', command.args, command.opts(), null) +} + +describe('discriminated body flags', () => { + it('builds an apply request and coerces an optional credit cap', () => { + expect( + request([ + '--action', + 'apply', + '--expected-fingerprint', + 'preview', + '--new-limit-credits', + '100', + ]) + ).toEqual({ + path: '/api/v2/requests/request/resolve', + query: {}, + body: { action: 'apply', expectedFingerprint: 'preview', newLimitCredits: 100 }, + }) + }) + + it('builds a decline request without requiring apply fields', () => { + expect(request(['--action', 'decline', '--reason', 'Not needed']).body).toEqual({ + action: 'decline', + reason: 'Not needed', + }) + }) + + it('allows an apply request without the optional credit cap', () => { + expect(request(['--action', 'apply', '--expected-fingerprint', 'preview']).body).toEqual({ + action: 'apply', + expectedFingerprint: 'preview', + }) + }) + + it('leaves branch defaults to the server and validates shared enum flags against the selected branch', () => { + expect(request(['--action', 'decline', '--reason', 'No']).body).not.toHaveProperty('mode') + expect( + request(['--action', 'decline', '--reason', 'No', '--mode', 'manual']).body + ).toMatchObject({ mode: 'manual' }) + expect(() => request(['--action', 'decline', '--reason', 'No', '--mode', 'automatic'])).toThrow( + '--mode must be one of: manual' + ) + }) + + it.each([ + [['--action', 'apply'], '--expected-fingerprint is required'], + [['--action', 'decline'], '--reason is required'], + [ + ['--action', 'apply', '--expected-fingerprint', 'preview', '--reason', 'Unused'], + '--reason is not available when --action is apply', + ], + [ + ['--action', 'decline', '--reason', 'No', '--expected-fingerprint', 'preview'], + '--expected-fingerprint is not available when --action is decline', + ], + [ + ['--action', 'decline', '--reason', 'No', '--new-limit-credits', '100'], + '--new-limit-credits is not available when --action is decline', + ], + [ + ['--action', 'apply', '--expected-fingerprint', 'preview', '--new-limit-credits', '1.5'], + '--new-limit-credits must be a whole number', + ], + ])('refuses an invalid selected-branch request %j before sending', (args, error) => { + expect(() => request(args)).toThrow(error) + }) + + it('rejects unknown and missing discriminator values in direct request building', () => { + expect(() => buildRequest('updateTable', ['request'], {}, null)).toThrow('--action is required') + expect(() => buildRequest('updateTable', ['request'], { action: 'other' }, null)).toThrow( + '--action must be one of: apply, decline' + ) + }) + + it('advertises action choices and all branch flags without requiring opaque JSON', () => { + const command = new Command('resolve') + addOperationOptions(command, 'updateTable', {}, operation) + const help = command.helpInformation() + expect(help).toContain('--action ') + expect(help).toContain('--expected-fingerprint ') + expect(help).toContain('--new-limit-credits ') + expect(help).toContain('--reason ') + expect(help).not.toContain('--body') + }) +}) diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index bcd2f5de7cb..24216cb9ad8 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -5,7 +5,11 @@ import { readFileSync } from 'node:fs' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands' -import { type GetWorkspaceOperationResponse, V2_OPERATIONS } from '../generated/v2-api' +import { + type CreateWorkspaceInvitationsResponse, + type GetWorkspaceOperationResponse, + V2_OPERATIONS, +} from '../generated/v2-api' import { SimApiError } from '../http/client' import { BULK_OUTCOME_CHECKS, executeOperation } from './execute' import type { OperationSpec } from './types' @@ -553,6 +557,87 @@ const ADD_WORKSPACE_FILES: OperationSpec = { * failed. Both printed their own report of the miss and exited `0`, so * `sim … && next-step` ran on the strength of a no-op. */ +describe('workspace invitation batch outcomes', () => { + const receipt = { + id: 'invitation-1', + email: 'first@example.com', + workspaceIds: ['ws_local'], + permission: 'read', + membershipIntent: 'internal', + } as const + const empty: CreateWorkspaceInvitationsResponse['data'] = { + success: true, + successful: [], + added: [], + failed: [], + invitations: [], + } + + function invite() { + return executeOperation( + 'createWorkspaceInvitations', + CLI_CONTRACT.createWorkspaceInvitations!, + V2_OPERATIONS.createWorkspaceInvitations, + [{ emails: ['first@example.com', 'second@example.com'] }, new Command('leaf')] + ) + } + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([false, true])( + 'prints every result and exits nonzero on a partial=%s failure', + async (partial) => { + const payload = { + ...empty, + success: false, + successful: partial ? ['first@example.com'] : [], + invitations: partial ? [receipt] : [], + failed: [{ email: 'second@example.com', error: 'Unable to invite this recipient' }], + } + request.mockResolvedValue({ data: payload }) + await expect(invite()).rejects.toThrow( + 'Invitation batch failed for 1 recipient. Successful results remain committed; inspect failed recipients before retrying.' + ) + expect(JSON.parse(vi.mocked(console.log).mock.calls[0][0])).toEqual(payload) + expect(request).toHaveBeenCalledTimes(1) + } + ) + + it.each(['added', 'updated', 'unchanged'] as const)( + 'accepts successful direct grants with outcome %s even without a new invitation', + async (outcome) => { + request.mockResolvedValue({ + data: { + ...empty, + invitations: [{ ...receipt, instantAdd: true, outcome }], + added: outcome === 'added' ? ['first@example.com'] : [], + }, + }) + await expect(invite()).resolves.toBeUndefined() + } + ) + + it('accepts successful pending invitations', async () => { + request.mockResolvedValue({ + data: { ...empty, successful: ['first@example.com'], invitations: [receipt] }, + }) + await expect(invite()).resolves.toBeUndefined() + }) + + it('preserves HTTP failures without reporting a successful batch or retrying', async () => { + const failure = new SimApiError('Organization access denied', 403) + request.mockRejectedValue(failure) + await expect(invite()).rejects.toBe(failure) + expect(console.log).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledTimes(1) + }) +}) + describe('a bulk call that touched nothing', () => { function updateChunks(flags: Record) { const host = new Command('leaf') diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 06f78715585..6127ffd8792 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -99,7 +99,7 @@ export function runFailureMessage(operation: V2OperationName, payload: unknown): * `sim tables batch-delete --table-ids '["tbl_typo"]'` indistinguishable from a * real deletion in a CI step. * - * Only a total miss fails. A partial success still exits `0`: the payload names + * Most checks fail only a total miss. A partial success still exits `0`: the payload names * every item that did not make it, and failing the process there would break * every caller that legitimately sweeps a list containing already-gone items. */ @@ -117,6 +117,12 @@ type BulkOutcomeCheck = ( ) => string | null export const BULK_OUTCOME_CHECKS: Readonly>> = { + /** Invitation batches explicitly promise success only when every recipient succeeds. */ + createWorkspaceInvitations: (payload) => { + const failed = lengthOf(payload.failed) + if (failed === 0 && payload.success !== false) return null + return `${failed > 0 ? `Invitation batch failed for ${failed} ${failed === 1 ? 'recipient' : 'recipients'}.` : 'Invitation batch failed.'} Successful results remain committed; inspect failed recipients before retrying.` + }, bulkDeleteFiles: (payload, body) => { if (countOf((payload.deletedItems as { files?: unknown } | undefined)?.files) > 0) return null const requested = lengthOf(body?.fileIds) diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 555aa921ac1..e3b2a8201c2 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -202,13 +202,15 @@ function addFieldOption( ? '' : wantsJson ? '' - : '' + : descriptor.nullable + ? '' + : '' const choices = flag.choices ?? descriptor.values /** * Only a body field reaches the wire as JSON, and only a plain scalar flag is * stuck with the literal: a `` flag parses `null` into the value. */ - const literalNull = slot === 'body' && !takesList && !wantsJson + const literalNull = slot === 'body' && !takesList && !wantsJson && !descriptor.nullable const describe = `${documented}${ takesList ? flag.manifest diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 69206c97fc8..06f325817ca 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -134,6 +134,12 @@ describe('buildRequest', () => { }) }) + it('preserves the literal word null on string flags', () => { + expect( + buildRequest('updateWorkflow', ['wf_1'], { description: 'null' }, WORKSPACE).body + ).toEqual({ description: 'null' }) + }) + describe('failures, all before any network call', () => { it('rejects a missing path arg', () => { expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') @@ -271,6 +277,15 @@ describe('buildRequest', () => { ) }) + it.each(['Infinity', '-Infinity', '1e999'])( + 'rejects non-finite numeric input %s before JSON can turn it into null', + (minCost) => { + expect(() => buildRequest('listLogs', [], { minCost }, WORKSPACE)).toThrow( + '--min-cost must be a finite number' + ) + } + ) + it('explains an unset workspace in terms of how to set one', () => { expect(() => buildRequest('listTables', [], {}, null)).toThrow(SimApiError) expect(() => buildRequest('listTables', [], {}, null)).toThrow( diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index a288f4a6e1f..c8e197c0238 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -10,6 +10,8 @@ import type { OperationSpec } from './types' export interface FieldSpec { kind: 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'array' | 'object' | 'unknown' required?: boolean + /** Numeric fields can represent JSON null without changing literal string flags. */ + nullable?: true values?: readonly string[] default?: unknown /** The field's `.describe()` from the route contract, used as `--help` text. */ @@ -415,8 +417,11 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: } if (NUMERIC_KINDS.has(field.kind)) { + if (field.nullable && (raw === null || (typeof raw === 'string' && raw.trim() === 'null'))) + return null const value = Number(raw) if (Number.isNaN(value)) throw new SimApiError(`--${flagName} must be a number`, 0) + if (!Number.isFinite(value)) throw new SimApiError(`--${flagName} must be a finite number`, 0) /** * An `integer` field said so in the contract, and every other constraint on * one is already refused here by hand. Leaving integrality to the server @@ -508,15 +513,7 @@ export function buildRequest( workspaceId: string | null ): BuiltRequest { const commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {} - const spec = V2_OPERATIONS[operation] as { - method: string - path: string - pathParams: readonly string[] - query?: Record - body?: Record - headers?: Record - opaqueBody?: boolean - } + const spec: OperationSpec = V2_OPERATIONS[operation] let path = spec.path let positionalIndex = 0 @@ -557,9 +554,37 @@ export function buildRequest( * gets that message instead of the generic refusal below. */ const paginatedLimit = cursorSlot(spec) !== null + let bodyFields = spec.body + if (spec.bodyDiscriminator) { + const { field, variants } = spec.bodyDiscriminator + const flagName = flagNameFor(operation, field) + const descriptor = spec.body?.[field] + if (!descriptor) throw new Error(`Missing body discriminator field: ${field}`) + const flag = flagSpecFor(operation, field) + const value = coerce( + flags[camel(flagName)] ?? flag.requestDefault ?? descriptor.default, + descriptor, + flag, + flagName + ) + if (value === undefined) throw new SimApiError(`--${flagName} is required`, 0) + if (typeof value !== 'string' || !Object.hasOwn(variants, value)) + throw new SimApiError(`--${flagName} must be one of: ${Object.keys(variants).join(', ')}`, 0) + bodyFields = variants[value] + for (const candidate of Object.keys(spec.body ?? {})) { + const candidateFlag = flagNameFor(operation, candidate) + if (!Object.hasOwn(bodyFields, candidate) && flags[camel(candidateFlag)] !== undefined) + throw new SimApiError( + `--${candidateFlag} is not available when --${flagName} is ${value}`, + 0 + ) + } + } for (const slot of ['query', 'body', 'headers'] as const) { - for (const [field, descriptor] of Object.entries(spec[slot] ?? {})) { + for (const [field, descriptor] of Object.entries( + (slot === 'body' ? bodyFields : spec[slot]) ?? {} + )) { const flag = flagSpecFor(operation, field) if (flag.omit) continue diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts index 51ea1cd7744..ba115d3d19c 100644 --- a/packages/sim-cli/src/runtime/types.ts +++ b/packages/sim-cli/src/runtime/types.ts @@ -9,6 +9,11 @@ export interface OperationSpec { pathParamDocs?: Record query?: Record body?: Record + /** Selects the body fields and their requirements from the caller's discriminator flag. */ + bodyDiscriminator?: { + field: string + variants: Record> + } /** Contract-declared request headers, minus any the CLI sets itself. */ headers?: Record opaqueBody?: boolean diff --git a/scripts/generate-v2-cli-api.test.ts b/scripts/generate-v2-cli-api.test.ts index ee075d37002..da515b652fc 100644 --- a/scripts/generate-v2-cli-api.test.ts +++ b/scripts/generate-v2-cli-api.test.ts @@ -3,17 +3,124 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { z } from 'zod' -import { CLI_MANAGED_HEADERS, loadSummaries, renderSlotMap } from './generate-v2-cli-api' +import { + CLI_MANAGED_HEADERS, + loadSummaries, + render, + renderBodyDiscriminator, + renderSlotMap, +} from './generate-v2-cli-api' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +describe('discriminated object request bodies', () => { + const schema = z.discriminatedUnion('action', [ + z + .object({ + action: z.literal('apply'), + expectedFingerprint: z.string().describe('Fingerprint from preview.'), + newLimitCredits: z.number().int().optional(), + }) + .strict(), + z.object({ action: z.literal('decline'), reason: z.string() }).strict(), + ]) + + it('exposes every branch field while requiring only the discriminator before selection', () => { + const map = renderSlotMap(schema, ' ') + expect(map).toContain( + '"action": { kind: \'enum\', required: true, values: ["apply", "decline"]' + ) + expect(map).toContain( + '"expectedFingerprint": { kind: \'string\', describe: "Fingerprint from preview. Available when action is apply. Required when action is apply." }' + ) + expect(map).toContain('"newLimitCredits": { kind: \'integer\'') + expect(map).toContain( + '"reason": { kind: \'string\', describe: "Available when action is decline. Required when action is decline." }' + ) + expect(map?.match(/required: true/g)).toHaveLength(1) + }) + + it('emits branch requirements and does not require an opaque JSON body', () => { + const metadata = renderBodyDiscriminator(schema, ' ') + expect(metadata).toContain('field: "action"') + expect(metadata).toContain('"expectedFingerprint": { kind: \'string\', required: true') + expect(metadata).toContain('"reason": { kind: \'string\', required: true') + const source = render( + [ + { + name: 'resolveRequest', + exportName: 'v2ResolveRequestContract', + domain: 'requests', + contract: { + method: 'POST', + path: '/api/v2/requests/[requestId]/resolve', + body: schema, + response: { mode: 'json', schema: z.object({ data: z.object({ id: z.string() }) }) }, + }, + }, + ], + new Map() + ) + expect(source).toContain('bodyDiscriminator:') + expect(source).not.toContain('opaqueBody: true') + }) + + it('unites shared enum choices without losing branch-specific validation', () => { + const body = z.discriminatedUnion('action', [ + z.object({ action: z.literal('first'), mode: z.enum(['a', 'b']).default('a') }), + z.object({ action: z.literal('second'), mode: z.enum(['b', 'c']).default('c') }), + ]) + expect(renderSlotMap(body, ' ')).toContain('values: ["a", "b", "c"]') + expect(renderSlotMap(body, ' ')).not.toContain('default:') + expect(renderBodyDiscriminator(body, ' ')).toContain('values: ["b", "c"]') + expect(renderBodyDiscriminator(body, ' ')).toContain('default: "a"') + expect(renderBodyDiscriminator(body, ' ')).toContain('default: "c"') + }) + + it('resolves named branches and fields while preserving discriminator descriptions', () => { + const fingerprint = z.string().meta({ id: 'Fingerprint' }) + const body = z.discriminatedUnion('action', [ + z + .object({ action: z.literal('apply').describe('Apply the change.'), fingerprint }) + .meta({ id: 'ApplyDecision' }), + z + .object({ + action: z.literal('decline').describe('Decline the request.'), + reason: z.string(), + }) + .meta({ id: 'DeclineDecision' }), + ]) + const map = renderSlotMap(body, ' ') + expect(map).toContain('"fingerprint": { kind: \'string\'') + expect(map).toContain('describe: "apply: Apply the change. decline: Decline the request."') + expect(renderBodyDiscriminator(body, ' ')).toContain( + '"fingerprint": { kind: \'string\', required: true' + ) + }) + + it('preserves opaque single-row and batch unions and their shared workspace field', () => { + const body = z.union([ + z.object({ workspaceId: z.string(), data: z.record(z.string(), z.unknown()) }), + z.object({ workspaceId: z.string(), rows: z.array(z.record(z.string(), z.unknown())) }), + ]) + expect(renderBodyDiscriminator(body, ' ')).toBeNull() + expect(renderSlotMap(body, ' ')).toBe( + '{\n "workspaceId": { kind: \'string\', required: true },\n }' + ) + }) + + it('keeps incompatible flag shapes on the existing opaque body path', () => { + const body = z.discriminatedUnion('action', [ + z.object({ action: z.literal('first'), value: z.string() }), + z.object({ action: z.literal('second'), value: z.object({ id: z.string() }) }), + ]) + expect(renderBodyDiscriminator(body, ' ')).toBeNull() + }) +}) + describe('a field the contract types as nullable', () => { /** - * The operation table describes what the CLI can build a flag from, and a flag - * that sends JSON `null` is not one of them: `--no-` already means "send - * this boolean as false" on 37 flags, and `--description ''` is how a string - * is emptied. Emitting the nullability invited a second meaning for one - * spelling, so it is no longer carried. + * String flags preserve their literal value; numeric flags have an unambiguous null spelling. */ it('describes it no differently from any other string', () => { const map = renderSlotMap( @@ -23,6 +130,15 @@ describe('a field the contract types as nullable', () => { expect(map).toContain("kind: 'string'") expect(map).not.toContain('nullable') }) + + it.each([z.number(), z.number().int()])( + 'carries numeric nullability through the descriptor', + (value) => { + const map = renderSlotMap(z.object({ creditLimit: value.nullable() }), ' ') + expect(map).toContain('nullable: true, required: true') + expect(map).toContain(`kind: '${value.isInt ? 'integer' : 'number'}'`) + } + ) }) describe('request headers reaching the CLI as flags', () => { diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 0a34d2409ad..fc6cce672f0 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -428,7 +428,7 @@ function fieldKind(schema: JsonSchema): FieldKind { * these at startup to construct commands — a type alone cannot be walked. */ /** - * Whether the slot is a union, whose branches the CLI cannot turn into flags. + * Whether the slot is a union, which needs JSON unless its discriminator selects known fields. * * Distinct from "the map came out empty": the shared fields of a union are * emitted as a map, so emptiness alone no longer identifies one, and the @@ -439,6 +439,46 @@ function isUnionSlot(schema: z.ZodType): boolean { return Object.keys(json.properties ?? {}).length === 0 && Boolean(json.anyOf ?? json.oneOf) } +/** Object branches with explicit string tags can expose fields as ordinary CLI flags. */ +function discriminatedSlot(schema: z.ZodType) { + if (!(schema instanceof z.ZodDiscriminatedUnion)) return null + const discriminator = schema.def.discriminator + const variants: Array<{ value: string; schema: z.ZodObject }> = [] + const fieldKinds = new Map() + for (const option of schema.options) { + if (!(option instanceof z.ZodObject)) return null + for (const [field, value] of Object.entries(option.shape)) { + if (field === discriminator) continue + const kind = fieldKind( + z.toJSONSchema(value as z.ZodType, { io: 'input', unrepresentable: 'any' }) as JsonSchema + ) + const previous = fieldKinds.get(field) + if (previous && previous !== kind) return null + fieldKinds.set(field, kind) + } + const tag = z.toJSONSchema(option.shape[discriminator], { + io: 'input', + unrepresentable: 'any', + }) as JsonSchema + const values: unknown[] = tag.enum ?? (tag.const !== undefined ? [tag.const] : []) + if (!values.length || values.some((value) => typeof value !== 'string')) return null + for (const value of values) variants.push({ value: value as string, schema: option }) + } + return { discriminator, variants } +} + +/** Branch field descriptors let request building enforce required and inapplicable flags. */ +export function renderBodyDiscriminator(schema: z.ZodType | undefined, indent: string) { + if (!schema) return null + const slot = discriminatedSlot(schema) + if (!slot) return null + const variants = slot.variants.map( + ({ value, schema: branch }) => + `${indent} ${JSON.stringify(value)}: ${renderSlotMap(branch, `${indent} `)},` + ) + return `{\n${indent} field: ${JSON.stringify(slot.discriminator)},\n${indent} variants: {\n${variants.join('\n')}\n${indent} },\n${indent}}` +} + /** * Headers the CLI sets itself, which must never become flags. * @@ -466,19 +506,68 @@ export function renderSlotMap( const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema let properties: Record = json.properties ?? {} let required = new Set(json.required ?? []) + const discriminated = discriminatedSlot(schema) + const defs = (json.$defs ?? {}) as Record + const deref = (value: JsonSchema): JsonSchema => { + let current = value + for (let depth = 0; typeof current.$ref === 'string' && depth < 10; depth++) { + const resolved = defs[current.$ref.replace('#/$defs/', '')] + if (!resolved) break + current = resolved + } + return current + } // A union has no properties of its own, but the fields every branch agrees on // are still known and still have to be sent — `workspaceId` is required by // both branches of the row-insert body and comes from the profile, so // dropping it left `tables rows create` rejected as invalid input. if (Object.keys(properties).length === 0) { - const branches = (json.anyOf ?? json.oneOf) as JsonSchema[] | undefined + const branches = ((json.anyOf ?? json.oneOf) as JsonSchema[] | undefined)?.map(deref) if (branches?.length) { const shared = branches.reduce( (keys, branch) => keys.filter((key) => branch.properties?.[key] !== undefined), Object.keys(branches[0].properties ?? {}) ) - properties = Object.fromEntries(shared.map((key) => [key, branches[0].properties[key]])) + const fields = discriminated + ? [...new Set(branches.flatMap((branch) => Object.keys(branch.properties ?? {})))] + : shared + properties = Object.fromEntries( + fields.map((key) => { + const candidates = branches.flatMap((branch) => + branch.properties?.[key] ? [deref(branch.properties[key] as JsonSchema)] : [] + ) + if (discriminated && key === discriminated.discriminator) { + const description = discriminated.variants + .flatMap(({ value, schema: branch }) => { + const text = branch.shape[key].description + return text ? [`${value}: ${text}`] : [] + }) + .join(' ') + return [ + key, + { + type: 'string', + enum: discriminated.variants.map(({ value }) => value), + description, + }, + ] + } + const property = { ...candidates[0] } + if (discriminated) { + if (candidates.every((candidate) => candidate.enum)) + property.enum = [...new Set(candidates.flatMap((candidate) => candidate.enum))] + if ( + candidates.some( + (candidate) => + JSON.stringify(candidate.default) !== JSON.stringify(property.default) + ) + ) + property.default = undefined + } + return [key, property] + }) + ) required = new Set(shared.filter((key) => branches.every((b) => b.required?.includes(key)))) } } @@ -490,24 +579,18 @@ export function renderSlotMap( // JSON flag instead. if (keys.length === 0) return null - // A schema carrying `.meta({ id })` is lifted into `$defs` and referenced, so - // the property here is a bare `$ref` with no type to classify. Left - // unresolved every such field reads as `unknown` and the CLI demands JSON for - // what is really a plain string flag. - const defs = (json.$defs ?? {}) as Record - const deref = (schema: JsonSchema): JsonSchema => { - let current = schema - for (let depth = 0; typeof current.$ref === 'string' && depth < 10; depth++) { - const resolved = defs[current.$ref.replace('#/$defs/', '')] - if (!resolved) break - current = resolved - } - return current - } - const lines = keys.map((key) => { const property = deref(properties[key]) - const parts = [`kind: '${fieldKind(property)}'`] + const kind = fieldKind(property) + const parts = [`kind: '${kind}'`] + if ( + (kind === 'number' || kind === 'integer') && + ((Array.isArray(property.type) && property.type.includes('null')) || + (property.anyOf ?? property.oneOf ?? []).some( + (variant: JsonSchema) => deref(variant).type === 'null' + )) + ) + parts.push('nullable: true') if (required.has(key)) parts.push('required: true') if (property.enum) { parts.push( @@ -521,8 +604,22 @@ export function renderSlotMap( // reader as "Set sort by". Read from the reference site first: a field that // narrows a shared `$defs` schema describes its own use of it. const description = properties[key].description ?? property.description - if (typeof description === 'string' && description.trim()) { - parts.push(`describe: ${JSON.stringify(description.trim())}`) + const descriptions = + typeof description === 'string' && description.trim() ? [description.trim()] : [] + if (discriminated && key !== discriminated.discriminator) { + const applicable = discriminated.variants.filter(({ schema: branch }) => key in branch.shape) + const requiredFor = applicable.filter(({ schema: branch }) => !branch.shape[key].isOptional()) + if (applicable.length < discriminated.variants.length) + descriptions.push( + `Available when ${discriminated.discriminator} is ${applicable.map(({ value }) => value).join(' or ')}.` + ) + if (requiredFor.length && !required.has(key)) + descriptions.push( + `Required when ${discriminated.discriminator} is ${requiredFor.map(({ value }) => value).join(' or ')}.` + ) + } + if (descriptions.length) { + parts.push(`describe: ${JSON.stringify(descriptions.join(' '))}`) } return `${indent} ${JSON.stringify(key)}: { ${parts.join(', ')} },` }) @@ -530,7 +627,7 @@ export function renderSlotMap( return `{\n${lines.join('\n')}\n${indent}}` } -function render(operations: Operation[], docs: Map): string { +export function render(operations: Operation[], docs: Map): string { const out: string[] = [] out.push('/**') @@ -579,8 +676,8 @@ function render(operations: Operation[], docs: Map): strin out.push(' * `query`, `body`, and `headers` describe each field well enough for the CLI') out.push(' * to build a flag for it and coerce the string argv gives back: its kind,') out.push(' * whether it is required, its enum values, and its server-side default. A slot') - out.push(' * the contract does not declare — or one whose shape is a union with no flat') - out.push(' * field list — is absent, and the runtime falls back to taking it as JSON.') + out.push(' * the contract does not declare is absent. Discriminated bodies carry branch') + out.push(' * field maps; other unions fall back to taking their variant data as JSON.') out.push(' * Headers the CLI sets itself, such as the API key, are never listed.') out.push(' *') out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI") @@ -616,8 +713,10 @@ function render(operations: Operation[], docs: Map): strin // Absence alone cannot say so: it means both "no body" and "a body the // generator could not describe", and reading it as the former left // `tables rows create` unable to send anything at all. - if (slot === 'body' && op.contract.body && isUnionSlot(op.contract.body)) { - out.push(` opaqueBody: true,`) + if (slot === 'body' && op.contract.body) { + const discriminator = renderBodyDiscriminator(op.contract.body, ' ') + if (discriminator) out.push(` bodyDiscriminator: ${discriminator},`) + else if (isUnionSlot(op.contract.body)) out.push(` opaqueBody: true,`) } } // Contract headers are request input like any other slot: `upload-token` diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 4f4e860275c..8b46f87bc88 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', 71], + ['apps/docs/openapi-v2-resources.json', 92], ]) const generatedDocuments = new Map<(typeof DOCUMENTS)[number], JsonObject>() @@ -310,7 +310,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(267) + expect(totalOperations).toBe(288) }) it('documents mixed workflow execution and resume responses', () => {