feat(api): add v2 resource management endpoints (#6900)

* feat(api): add v2 resource management endpoints

* fix(cli): gate destructive v2 commands

* fix(test): update v2 request-slice count

* feat(cli): add shared workspace profiles
This commit is contained in:
Theodore Li
2026-08-20 18:55:57 -04:00
committed by GitHub
parent 85290693c4
commit a99f61bee9
59 changed files with 7779 additions and 540 deletions
@@ -53,6 +53,20 @@ sim workflows list --workspace ws_other
`sim login --workspace <id>` preselects a workspace in the picker, and
re-logging into an existing profile preselects the one already configured.
To save another workspace without minting or copying another personal key, add
a workspace profile:
```bash
sim workspaces list
sim profile add acme --workspace ws_acme
sim --profile acme whoami
```
The new profile stores `auth_profile = default` and its own workspace. Omit
`--workspace` in an interactive terminal to choose from the workspaces the
active key can access; scripts must provide the workspace ID explicitly. The
picker is capped at 1,000 entries and asks for an explicit ID above that.
## Checking who you are
```bash
@@ -76,6 +90,12 @@ sim logout # remove the stored key
sim logout --all # remove the profile entirely, including its settings
```
A workspace profile that shares authentication cannot remove the shared key.
Remove only that local profile with `sim logout --all --profile <name>`, or log
out of the authentication profile named by the error message. Removing an
authentication profile entirely is refused until its workspace profiles are
removed, so it cannot leave dangling references.
<Callout type="warn">
`sim logout` removes the key from disk but does **not** revoke it. Revoke keys in
Sim under **Settings → API keys**.
@@ -116,9 +136,9 @@ jobs:
SIM_WORKSPACE: ${{ vars.SIM_WORKSPACE }}
```
## Several accounts at once
## Several accounts and workspaces
Each profile holds one identity and one set of defaults:
Use separate logins for separate identities or deployments:
```bash
sim login --profile dev --endpoint http://localhost:3000
@@ -128,6 +148,16 @@ sim workflows list --profile dev
sim workflows list --profile prod
```
Use workspace profiles when one personal key should target several workspaces:
```bash
sim profile add marketing --workspace ws_marketing
sim profile add support --workspace ws_support
sim workflows list --profile marketing
sim workflows list --profile support
```
See [Configuration](/cli/configuration) for how profiles are stored and resolved.
## Self-hosted and non-production deployments
+1 -8
View File
@@ -30,6 +30,7 @@ These apply to every command, and may be written before or after it.
| Group | Description |
| --- | --- |
| [`sim profiles`](/cli/profiles) | List profiles or add a workspace profile that shares a stored login |
| [`sim audit-logs`](/cli/audit-logs) | Manage audit logs |
| [`sim billing`](/cli/billing) | Manage billing |
| [`sim credentials`](/cli/credentials) | Manage credentials |
@@ -94,14 +95,6 @@ sim whoami [options]
</CommandTable>
## List the profiles defined in the config and credentials files
```bash
sim profiles
```
Also available as `sim profile`.
## Set a profile's endpoint, default workspace, or output format
```bash
@@ -9,8 +9,10 @@ for a single command.
## Profiles
A profile is one identity plus one set of defaults, in the style of the AWS CLI.
Select one with `-P`, `--profile`, or `SIM_PROFILE`:
A profile selects one set of defaults, in the style of the AWS CLI. It normally
uses its same-named stored identity, but a workspace profile can share another
profile's identity through `auth_profile`. Select one with `-P`, `--profile`, or
`SIM_PROFILE`:
```bash
sim workflows list --profile dev
@@ -23,6 +25,12 @@ The profile is named `default` when you do not pick one.
sim profiles # list them; * marks the active one
```
Add a profile for another workspace without creating or copying an API key:
```bash
sim profile add acme --workspace ws_acme
```
## Setting defaults
```bash
@@ -51,7 +59,7 @@ Each setting resolves independently, and the first match wins:
| --- | --- |
| 1 | Command-line flag — `--endpoint`, `--workspace`, `--output` |
| 2 | Environment — `SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT` |
| 3 | `~/.sim/config` and `~/.sim/credentials`, for the selected profile |
| 3 | `~/.sim/config` for the selected profile and `~/.sim/credentials` for its `auth_profile`, when set |
| 4 | Built-in default — `https://www.sim.ai` and `table` |
`sim whoami` prints the winning source for each setting:
@@ -74,6 +82,10 @@ output = table
[profile dev]
endpoint = http://localhost:3000
workspace = ws_local
[profile acme]
auth_profile = default
workspace = ws_acme
```
Keys live in `~/.sim/credentials`, written `0600`:
@@ -89,6 +101,10 @@ api_key = sim_…
Section naming follows the AWS convention: `[profile dev]` in config, `[dev]` in
credentials. The `default` profile is `[default]` in both.
`auth_profile` references one direct profile and shares only its endpoint and
API key; workspace and output remain local. References cannot be chained, and a
shared profile cannot also set its own endpoint or API key.
## Environment variables
| Variable | Effect |
@@ -121,6 +137,19 @@ sim configure --set-workspace ws_abc123
export SIM_WORKSPACE=ws_abc123
```
For a reusable selection, create a workspace profile backed by the current
stored login:
```bash
sim workspaces list
sim profile add acme --workspace ws_acme
sim --profile acme tables list
```
When `--workspace` is omitted in a terminal, `profile add` presents an
interactive picker, capped at 1,000 entries. It refuses environment-only keys
and endpoint overrides because those values would disappear in another shell.
`sim billing status`, `sim billing logs`, and `sim audit-logs list` accept
`--all-workspaces` to drop the filter instead. It cannot be combined with
`--workspace`.
+218
View File
@@ -213,6 +213,196 @@ sim knowledge create [options]
</CommandTable>
## Create knowledge connector
```bash
sim knowledge connectors create <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique knowledge base identifier. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--connector-type <value>` | Yes | Registered connector type. |
| `--credential-id <value>` | No | OAuth credential identifier for connectors that require OAuth. |
| `--api-key <value>` | No | Write-only API key for connectors that use API-key authentication. |
| `--source-config <json\|@file>` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). |
| `--sync-interval-minutes <value>` | No | Scheduled synchronization interval in minutes; zero disables scheduling. |
</CommandTable>
## Delete knowledge connector
```bash
sim knowledge connectors delete <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--delete-documents` | No | Also permanently delete documents produced by this connector. |
| `--no-delete-documents` | No | Send --delete-documents as false. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
</CommandTable>
## Get knowledge connector
```bash
sim knowledge connectors get <id> <connectorId>
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
## List knowledge connector documents
```bash
sim knowledge connectors documents list <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--include-excluded` | No | Include documents explicitly excluded by a user. |
| `--no-include-excluded` | No | Send --include-excluded as false. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
## List knowledge connectors
```bash
sim knowledge connectors list <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique knowledge base identifier. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--sort-by <value>` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
## Update knowledge connector
```bash
sim knowledge connectors update <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--source-config <json\|@file>` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). |
| `--sync-interval-minutes <value>` | No | New scheduled synchronization interval in minutes. |
| `--status <value>` | No | New connector state. Accepted values: `active`, `paused`. |
</CommandTable>
## Update knowledge connector documents
```bash
sim knowledge connectors documents update <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--operation <value>` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. |
| `--document <value...>` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). |
</CommandTable>
## Create a knowledge folder at a path
```bash
@@ -398,6 +588,34 @@ sim knowledge search [options]
</CommandTable>
## Sync knowledge connector
```bash
sim knowledge sync create <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--rehydrate` | No | Re-fetch and re-index every existing connector document. |
| `--no-rehydrate` | No | Send --rehydrate as false. |
</CommandTable>
## Update knowledge base
```bash
+1
View File
@@ -11,6 +11,7 @@
"troubleshooting",
"---Commands---",
"commands",
"profiles",
"audit-logs",
"billing",
"credentials",
@@ -0,0 +1,42 @@
---
title: Profiles
description: List profiles or add a workspace profile that shares a stored login — every subcommand, argument, and flag
---
import { CommandTable } from '@/components/ui/command-table'
`sim profiles` is also spelled `sim profile`.
Every command below also accepts the [global options](/cli/commands#global-options).
## List configured profiles
```bash
sim profiles list
```
## Add a workspace profile that shares the active stored login
```bash
sim profiles add <name> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `name` | Yes | Name for the new profile |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `-w, --workspace <id>` | No | Existing workspace to use; omit for an interactive picker. |
</CommandTable>
+381 -10
View File
@@ -80,16 +80,6 @@ sim whoami [options]
</CommandTable>
## sim profiles
List the profiles defined in the config and credentials files
```bash
sim profiles
```
Also available as `sim profile`.
## sim configure
Set a profile's endpoint, default workspace, or output format
@@ -111,6 +101,46 @@ sim configure [options]
</CommandTable>
## sim profiles
Also spelled `sim profile`.
### sim profiles list
List configured profiles
```bash
sim profiles list
```
### sim profiles add
Add a workspace profile that shares the active stored login
```bash
sim profiles add <name> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `name` | Yes | Name for the new profile |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `-w, --workspace <id>` | No | Existing workspace to use; omit for an interactive picker. |
</CommandTable>
## sim audit-logs
Also spelled `sim audit-log`.
@@ -1173,6 +1203,210 @@ sim knowledge create [options]
</CommandTable>
### sim knowledge connectors create
Create Knowledge Connector
```bash
sim knowledge connectors create <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique knowledge base identifier. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--connector-type <value>` | Yes | Registered connector type. |
| `--credential-id <value>` | No | OAuth credential identifier for connectors that require OAuth. |
| `--api-key <value>` | No | Write-only API key for connectors that use API-key authentication. |
| `--source-config <json\|@file>` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). |
| `--sync-interval-minutes <value>` | No | Scheduled synchronization interval in minutes; zero disables scheduling. |
</CommandTable>
### sim knowledge connectors delete
Delete Knowledge Connector
```bash
sim knowledge connectors delete <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--delete-documents` | No | Also permanently delete documents produced by this connector. |
| `--no-delete-documents` | No | Send --delete-documents as false. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
</CommandTable>
### sim knowledge connectors get
Get Knowledge Connector
```bash
sim knowledge connectors get <id> <connectorId>
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
### sim knowledge connectors documents list
List Knowledge Connector Documents
```bash
sim knowledge connectors documents list <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--include-excluded` | No | Include documents explicitly excluded by a user. |
| `--no-include-excluded` | No | Send --include-excluded as false. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
### sim knowledge connectors list
List Knowledge Connectors
```bash
sim knowledge connectors list <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique knowledge base identifier. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--sort-by <value>` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
### sim knowledge connectors update
Update Knowledge Connector
```bash
sim knowledge connectors update <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--source-config <json\|@file>` | No | Replacement source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). |
| `--sync-interval-minutes <value>` | No | New scheduled synchronization interval in minutes. |
| `--status <value>` | No | New connector state. Accepted values: `active`, `paused`. |
</CommandTable>
### sim knowledge connectors documents update
Update Knowledge Connector Documents
```bash
sim knowledge connectors documents update <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--operation <value>` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. |
| `--document <value...>` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). |
</CommandTable>
### sim knowledge folders create
Create a knowledge folder at a path
@@ -1376,6 +1610,36 @@ sim knowledge search [options]
</CommandTable>
### sim knowledge sync create
Sync Knowledge Connector
```bash
sim knowledge sync create <id> <connectorId> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Knowledge base that owns the connector. |
| `connectorId` | Yes | Connector selected for the operation. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--rehydrate` | No | Re-fetch and re-index every existing connector document. |
| `--no-rehydrate` | No | Send --rehydrate as false. |
</CommandTable>
### sim knowledge update
Update Knowledge Base
@@ -1886,6 +2150,93 @@ sim skills get <id>
</CommandTable>
### sim skills editors create
Grant Skill Editor
```bash
sim skills editors create <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--email <value>` | Yes | Email address of a current workspace member. |
</CommandTable>
### sim skills editors list
List Skill Editors
```bash
sim skills editors list <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--sort-by <value>` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
### sim skills editors delete
Revoke Skill Editor
```bash
sim skills editors delete <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--email <value>` | Yes | Email address of a current workspace member. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
</CommandTable>
### sim skills list
List Skills
@@ -3766,3 +4117,23 @@ sim workspaces members [options]
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
### sim workspaces list
List Workspaces
```bash
sim workspaces list [options]
```
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--sort-by <value>` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
+81
View File
@@ -69,6 +69,87 @@ sim skills get <id>
</CommandTable>
## Grant skill editor
```bash
sim skills editors create <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--email <value>` | Yes | Email address of a current workspace member. |
</CommandTable>
## List skill editors
```bash
sim skills editors list <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--sort-by <value>` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
## Revoke skill editor
```bash
sim skills editors delete <id> [options]
```
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `id` | Yes | Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--email <value>` | Yes | Email address of a current workspace member. |
| `-y, --yes` | Yes | Confirm this destructive operation. |
</CommandTable>
## List skills
```bash
@@ -30,3 +30,21 @@ sim workspaces members [options]
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
## List workspaces
```bash
sim workspaces list [options]
```
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--sort-by <value>` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
</CommandTable>
File diff suppressed because it is too large Load Diff
+635 -96
View File
@@ -52,6 +52,108 @@
}
],
"paths": {
"/api/v2/workspaces": {
"get": {
"operationId": "listWorkspaces",
"summary": "List Workspaces",
"description": "List active workspaces available to the API key with opaque cursor pagination. A personal API key sees every accessible workspace that permits personal API keys; a workspace API key sees only its bound workspace.",
"tags": ["Workspaces"],
"parameters": [
{
"name": "sortBy",
"in": "query",
"required": false,
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
"schema": {
"default": "createdAt",
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
"type": "string",
"enum": ["name", "createdAt", "updatedAt"]
}
},
{
"name": "sortOrder",
"in": "query",
"required": false,
"description": "Sort direction.",
"schema": {
"default": "desc",
"description": "Sort direction.",
"type": "string",
"enum": ["asc", "desc"]
}
},
{
"name": "limit",
"in": "query",
"required": false,
"description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.",
"schema": {
"default": 50,
"description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.",
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
{
"name": "cursor",
"in": "query",
"required": false,
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
"schema": {
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
"type": "string",
"minLength": 1
}
}
],
"responses": {
"200": {
"description": "Public metadata for workspaces available to the API key.",
"headers": {
"X-RateLimit-Limit": {
"$ref": "#/components/headers/X-RateLimit-Limit"
},
"X-RateLimit-Remaining": {
"$ref": "#/components/headers/X-RateLimit-Remaining"
},
"X-RateLimit-Reset": {
"$ref": "#/components/headers/X-RateLimit-Reset"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListWorkspacesResponse"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"429": {
"$ref": "#/components/responses/RateLimited"
},
"500": {
"$ref": "#/components/responses/InternalError"
},
"503": {
"$ref": "#/components/responses/ServiceUnavailable"
}
}
}
},
"/api/v2/workspaces/{workspaceId}": {
"get": {
"operationId": "getWorkspace",
@@ -1145,6 +1247,316 @@
}
}
},
"/api/v2/skills/{id}/editors": {
"get": {
"operationId": "listSkillEditors",
"summary": "List Skill Editors",
"description": "List explicit skill editors and workspace administrators with opaque cursor pagination. Internal user and membership identifiers are never returned.",
"tags": ["Skills"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.",
"schema": {
"type": "string",
"minLength": 1,
"description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`."
}
},
{
"name": "workspaceId",
"in": "query",
"required": true,
"description": "Workspace that owns the skill.",
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "Workspace that owns the skill."
}
},
{
"name": "sortBy",
"in": "query",
"required": false,
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
"schema": {
"default": "email",
"description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.",
"type": "string",
"enum": ["email", "name"]
}
},
{
"name": "sortOrder",
"in": "query",
"required": false,
"description": "Sort direction.",
"schema": {
"default": "asc",
"description": "Sort direction.",
"type": "string",
"enum": ["asc", "desc"]
}
},
{
"name": "limit",
"in": "query",
"required": false,
"description": "Maximum skill editors to return per page. Must be a whole number from 1 to 100. Defaults to 50.",
"schema": {
"default": 50,
"description": "Maximum skill editors to return per page. Must be a whole number from 1 to 100. Defaults to 50.",
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
{
"name": "cursor",
"in": "query",
"required": false,
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
"schema": {
"description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.",
"type": "string",
"minLength": 1
}
}
],
"responses": {
"200": {
"description": "Users who can edit the skill.",
"headers": {
"X-RateLimit-Limit": {
"$ref": "#/components/headers/X-RateLimit-Limit"
},
"X-RateLimit-Remaining": {
"$ref": "#/components/headers/X-RateLimit-Remaining"
},
"X-RateLimit-Reset": {
"$ref": "#/components/headers/X-RateLimit-Reset"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListSkillEditorsResponse"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"429": {
"$ref": "#/components/responses/RateLimited"
},
"500": {
"$ref": "#/components/responses/InternalError"
},
"503": {
"$ref": "#/components/responses/ServiceUnavailable"
}
}
},
"post": {
"operationId": "grantSkillEditor",
"summary": "Grant Skill Editor",
"description": "Grant editor access to a current workspace member by email. The caller must already be a skill editor or workspace administrator. Workspace administrators already have derived editor access and cannot receive an explicit grant. A retried existing grant returns 200; a newly created grant returns 201. A workspace API key is rejected with `403`; use a personal API key.",
"tags": ["Skills"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.",
"schema": {
"type": "string",
"minLength": 1,
"description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`."
}
}
],
"requestBody": {
"required": true,
"description": "Workspace scope and email of the member to grant.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantSkillEditorRequest"
}
}
}
},
"responses": {
"200": {
"description": "The workspace member was already a skill editor.",
"headers": {
"X-RateLimit-Limit": {
"$ref": "#/components/headers/X-RateLimit-Limit"
},
"X-RateLimit-Remaining": {
"$ref": "#/components/headers/X-RateLimit-Remaining"
},
"X-RateLimit-Reset": {
"$ref": "#/components/headers/X-RateLimit-Reset"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantSkillEditorResponse"
}
}
}
},
"201": {
"description": "The skill editor grant was created.",
"headers": {
"X-RateLimit-Limit": {
"$ref": "#/components/headers/X-RateLimit-Limit"
},
"X-RateLimit-Remaining": {
"$ref": "#/components/headers/X-RateLimit-Remaining"
},
"X-RateLimit-Reset": {
"$ref": "#/components/headers/X-RateLimit-Reset"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantSkillEditorResponse"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/RateLimited"
},
"500": {
"$ref": "#/components/responses/InternalError"
},
"503": {
"$ref": "#/components/responses/ServiceUnavailable"
}
}
},
"delete": {
"operationId": "revokeSkillEditor",
"summary": "Revoke Skill Editor",
"description": "Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. A workspace API key is rejected with `403`; use a personal API key.",
"tags": ["Skills"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.",
"schema": {
"type": "string",
"minLength": 1,
"description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`."
}
},
{
"name": "workspaceId",
"in": "query",
"required": true,
"description": "Workspace that owns the skill.",
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "Workspace that owns the skill."
}
},
{
"name": "email",
"in": "query",
"required": true,
"description": "Email address of a current workspace member.",
"schema": {
"type": "string",
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
"description": "Email address of a current workspace member."
}
}
],
"responses": {
"200": {
"description": "The explicit editor grant was revoked.",
"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/RevokeSkillEditorResponse"
}
}
}
},
"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/custom-tools": {
"get": {
"operationId": "listCustomTools",
@@ -1720,11 +2132,11 @@
"post": {
"operationId": "createServiceAccountCredential",
"summary": "Create Service-Account Credential",
"description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.",
"description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider, then encode its required fields as the JSON object string in credentials. The credentials string is write-only and is never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.",
"tags": ["Credentials"],
"requestBody": {
"required": true,
"description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.",
"description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery.",
"content": {
"application/json": {
"schema": {
@@ -2670,6 +3082,49 @@
"title": "Workspace",
"description": "Public metadata for an accessible workspace."
},
"ListWorkspacesResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/V2Workspace"
},
"description": "Items in the current page."
},
"nextCursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
}
},
"required": ["data", "nextCursor"],
"additionalProperties": false,
"title": "List workspaces response",
"description": "Public metadata for workspaces available to the API key.",
"examples": [
{
"data": [
{
"id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
"name": "Engineering",
"color": "#33C482",
"logoUrl": null,
"memberCount": 14,
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-06-20T14:02:11.000Z"
}
],
"nextCursor": null
}
]
},
"GetWorkspaceResponse": {
"type": "object",
"properties": {
@@ -3755,6 +4210,178 @@
}
]
},
"V2SkillEditor": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
"description": "Email address of the skill editor."
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Display name of the skill editor."
},
"image": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Profile image URL of the skill editor."
},
"isWorkspaceAdmin": {
"type": "boolean",
"description": "Whether editor access is derived from workspace administration."
}
},
"required": ["email", "name", "image", "isWorkspaceAdmin"],
"additionalProperties": false,
"title": "Skill editor",
"description": "Public identity fields for a user who can edit a skill."
},
"ListSkillEditorsResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/V2SkillEditor"
},
"description": "Items in the current page."
},
"nextCursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself."
}
},
"required": ["data", "nextCursor"],
"additionalProperties": false,
"title": "List skill editors response",
"description": "Public identity fields for users who can edit the skill.",
"examples": [
{
"data": [
{
"email": "jane@example.com",
"name": "Jane Smith",
"image": null,
"isWorkspaceAdmin": false
}
],
"nextCursor": null
}
]
},
"GrantSkillEditorResponse": {
"type": "object",
"properties": {
"data": {
"description": "Response data.",
"$ref": "#/components/schemas/V2SkillEditor"
}
},
"required": ["data"],
"additionalProperties": false,
"title": "Grant skill editor response",
"description": "Public identity fields for the editor.",
"examples": [
{
"data": {
"email": "jane@example.com",
"name": "Jane Smith",
"image": null,
"isWorkspaceAdmin": false
}
}
]
},
"GrantSkillEditorRequest": {
"type": "object",
"properties": {
"workspaceId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "Workspace that owns the skill."
},
"email": {
"type": "string",
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
"description": "Email address of a current workspace member."
}
},
"required": ["workspaceId", "email"],
"additionalProperties": false,
"title": "Grant skill editor request",
"description": "Workspace scope and email of the member to grant.",
"examples": [
{
"workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
"email": "jane@example.com"
}
]
},
"V2SkillEditorDeleteData": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
"description": "Email address whose explicit editor grant was revoked."
},
"revoked": {
"type": "boolean",
"const": true,
"description": "Whether the explicit editor grant was revoked."
}
},
"required": ["email", "revoked"],
"additionalProperties": false,
"title": "Revoke skill editor data",
"description": "Skill editor revocation acknowledgement."
},
"RevokeSkillEditorResponse": {
"type": "object",
"properties": {
"data": {
"description": "Response data.",
"$ref": "#/components/schemas/V2SkillEditorDeleteData"
}
},
"required": ["data"],
"additionalProperties": false,
"title": "Revoke skill editor response",
"description": "Acknowledgement that the explicit editor grant was revoked.",
"examples": [
{
"data": {
"email": "jane@example.com",
"revoked": true
}
}
]
},
"V2CustomTool": {
"type": "object",
"properties": {
@@ -4810,106 +5437,18 @@
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"serviceAccountJson": {
"description": "Write-only Google service-account JSON key.",
"writeOnly": true,
"credentials": {
"type": "string",
"minLength": 1,
"maxLength": 65536
},
"apiToken": {
"description": "Write-only provider API token.",
"writeOnly": true,
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"domain": {
"description": "Provider account domain.",
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"signingSecret": {
"description": "Write-only webhook signing secret.",
"writeOnly": true,
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"botToken": {
"description": "Write-only bot token.",
"writeOnly": true,
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"clientId": {
"description": "OAuth client identifier.",
"type": "string",
"minLength": 1,
"maxLength": 512
},
"clientSecret": {
"description": "Write-only OAuth client secret.",
"writeOnly": true,
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"certificateId": {
"description": "Provider certificate mapping identifier.",
"type": "string",
"minLength": 1,
"maxLength": 512
},
"orgId": {
"description": "Provider organization ID.",
"type": "string",
"minLength": 1,
"maxLength": 255
},
"dataCenter": {
"description": "Provider data center.",
"type": "string",
"minLength": 1,
"maxLength": 32
},
"authMethod": {
"description": "Provider authentication method.",
"type": "string",
"minLength": 1,
"maxLength": 64
},
"privateKey": {
"description": "Write-only PEM private key.",
"writeOnly": true,
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"username": {
"description": "Provider run-as username.",
"type": "string",
"minLength": 1,
"maxLength": 255
"maxLength": 131072,
"description": "Write-only JSON object string containing the fields declared by credential-provider discovery.",
"writeOnly": true
}
},
"required": ["workspaceId", "type", "providerId"],
"required": ["workspaceId", "type", "providerId", "credentials"],
"additionalProperties": false,
"title": "Create service-account credential request",
"description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.",
"examples": [
{
"workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
"type": "service_account",
"providerId": "zoom-service-account",
"displayName": "Zoom automation",
"clientId": "YOUR_CLIENT_ID",
"clientSecret": "YOUR_CLIENT_SECRET",
"orgId": "YOUR_ACCOUNT_ID"
}
]
"description": "Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery."
},
"V2CredentialConnectionAuthorization": {
"type": "object",
+73 -237
View File
@@ -1,251 +1,87 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { skillMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { removeSkillMemberContract, upsertSkillMemberContract } from '@/lib/api/contracts/skills'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
listSkillMembersContract,
removeSkillMemberContract,
upsertSkillMemberContract,
} from '@/lib/api/contracts/skills'
import {
createInternalResourceConcealmentPolicy,
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { captureServerEvent } from '@/lib/posthog/server'
import { getSkillActorContext, listSkillEditors } from '@/lib/skills/access'
import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { skillOperations } from '@/lib/skills/application/operations'
import {
grantSkillEditorUseCase,
listSkillEditorsUseCase,
revokeSkillEditorUseCase,
} from '@/lib/skills/application/use-cases'
const logger = createLogger('SkillMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
type SkillEditorGate =
| { ok: true; workspaceId: string }
| { ok: false; reason: 'not-found' | 'not-editor' }
/**
* Resolves the skill and asserts the actor can edit it (explicit editor row or
* derived workspace admin). Skills the actor cannot reach at all (missing,
* builtin, no workspace, no workspace access) read as not-found;
* visible-but-not-editor reads as forbidden.
*/
async function requireSkillEditor(skillId: string, userId: string): Promise<SkillEditorGate> {
if (isBuiltinSkillId(skillId)) return { ok: false, reason: 'not-found' }
const actor = await getSkillActorContext(skillId, userId)
if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) {
return { ok: false, reason: 'not-found' }
}
if (!actor.canEdit) return { ok: false, reason: 'not-editor' }
return { ok: true, workspaceId: actor.skill.workspaceId }
}
function skillEditorGateResponse(reason: 'not-found' | 'not-editor'): NextResponse {
return reason === 'not-found'
? NextResponse.json({ error: 'Not found' }, { status: 404 })
: NextResponse.json({ error: 'Skill editor access required' }, { status: 403 })
}
export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: skillId } = await context.params
if (isBuiltinSkillId(skillId)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const actor = await getSkillActorContext(skillId, session.user.id)
if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const editors = await listSkillEditors({
id: actor.skill.id,
workspaceId: actor.skill.workspaceId,
})
return NextResponse.json({ editors })
} catch (error) {
logger.error('Failed to fetch skill editors', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' })
const errorPolicy = createInternalResourceConcealmentPolicy({
base: internalOrchestrationErrorPolicy,
notFoundMessage: 'Not found',
})
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: skillId } = await context.params
const gate = await requireSkillEditor(skillId, session.user.id)
if (!gate.ok) {
logger.warn('Skill editor add denied', {
skillId,
actorId: session.user.id,
reason: gate.reason,
})
return skillEditorGateResponse(gate.reason)
}
const parsed = await parseRequest(upsertSkillMemberContract, request, context)
if (!parsed.success) return parsed.response
const { userId } = parsed.data.body
const targetWorkspacePerm = await getUserEntityPermissions(
userId,
'workspace',
gate.workspaceId
)
if (targetWorkspacePerm === null) {
return NextResponse.json({ error: 'User is not a member of this workspace' }, { status: 400 })
}
if (targetWorkspacePerm === 'admin') {
return NextResponse.json(
{ error: 'Workspace admins can always edit skills' },
{ status: 400 }
)
}
const [existing] = await db
.select({ id: skillMember.id })
.from(skillMember)
.where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId)))
.limit(1)
if (existing) {
return NextResponse.json({ success: true })
}
const now = new Date()
// Conflict-safe against a concurrent add racing the unique (skillId, userId) index.
const [inserted] = await db
.insert(skillMember)
.values({
id: generateId(),
skillId,
userId,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] })
.returning({ id: skillMember.id })
// A concurrent request won the race and created the row. The editor exists,
// so this is still a success — but this request added nothing, and emitting
// the share event or audit entry here would record an add that never happened.
if (!inserted) {
return NextResponse.json({ success: true })
}
export const GET = defineInternalJsonRoute({
contract: listSkillMembersContract,
auth: internalSessionAuth,
operation: skillOperations.listEditors,
rateLimit,
errorPolicy,
mapInput: ({ params }) => ({
skillId: params.id,
sortBy: 'email' as const,
sortOrder: 'asc' as const,
}),
useCase: listSkillEditorsUseCase,
present: ({ editors }) => ({ editors }),
})
export const POST = defineInternalJsonRoute({
contract: upsertSkillMemberContract,
auth: internalSessionAuth,
operation: skillOperations.grantEditor,
rateLimit,
errorPolicy,
mapInput: ({ params, body }) => ({
skillId: params.id,
target: { kind: 'user_id' as const, userId: body.userId },
}),
useCase: grantSkillEditorUseCase,
onSuccess: ({ principal, input, result }) => {
if (!result.created || principal.kind !== 'session') return
captureServerEvent(
session.user.id,
principal.userId,
'skill_shared',
{ skill_id: skillId, workspace_id: gate.workspaceId },
{ groups: { workspace: gate.workspaceId } }
{ skill_id: input.skillId, workspace_id: result.workspaceId },
{ groups: { workspace: result.workspaceId } }
)
recordAudit({
workspaceId: gate.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.SKILL_MEMBER_ADDED,
resourceType: AuditResourceType.SKILL,
resourceId: skillId,
description: 'Added skill editor',
metadata: { targetUserId: userId },
request,
})
return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add skill editor', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
},
present: () => ({ success: true as const }),
statusForResult: ({ created }) => (created ? 201 : 200),
})
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: skillId } = await context.params
const gate = await requireSkillEditor(skillId, session.user.id)
if (!gate.ok) {
logger.warn('Skill editor removal denied', {
skillId,
actorId: session.user.id,
reason: gate.reason,
})
return skillEditorGateResponse(gate.reason)
}
const parsed = await parseRequest(removeSkillMemberContract, request, context)
if (!parsed.success) return parsed.response
const { userId: targetUserId } = parsed.data.query
const targetWorkspacePerm = await getUserEntityPermissions(
targetUserId,
'workspace',
gate.workspaceId
)
if (targetWorkspacePerm === 'admin') {
return NextResponse.json(
{ error: 'Workspace admins can always edit skills' },
{ status: 400 }
)
}
// Hard delete — no deny markers and no last-editor guard: workspace admins
// always remain derived editors, so a skill can never be orphaned.
const removed = await db
.delete(skillMember)
.where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, targetUserId)))
.returning({ id: skillMember.id })
if (removed.length === 0) {
return NextResponse.json({ error: 'Editor not found' }, { status: 404 })
}
export const DELETE = defineInternalJsonRoute({
contract: removeSkillMemberContract,
auth: internalSessionAuth,
operation: skillOperations.revokeEditor,
rateLimit,
errorPolicy,
mapInput: ({ params, query }) => ({
skillId: params.id,
target: { kind: 'user_id' as const, userId: query.userId },
}),
useCase: revokeSkillEditorUseCase,
onSuccess: ({ principal, input, result }) => {
if (principal.kind !== 'session') return
captureServerEvent(
session.user.id,
principal.userId,
'skill_unshared',
{ skill_id: skillId, workspace_id: gate.workspaceId },
{ groups: { workspace: gate.workspaceId } }
{ skill_id: input.skillId, workspace_id: result.workspaceId },
{ groups: { workspace: result.workspaceId } }
)
recordAudit({
workspaceId: gate.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.SKILL_MEMBER_REMOVED,
resourceType: AuditResourceType.SKILL,
resourceId: skillId,
description: 'Removed skill editor',
metadata: { targetUserId },
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove skill editor', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
},
present: () => ({ success: true as const }),
})
+72 -6
View File
@@ -253,10 +253,11 @@ describe('POST /api/v2/credentials', () => {
type: 'service_account',
providerId: 'zoom-service-account',
displayName: 'Zoom account',
clientId: 'client-id',
clientSecret: 'client-secret',
certificateId: undefined,
orgId: 'account-id',
credentials: JSON.stringify({
clientId: 'client-id',
clientSecret: 'client-secret',
orgId: 'account-id',
}),
}),
})
const response = await POST(request)
@@ -277,7 +278,6 @@ describe('POST /api/v2/credentials', () => {
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
input: {
workspaceId: WORKSPACE_ID,
type: 'service_account',
providerId: 'zoom-service-account',
displayName: 'Zoom account',
description: undefined,
@@ -308,7 +308,7 @@ describe('POST /api/v2/credentials', () => {
workspaceId: WORKSPACE_ID,
type: 'service_account',
providerId: 'made-up-service-account',
serviceAccountJson: '{}',
credentials: JSON.stringify({ serviceAccountJson: '{}' }),
}),
})
)
@@ -316,4 +316,70 @@ describe('POST /api/v2/credentials', () => {
expect(response.status).toBe(400)
expect(mocks.create).not.toHaveBeenCalled()
})
it('rejects flattened provider fields before the use case', async () => {
const response = await POST(
new NextRequest('http://localhost:3000/api/v2/credentials', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: WORKSPACE_ID,
type: 'service_account',
providerId: 'zoom-service-account',
clientId: 'client-id',
clientSecret: 'client-secret',
orgId: 'account-id',
}),
})
)
expect(response.status).toBe(400)
expect(mocks.create).not.toHaveBeenCalled()
})
it.each([
['malformed JSON', '{'],
['a JSON array', '[]'],
['an unsupported field', JSON.stringify({ extra: 'not-accepted' })],
])('rejects credentials containing %s before the use case', async (_label, credentials) => {
const response = await POST(
new NextRequest('http://localhost:3000/api/v2/credentials', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: WORKSPACE_ID,
type: 'service_account',
providerId: 'zoom-service-account',
credentials,
}),
})
)
expect(response.status).toBe(400)
expect(mocks.create).not.toHaveBeenCalled()
})
it('rejects missing provider fields inside credentials before the use case', async () => {
const response = await POST(
new NextRequest('http://localhost:3000/api/v2/credentials', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: WORKSPACE_ID,
type: 'service_account',
providerId: 'zoom-service-account',
credentials: JSON.stringify({ clientId: 'client-id', clientSecret: 'client-secret' }),
}),
})
)
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({
error: {
code: 'BAD_REQUEST',
details: [{ path: ['credentials', 'orgId'] }],
},
})
expect(mocks.create).not.toHaveBeenCalled()
})
})
+8 -1
View File
@@ -72,7 +72,14 @@ export const POST = defineV2JsonRoute({
operation: credentialOperations.createServiceAccount,
rateLimit: v2RateLimits.publicApi,
errorPolicy: credentialWorkspaceErrorPolicy,
mapInput: ({ body }) => body,
mapInput: ({ body }) => ({
workspaceId: body.workspaceId,
providerId: body.providerId,
displayName: body.displayName,
description: body.description,
id: body.id,
...body.credentials,
}),
useCase: createServiceAccountCredentialUseCase,
present: ({ credential, hasServiceAccountKey, role }) => ({
data: toV2Credential({ ...credential, hasServiceAccountKey, role }),
@@ -0,0 +1,83 @@
import {
v2ListKnowledgeConnectorDocumentsContract,
v2UpdateKnowledgeConnectorDocumentsContract,
} from '@/lib/api/contracts/v2/knowledge'
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import {
listKnowledgeConnectorDocuments,
updateKnowledgeConnectorDocuments,
} from '@/lib/knowledge/application/connectors'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import { toV2KnowledgeConnectorDocument } from '@/app/api/v2/knowledge/connector-utils'
import { decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response'
const CONNECTOR_DOCUMENT_SORT = 'userExcluded:asc,filename:asc'
function connectorDocumentCursorScope(
knowledgeBaseId: string,
connectorId: string,
query: { workspaceId: string; includeExcluded: boolean }
) {
return cursorScopeKey(
cursorRoute(v2ListKnowledgeConnectorDocumentsContract, {
id: knowledgeBaseId,
connectorId,
}),
{
workspaceId: query.workspaceId,
includeExcluded: query.includeExcluded,
}
)
}
export const GET = defineV2JsonRoute({
contract: v2ListKnowledgeConnectorDocumentsContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.listConnectorDocuments,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
connectorId: params.connectorId,
assertedWorkspaceId: query.workspaceId,
includeExcluded: query.includeExcluded,
limit: query.limit,
offset: decodeOffsetCursor(
query.cursor,
CONNECTOR_DOCUMENT_SORT,
connectorDocumentCursorScope(params.id, params.connectorId, query)
),
}),
useCase: listKnowledgeConnectorDocuments,
present: ({ documents, hasMore, offset, limit }, { params, query }) => ({
data: documents.map(toV2KnowledgeConnectorDocument),
nextCursor: hasMore
? encodeOffsetCursor(
CONNECTOR_DOCUMENT_SORT,
connectorDocumentCursorScope(params.id, params.connectorId, query),
offset + limit
)
: null,
}),
})
export const PATCH = defineV2JsonRoute({
contract: v2UpdateKnowledgeConnectorDocumentsContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.updateConnectorDocuments,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, body }) => ({
knowledgeBaseId: params.id,
connectorId: params.connectorId,
assertedWorkspaceId: body.workspaceId,
operation: body.operation,
documentIds: body.documentIds,
}),
useCase: updateKnowledgeConnectorDocuments,
present: ({ operation, count, documentIds }) => ({
data: { operation, updatedCount: count, documentIds },
}),
})
@@ -0,0 +1,82 @@
import {
v2DeleteKnowledgeConnectorContract,
v2GetKnowledgeConnectorContract,
v2UpdateKnowledgeConnectorContract,
} from '@/lib/api/contracts/v2/knowledge'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { internalKnowledgeAnalytics } from '@/lib/knowledge/api/internal-route'
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import {
deleteKnowledgeConnector,
readKnowledgeConnector,
updateKnowledgeConnector,
} from '@/lib/knowledge/application/connectors'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import {
toV2KnowledgeConnector,
toV2KnowledgeConnectorDetail,
} from '@/app/api/v2/knowledge/connector-utils'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export const GET = defineV2JsonRoute({
contract: v2GetKnowledgeConnectorContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.readConnector,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
connectorId: params.connectorId,
assertedWorkspaceId: query.workspaceId,
}),
useCase: readKnowledgeConnector,
present: ({ connector }) => ({ data: toV2KnowledgeConnectorDetail(connector) }),
})
export const PATCH = defineV2JsonRoute({
contract: v2UpdateKnowledgeConnectorContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.updateConnector,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, body }) => ({
knowledgeBaseId: params.id,
connectorId: params.connectorId,
assertedWorkspaceId: body.workspaceId,
updates: {
sourceConfig: body.sourceConfig,
syncIntervalMinutes: body.syncIntervalMinutes,
status: body.status,
},
source: 'api' as const,
}),
useCase: updateKnowledgeConnector,
present: ({ connector }) => ({ data: toV2KnowledgeConnector(connector) }),
})
export const DELETE = defineV2JsonRoute({
contract: v2DeleteKnowledgeConnectorContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.deleteConnector,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
connectorId: params.connectorId,
assertedWorkspaceId: query.workspaceId,
deleteDocuments: query.deleteDocuments,
source: 'api' as const,
}),
useCase: deleteKnowledgeConnector,
onSuccess: internalKnowledgeAnalytics.connectorRemoved,
present: ({ connectorId, documentsDeleted, documentsKept }) => ({
data: {
id: connectorId,
deleted: true as const,
documentsDeleted,
documentsKept,
},
}),
})
@@ -0,0 +1,24 @@
import { v2SyncKnowledgeConnectorContract } from '@/lib/api/contracts/v2/knowledge'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { internalKnowledgeAnalytics } from '@/lib/knowledge/api/internal-route'
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import { syncKnowledgeConnector } from '@/lib/knowledge/application/connectors'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
export const POST = defineV2JsonRoute({
contract: v2SyncKnowledgeConnectorContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.syncConnector,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, body }) => ({
knowledgeBaseId: params.id,
connectorId: params.connectorId,
assertedWorkspaceId: body.workspaceId,
rehydrate: body.rehydrate,
source: 'api' as const,
}),
useCase: syncKnowledgeConnector,
onSuccess: internalKnowledgeAnalytics.connectorSynced,
present: ({ connectorId }) => ({ data: { id: connectorId, syncTriggered: true as const } }),
})
@@ -0,0 +1,324 @@
/**
* @vitest-environment node
*/
import {
V2_OPERATION_RATE_LIMIT_ALLOWED,
V2_PREAUTH_RATE_LIMIT_ALLOWED,
v2ApiKeyAuthModuleMock,
v2GateModuleMock,
v2RateLimiterModuleMock,
v2RouteMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
read: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
sync: vi.fn(),
listDocuments: vi.fn(),
updateDocuments: vi.fn(),
connectorAdded: vi.fn(),
connectorRemoved: vi.fn(),
connectorSynced: vi.fn(),
}))
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock)
vi.mock('@/lib/knowledge/api/internal-route', () => ({
internalKnowledgeAnalytics: {
connectorAdded: mocks.connectorAdded,
connectorRemoved: mocks.connectorRemoved,
connectorSynced: mocks.connectorSynced,
},
}))
vi.mock('@/lib/knowledge/application/connectors', () => ({
listKnowledgeConnectors: {
operation: { id: 'knowledge.connectors.list' },
execute: mocks.list,
},
createKnowledgeConnector: {
operation: { id: 'knowledge.connectors.create' },
execute: mocks.create,
},
readKnowledgeConnector: {
operation: { id: 'knowledge.connectors.read' },
execute: mocks.read,
},
updateKnowledgeConnector: {
operation: { id: 'knowledge.connectors.update' },
execute: mocks.update,
},
deleteKnowledgeConnector: {
operation: { id: 'knowledge.connectors.delete' },
execute: mocks.remove,
},
syncKnowledgeConnector: {
operation: { id: 'knowledge.connectors.sync' },
execute: mocks.sync,
},
listKnowledgeConnectorDocuments: {
operation: { id: 'knowledge.connectors.documents.list' },
execute: mocks.listDocuments,
},
updateKnowledgeConnectorDocuments: {
operation: { id: 'knowledge.connectors.documents.update' },
execute: mocks.updateDocuments,
},
}))
import {
GET as listConnectorDocuments,
PATCH as updateConnectorDocuments,
} from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/documents/route'
import {
DELETE as deleteConnector,
GET as getConnector,
PATCH as updateConnector,
} from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/route'
import { POST as syncConnector } from '@/app/api/v2/knowledge/[id]/connectors/[connectorId]/sync/route'
import {
POST as createConnector,
GET as listConnectors,
} from '@/app/api/v2/knowledge/[id]/connectors/route'
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
const KNOWLEDGE_BASE_ID = 'knowledge-1'
const CONNECTOR_ID = 'connector-1'
const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }
const AUTH = {
principal: PRINCIPAL,
rolloutUserId: 'user-1',
rateLimitSubjectIds: ['user:user-1'] as const,
rateLimitSubscription: null,
keyType: 'personal' as const,
}
const collectionContext = { params: Promise.resolve({ id: KNOWLEDGE_BASE_ID }) }
const connectorContext = {
params: Promise.resolve({ id: KNOWLEDGE_BASE_ID, connectorId: CONNECTOR_ID }),
}
const connector = {
id: CONNECTOR_ID,
knowledgeBaseId: KNOWLEDGE_BASE_ID,
connectorType: 'notion',
credentialId: 'credential-1',
sourceConfig: { pageIds: ['page-1'] },
syncMode: 'full',
syncIntervalMinutes: 1440,
status: 'active',
lastSyncAt: null,
lastSyncError: null,
lastSyncDocCount: null,
nextSyncAt: null,
consecutiveFailures: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-02T00:00:00Z'),
}
const document = {
id: 'document-1',
filename: 'Page one',
externalId: 'page-1',
sourceUrl: 'https://notion.so/page-1',
enabled: true,
userExcluded: false,
uploadedAt: new Date('2026-01-03T00:00:00Z'),
processingStatus: 'completed',
}
function request(path: string, method = 'GET', body?: unknown) {
return new NextRequest(`http://localhost:3000${path}`, {
method,
headers: {
'x-api-key': 'key',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
}
describe('v2 knowledge connector routes', () => {
beforeEach(() => {
vi.clearAllMocks()
v2RouteMocks.authenticate.mockResolvedValue(AUTH)
v2RouteMocks.gate.mockResolvedValue(null)
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
mocks.list.mockResolvedValue({
connectors: [connector],
hasMore: false,
offset: 0,
limit: 50,
})
mocks.create.mockResolvedValue({ connector, workspaceId: WORKSPACE_ID })
mocks.read.mockResolvedValue({ connector: { ...connector, syncLogs: [] } })
mocks.update.mockResolvedValue({ connector })
mocks.remove.mockResolvedValue({
knowledgeBaseId: KNOWLEDGE_BASE_ID,
workspaceId: WORKSPACE_ID,
connectorId: CONNECTOR_ID,
connectorType: connector.connectorType,
documentsDeleted: 0,
documentsKept: 1,
})
mocks.sync.mockResolvedValue({
knowledgeBaseId: KNOWLEDGE_BASE_ID,
workspaceId: WORKSPACE_ID,
connectorId: CONNECTOR_ID,
connectorType: connector.connectorType,
})
mocks.listDocuments.mockResolvedValue({
documents: [document],
counts: { active: 1, excluded: 0 },
hasMore: false,
offset: 0,
limit: 50,
})
mocks.updateDocuments.mockResolvedValue({
operation: 'exclude',
count: 1,
documentIds: [document.id],
})
})
it('lists connectors without secret fields', async () => {
const response = await listConnectors(
request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors?workspaceId=${WORKSPACE_ID}`),
collectionContext
)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.data[0]).toMatchObject({ id: CONNECTOR_ID, connectorType: 'notion' })
expect(body.data[0]).not.toHaveProperty('encryptedApiKey')
expect(body.nextCursor).toBeNull()
})
it('creates a connector with principal-derived billing and write-only API keys', async () => {
const response = await createConnector(
request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors`, 'POST', {
workspaceId: WORKSPACE_ID,
connectorType: 'notion',
apiKey: 'secret-value',
sourceConfig: { pageIds: ['page-1'] },
}),
collectionContext
)
expect(response.status).toBe(201)
expect((await response.json()).data).not.toHaveProperty('apiKey')
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.not.objectContaining({ resolveBillingAttribution: expect.anything() }),
})
)
expect(mocks.connectorAdded).toHaveBeenCalledOnce()
})
it('gets, updates, and deletes one connector', async () => {
const getResponse = await getConnector(
request(
`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}?workspaceId=${WORKSPACE_ID}`
),
connectorContext
)
expect(getResponse.status).toBe(200)
expect((await getResponse.json()).data.syncLogs).toEqual([])
const patchResponse = await updateConnector(
request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}`, 'PATCH', {
workspaceId: WORKSPACE_ID,
status: 'paused',
}),
connectorContext
)
expect(patchResponse.status).toBe(200)
expect(mocks.update).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
assertedWorkspaceId: WORKSPACE_ID,
updates: expect.objectContaining({ status: 'paused' }),
}),
})
)
const deleteResponse = await deleteConnector(
request(
`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}?workspaceId=${WORKSPACE_ID}&deleteDocuments=false`,
'DELETE'
),
connectorContext
)
expect(deleteResponse.status).toBe(200)
expect(await deleteResponse.json()).toEqual({
data: {
id: CONNECTOR_ID,
deleted: true,
documentsDeleted: 0,
documentsKept: 1,
},
})
expect(mocks.connectorRemoved).toHaveBeenCalledOnce()
})
it('queues connector synchronization without an adapter billing resolver', async () => {
const response = await syncConnector(
request(`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}/sync`, 'POST', {
workspaceId: WORKSPACE_ID,
rehydrate: true,
}),
connectorContext
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
data: { id: CONNECTOR_ID, syncTriggered: true },
})
expect(mocks.sync).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.not.objectContaining({ resolveBillingAttribution: expect.anything() }),
})
)
expect(mocks.connectorSynced).toHaveBeenCalledOnce()
})
it('lists and updates connector documents', async () => {
const getResponse = await listConnectorDocuments(
request(
`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}/documents?workspaceId=${WORKSPACE_ID}`
),
connectorContext
)
expect(getResponse.status).toBe(200)
expect((await getResponse.json()).data[0]).toEqual({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: true,
userExcluded: false,
createdAt: '2026-01-03T00:00:00.000Z',
processingStatus: 'completed',
})
const patchResponse = await updateConnectorDocuments(
request(
`/api/v2/knowledge/${KNOWLEDGE_BASE_ID}/connectors/${CONNECTOR_ID}/documents`,
'PATCH',
{
workspaceId: WORKSPACE_ID,
operation: 'exclude',
documentIds: [document.id],
}
),
connectorContext
)
expect(patchResponse.status).toBe(200)
expect(await patchResponse.json()).toEqual({
data: { operation: 'exclude', updatedCount: 1, documentIds: [document.id] },
})
})
})
@@ -0,0 +1,76 @@
import {
v2CreateKnowledgeConnectorContract,
v2ListKnowledgeConnectorsContract,
} from '@/lib/api/contracts/v2/knowledge'
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { internalKnowledgeAnalytics } from '@/lib/knowledge/api/internal-route'
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import {
createKnowledgeConnector,
listKnowledgeConnectors,
} from '@/lib/knowledge/application/connectors'
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import { toV2KnowledgeConnector } from '@/app/api/v2/knowledge/connector-utils'
import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response'
export const dynamic = 'force-dynamic'
export const revalidate = 0
function connectorCursorScope(knowledgeBaseId: string, workspaceId: string) {
return cursorScopeKey(cursorRoute(v2ListKnowledgeConnectorsContract, { id: knowledgeBaseId }), {
workspaceId,
})
}
export const GET = defineV2JsonRoute({
contract: v2ListKnowledgeConnectorsContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.listConnectors,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
assertedWorkspaceId: query.workspaceId,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
limit: query.limit,
offset: decodeOffsetCursor(
query.cursor,
cursorSortKey(query.sortBy, query.sortOrder),
connectorCursorScope(params.id, query.workspaceId)
),
}),
useCase: listKnowledgeConnectors,
present: ({ connectors, hasMore, offset, limit }, { params, query }) => ({
data: connectors.map(toV2KnowledgeConnector),
nextCursor: hasMore
? encodeOffsetCursor(
cursorSortKey(query.sortBy, query.sortOrder),
connectorCursorScope(params.id, query.workspaceId),
offset + limit
)
: null,
}),
})
export const POST = defineV2JsonRoute({
contract: v2CreateKnowledgeConnectorContract,
auth: v2ApiKeyAuth,
operation: knowledgeOperations.createConnector,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, body }) => ({
knowledgeBaseId: params.id,
assertedWorkspaceId: body.workspaceId,
connectorType: body.connectorType,
credentialId: body.credentialId,
apiKey: body.apiKey,
sourceConfig: body.sourceConfig,
syncIntervalMinutes: body.syncIntervalMinutes,
source: 'api' as const,
}),
useCase: createKnowledgeConnector,
onSuccess: internalKnowledgeAnalytics.connectorAdded,
present: ({ connector }) => ({ data: toV2KnowledgeConnector(connector) }),
})
@@ -0,0 +1,112 @@
import {
type V2KnowledgeConnector,
type V2KnowledgeConnectorDetail,
type V2KnowledgeConnectorDocument,
v2KnowledgeConnectorDetailSchema,
v2KnowledgeConnectorDocumentSchema,
v2KnowledgeConnectorSchema,
v2KnowledgeConnectorSyncLogSchema,
} from '@/lib/api/contracts/v2/knowledge'
function serializeDate(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value
}
function serializeNullableDate(value: Date | string | null): string | null {
return value === null ? null : serializeDate(value)
}
interface KnowledgeConnectorProjection {
id: string
knowledgeBaseId: string
connectorType: string
credentialId: string | null
sourceConfig: unknown
syncMode: string | null
syncIntervalMinutes: number
status: string
lastSyncAt: Date | string | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: Date | string | null
consecutiveFailures: number
createdAt: Date | string
updatedAt: Date | string
}
interface KnowledgeConnectorSyncLogProjection {
id: string
connectorId: string
status: string
startedAt: Date | string
completedAt: Date | string | null
docsAdded: number
docsUpdated: number
docsDeleted: number
docsUnchanged: number
docsFailed: number
errorMessage: string | null
}
interface KnowledgeConnectorDocumentProjection {
id: string
filename: string
externalId: string | null
sourceUrl: string | null
enabled: boolean
userExcluded: boolean
uploadedAt: Date | string
processingStatus: string
}
export function toV2KnowledgeConnector(
connector: KnowledgeConnectorProjection
): V2KnowledgeConnector {
return v2KnowledgeConnectorSchema.parse({
id: connector.id,
knowledgeBaseId: connector.knowledgeBaseId,
connectorType: connector.connectorType,
credentialId: connector.credentialId,
sourceConfig: connector.sourceConfig,
syncMode: connector.syncMode,
syncIntervalMinutes: connector.syncIntervalMinutes,
status: connector.status,
lastSyncAt: serializeNullableDate(connector.lastSyncAt),
lastSyncError: connector.lastSyncError,
lastSyncDocCount: connector.lastSyncDocCount,
nextSyncAt: serializeNullableDate(connector.nextSyncAt),
consecutiveFailures: connector.consecutiveFailures,
createdAt: serializeDate(connector.createdAt),
updatedAt: serializeDate(connector.updatedAt),
})
}
export function toV2KnowledgeConnectorDetail(
connector: KnowledgeConnectorProjection & { syncLogs: KnowledgeConnectorSyncLogProjection[] }
): V2KnowledgeConnectorDetail {
return v2KnowledgeConnectorDetailSchema.parse({
...toV2KnowledgeConnector(connector),
syncLogs: connector.syncLogs.map((log) =>
v2KnowledgeConnectorSyncLogSchema.parse({
...log,
startedAt: serializeDate(log.startedAt),
completedAt: serializeNullableDate(log.completedAt),
})
),
})
}
export function toV2KnowledgeConnectorDocument(
document: KnowledgeConnectorDocumentProjection
): V2KnowledgeConnectorDocument {
return v2KnowledgeConnectorDocumentSchema.parse({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
createdAt: serializeDate(document.uploadedAt),
processingStatus: document.processingStatus,
})
}
@@ -0,0 +1,206 @@
/**
* @vitest-environment node
*/
import {
V2_OPERATION_RATE_LIMIT_ALLOWED,
V2_PREAUTH_RATE_LIMIT_ALLOWED,
v2ApiKeyAuthModuleMock,
v2GateModuleMock,
v2RateLimiterModuleMock,
v2RouteMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
list: vi.fn(),
grant: vi.fn(),
revoke: vi.fn(),
capture: vi.fn(),
}))
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock)
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))
vi.mock('@/lib/skills/application/use-cases', () => ({
listSkillEditorsUseCase: {
operation: { id: 'skills.editors.list' },
execute: mocks.list,
},
grantSkillEditorUseCase: {
operation: { id: 'skills.editors.grant' },
execute: mocks.grant,
},
revokeSkillEditorUseCase: {
operation: { id: 'skills.editors.revoke' },
execute: mocks.revoke,
},
}))
import { DELETE, GET, POST } from '@/app/api/v2/skills/[id]/editors/route'
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
const SKILL_ID = 'skill-1'
const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }
const AUTH = {
principal: PRINCIPAL,
rolloutUserId: 'user-1',
rateLimitSubjectIds: ['user:user-1'] as const,
rateLimitSubscription: null,
keyType: 'personal' as const,
}
const context = { params: Promise.resolve({ id: SKILL_ID }) }
const editor = {
id: 'membership-1',
userId: 'user-2',
userName: 'Ada',
userEmail: 'ada@example.com',
userImage: null,
isWorkspaceAdmin: false,
}
function request(method: 'GET' | 'POST' | 'DELETE', body?: unknown) {
const query =
method === 'GET'
? `?workspaceId=${WORKSPACE_ID}`
: method === 'DELETE'
? `?workspaceId=${WORKSPACE_ID}&email=${encodeURIComponent(editor.userEmail)}`
: ''
return new NextRequest(`http://localhost:3000/api/v2/skills/${SKILL_ID}/editors${query}`, {
method,
headers: {
'x-api-key': 'key',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
}
describe('/api/v2/skills/[id]/editors', () => {
beforeEach(() => {
vi.clearAllMocks()
v2RouteMocks.authenticate.mockResolvedValue(AUTH)
v2RouteMocks.gate.mockResolvedValue(null)
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
mocks.list.mockResolvedValue({
editors: [editor],
hasMore: false,
offset: 0,
limit: 50,
})
mocks.grant.mockResolvedValue({ editor, created: true, workspaceId: WORKSPACE_ID })
mocks.revoke.mockResolvedValue({ editor, workspaceId: WORKSPACE_ID })
})
it('lists public editor identity fields without internal IDs', async () => {
const response = await GET(request('GET'), context)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
data: [
{
email: editor.userEmail,
name: editor.userName,
image: null,
isWorkspaceAdmin: false,
},
],
nextCursor: null,
})
expect(mocks.list).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
skillId: SKILL_ID,
workspaceId: WORKSPACE_ID,
sortBy: 'email',
sortOrder: 'asc',
limit: 50,
offset: 0,
}),
})
)
})
it('allows a workspace key to list the editor roster', async () => {
const principal = {
kind: 'workspace_api_key' as const,
workspaceId: WORKSPACE_ID,
keyId: 'workspace-key-1',
}
v2RouteMocks.authenticate.mockResolvedValueOnce({
...AUTH,
principal,
keyType: 'workspace' as const,
})
const response = await GET(request('GET'), context)
expect(response.status).toBe(200)
expect(mocks.list).toHaveBeenCalledWith(expect.objectContaining({ principal }))
})
it('mints a cursor for the next editor page', async () => {
mocks.list.mockResolvedValueOnce({
editors: [editor],
hasMore: true,
offset: 0,
limit: 1,
})
const response = await GET(request('GET'), context)
expect(response.status).toBe(200)
expect(typeof (await response.json()).nextCursor).toBe('string')
})
it('creates an editor grant by email and returns 201', async () => {
const response = await POST(
request('POST', { workspaceId: WORKSPACE_ID, email: editor.userEmail }),
context
)
expect(response.status).toBe(201)
expect(mocks.grant).toHaveBeenCalledWith(
expect.objectContaining({
input: {
skillId: SKILL_ID,
workspaceId: WORKSPACE_ID,
target: { kind: 'email', email: editor.userEmail },
},
})
)
expect(mocks.capture).toHaveBeenCalledOnce()
})
it('returns 200 for an idempotent existing editor grant', async () => {
mocks.grant.mockResolvedValueOnce({ editor, created: false, workspaceId: WORKSPACE_ID })
const response = await POST(
request('POST', { workspaceId: WORKSPACE_ID, email: editor.userEmail }),
context
)
expect(response.status).toBe(200)
expect(mocks.capture).not.toHaveBeenCalled()
})
it('revokes an explicit editor grant by email', async () => {
const response = await DELETE(request('DELETE'), context)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
data: { email: editor.userEmail, revoked: true },
})
expect(mocks.revoke).toHaveBeenCalledWith(
expect.objectContaining({
input: {
skillId: SKILL_ID,
workspaceId: WORKSPACE_ID,
target: { kind: 'email', email: editor.userEmail },
},
})
)
})
})
@@ -0,0 +1,128 @@
import {
type V2SkillEditor,
v2GrantSkillEditorContract,
v2ListSkillEditorsContract,
v2RevokeSkillEditorContract,
} from '@/lib/api/contracts/v2/skills'
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import {
createV2ResourceConcealmentPolicy,
defineV2JsonRoute,
v2ApiKeyAuth,
v2RateLimits,
} from '@/lib/api/server/routes'
import { captureServerEvent } from '@/lib/posthog/server'
import type { SkillEditor } from '@/lib/skills/access'
import { skillOperations } from '@/lib/skills/application/operations'
import {
grantSkillEditorUseCase,
listSkillEditorsUseCase,
revokeSkillEditorUseCase,
} from '@/lib/skills/application/use-cases'
import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const skillResourceErrorPolicy = createV2ResourceConcealmentPolicy({
notFoundMessage: 'Skill not found',
})
function toV2SkillEditor(editor: SkillEditor): V2SkillEditor {
if (!editor.userEmail) throw new Error('Skill editor is missing an email address')
return {
email: editor.userEmail,
name: editor.userName,
image: editor.userImage ?? null,
isWorkspaceAdmin: editor.isWorkspaceAdmin,
}
}
function skillEditorCursorScope(skillId: string, query: { workspaceId: string }) {
return cursorScopeKey(cursorRoute(v2ListSkillEditorsContract, { id: skillId }), {
workspaceId: query.workspaceId,
})
}
export const GET = defineV2JsonRoute({
contract: v2ListSkillEditorsContract,
operation: skillOperations.listEditors,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: skillResourceErrorPolicy,
mapInput: ({ params, query }) => ({
skillId: params.id,
workspaceId: query.workspaceId,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
limit: query.limit,
offset: decodeOffsetCursor(
query.cursor,
cursorSortKey(query.sortBy, query.sortOrder),
skillEditorCursorScope(params.id, query)
),
}),
useCase: listSkillEditorsUseCase,
present: ({ editors, hasMore, offset, limit }, { params, query }) => ({
data: editors.map(toV2SkillEditor),
nextCursor: hasMore
? encodeOffsetCursor(
cursorSortKey(query.sortBy, query.sortOrder),
skillEditorCursorScope(params.id, query),
offset + limit
)
: null,
}),
})
export const POST = defineV2JsonRoute({
contract: v2GrantSkillEditorContract,
operation: skillOperations.grantEditor,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: skillResourceErrorPolicy,
mapInput: ({ params, body }) => ({
skillId: params.id,
workspaceId: body.workspaceId,
target: { kind: 'email' as const, email: body.email },
}),
useCase: grantSkillEditorUseCase,
onSuccess: ({ principal, input, result }) => {
if (!result.created || principal.kind !== 'personal_api_key') return
captureServerEvent(
principal.userId,
'skill_shared',
{ skill_id: input.skillId, workspace_id: result.workspaceId },
{ groups: { workspace: result.workspaceId } }
)
},
present: ({ editor }) => ({ data: toV2SkillEditor(editor) }),
statusForResult: ({ created }) => (created ? 201 : 200),
})
export const DELETE = defineV2JsonRoute({
contract: v2RevokeSkillEditorContract,
operation: skillOperations.revokeEditor,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: skillResourceErrorPolicy,
mapInput: ({ params, query }) => ({
skillId: params.id,
workspaceId: query.workspaceId,
target: { kind: 'email' as const, email: query.email },
}),
useCase: revokeSkillEditorUseCase,
onSuccess: ({ principal, input, result }) => {
if (principal.kind !== 'personal_api_key') return
captureServerEvent(
principal.userId,
'skill_unshared',
{ skill_id: input.skillId, workspace_id: result.workspaceId },
{ groups: { workspace: result.workspaceId } }
)
},
present: ({ editor }) => {
if (!editor.userEmail) throw new Error('Skill editor is missing an email address')
return { data: { email: editor.userEmail, revoked: true as const } }
},
})
@@ -14,6 +14,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getWorkspace: vi.fn(),
listWorkspaces: vi.fn(),
listMembers: vi.fn(),
}))
@@ -35,10 +36,18 @@ vi.mock('@/lib/workspaces/application/list-public-workspace-members', () => ({
},
}))
vi.mock('@/lib/workspaces/application/list-public-workspaces', () => ({
listPublicWorkspaces: {
operation: { id: 'workspaces.list_public' },
execute: mocks.listWorkspaces,
},
}))
import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { GET as listMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route'
import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route'
import { GET as listWorkspaces } from '@/app/api/v2/workspaces/route'
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
const auth = {
@@ -115,6 +124,53 @@ describe('v2 workspace routes', () => {
nextEmail: 'ada@example.com',
},
})
mocks.listWorkspaces.mockResolvedValue({
workspaces: [
{
id: WORKSPACE_ID,
name: 'Engineering',
color: '#33C482',
logoUrl: null,
memberCount: 1,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-02T00:00:00Z'),
},
],
hasMore: false,
offset: 0,
limit: 50,
})
})
it('lists the workspaces available to the API key', async () => {
const request = new NextRequest('http://localhost:3000/api/v2/workspaces')
const response = await listWorkspaces(request)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
data: [
{
id: WORKSPACE_ID,
name: 'Engineering',
color: '#33C482',
logoUrl: null,
memberCount: 1,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
},
],
nextCursor: null,
})
expect(mocks.listWorkspaces).toHaveBeenCalledWith({
principal: auth.principal,
input: {
sortBy: 'createdAt',
sortOrder: 'desc',
limit: 50,
offset: 0,
},
request,
})
})
it('projects public workspace metadata without governance identities', async () => {
+49
View File
@@ -0,0 +1,49 @@
import { v2ListWorkspacesContract } from '@/lib/api/contracts/v2/workspaces'
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import {
defineV2JsonRoute,
v2ApiKeyAuth,
v2OrchestrationErrorPolicy,
v2RateLimits,
} from '@/lib/api/server/routes'
import { listPublicWorkspaces } from '@/lib/workspaces/application/list-public-workspaces'
import { workspaceOperations } from '@/lib/workspaces/application/operations'
import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const cursorScope = cursorScopeKey(cursorRoute(v2ListWorkspacesContract), {})
export const GET = defineV2JsonRoute({
contract: v2ListWorkspacesContract,
auth: v2ApiKeyAuth,
operation: workspaceOperations.listPublic,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
mapInput: ({ query }) => ({
sortBy: query.sortBy,
sortOrder: query.sortOrder,
limit: query.limit,
offset: decodeOffsetCursor(
query.cursor,
cursorSortKey(query.sortBy, query.sortOrder),
cursorScope
),
}),
useCase: listPublicWorkspaces,
present: ({ workspaces, hasMore, offset, limit }, { query }) => ({
data: workspaces.map((workspace) => ({
...workspace,
createdAt: workspace.createdAt.toISOString(),
updatedAt: workspace.updatedAt.toISOString(),
})),
nextCursor: hasMore
? encodeOffsetCursor(
cursorSortKey(query.sortBy, query.sortOrder),
cursorScope,
offset + limit
)
: null,
}),
})
+16 -9
View File
@@ -125,19 +125,25 @@ export const deleteSkillContract = defineRouteContract({
},
})
export const skillIdParamsSchema = z.object({
id: z.string().min(1, 'Skill id is required'),
})
export const skillIdParamsSchema = z
.object({
id: z.string().min(1, 'Skill id is required'),
})
.strict()
export const upsertSkillMemberBodySchema = z.object({
userId: z.string().min(1, 'User id is required'),
})
export const upsertSkillMemberBodySchema = z
.object({
userId: z.string().min(1, 'User id is required'),
})
.strict()
export type UpsertSkillMemberBody = z.input<typeof upsertSkillMemberBodySchema>
export const removeSkillMemberQuerySchema = z.object({
userId: z.string().min(1, 'User id is required'),
})
export const removeSkillMemberQuerySchema = z
.object({
userId: z.string().min(1, 'User id is required'),
})
.strict()
export const listSkillMembersContract = defineRouteContract({
method: 'GET',
@@ -158,6 +164,7 @@ export const upsertSkillMemberContract = defineRouteContract({
body: upsertSkillMemberBodySchema,
response: {
mode: 'json',
status: [200, 201],
schema: z.object({
success: z.literal(true),
}),
@@ -182,10 +182,11 @@ describe('knowledge and files request-slice strictness', () => {
* A count, so a document that stopped listing its routes cannot make every
* assertion below pass vacuously. It rises when a route gains a slice: it went
* 45 → 63 when the knowledge and files endpoints that take no query params
* started saying so with `noInputSchema` instead of omitting `query`.
* started saying so with `noInputSchema` instead of omitting `query`, then
* 63 → 75 when knowledge connector management joined the documented surface.
*/
it('sweeps every documented query and body slice', () => {
expect(slices.length).toBe(63)
expect(slices.length).toBe(75)
})
it.each(slices)('%s rejects an undeclared key', (_name, schema) => {
@@ -51,11 +51,14 @@ const PAGED_LISTS = [
'GET /api/v2/custom-tools',
'GET /api/v2/files',
'GET /api/v2/knowledge',
'GET /api/v2/knowledge/[id]/connectors',
'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents',
'GET /api/v2/knowledge/[id]/documents',
'GET /api/v2/logs',
'GET /api/v2/mcp-servers',
'GET /api/v2/secrets',
'GET /api/v2/skills',
'GET /api/v2/skills/[id]/editors',
'GET /api/v2/tables',
'GET /api/v2/tables/[tableId]/rows',
'POST /api/v2/tables/[tableId]/query',
@@ -63,6 +66,7 @@ const PAGED_LISTS = [
'GET /api/v2/workflows/[id]/runs',
'GET /api/v2/workflows/[id]/versions',
'GET /api/v2/workspaces/[workspaceId]/members',
'GET /api/v2/workspaces',
] as const
/**
@@ -143,6 +147,11 @@ const CURSOR_BINDINGS: Record<string, readonly string[]> = {
'recursive',
],
'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/knowledge/[id]/connectors': ['workspaceId', 'sortBy', 'sortOrder'],
'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents': [
'workspaceId',
'includeExcluded',
],
'GET /api/v2/knowledge/[id]/documents': [
'workspaceId',
'enabledFilter',
@@ -170,6 +179,7 @@ const CURSOR_BINDINGS: Record<string, readonly string[]> = {
'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/secrets': ['workspaceId', 'scope', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/skills': ['workspaceId', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/skills/[id]/editors': ['workspaceId', 'sortBy', 'sortOrder'],
'GET /api/v2/tables': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'],
'GET /api/v2/tables/[tableId]/rows': [],
'POST /api/v2/tables/[tableId]/query': ['predicate', 'sort'],
@@ -184,6 +194,7 @@ const CURSOR_BINDINGS: Record<string, readonly string[]> = {
'GET /api/v2/workflows/[id]/runs': ['status', 'trigger', 'startDate', 'endDate', 'order'],
'GET /api/v2/workflows/[id]/versions': [],
'GET /api/v2/workspaces/[workspaceId]/members': [],
'GET /api/v2/workspaces': ['sortBy', 'sortOrder'],
}
/**
@@ -207,7 +218,10 @@ const CURSOR_BINDINGS: Record<string, readonly string[]> = {
* resolves the path before fingerprinting it.
*/
const CURSOR_BOUND_PATH_PARAMS: Record<string, readonly string[]> = {
'GET /api/v2/knowledge/[id]/connectors': ['id'],
'GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents': ['id', 'connectorId'],
'GET /api/v2/knowledge/[id]/documents': ['id'],
'GET /api/v2/skills/[id]/editors': ['id'],
'GET /api/v2/tables/[tableId]/rows': ['tableId'],
'POST /api/v2/tables/[tableId]/query': ['tableId'],
'GET /api/v2/workflows/[id]/runs': ['id'],
+112 -72
View File
@@ -1,6 +1,11 @@
import { z } from 'zod'
import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials'
import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import {
missingFieldError,
noInputSchema,
nonEmptyIdSchema,
workspaceIdSchema,
} from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import {
v2CursorListResponse,
@@ -285,74 +290,109 @@ export const v2CreateCredentialConnectionContract = defineRouteContract({
},
})
const v2ServiceAccountSecretFieldsShape = {
serviceAccountJson: z
.string()
.min(1)
.max(65_536)
.optional()
.describe('Write-only Google service-account JSON key.')
.meta({ writeOnly: true }),
apiToken: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only provider API token.')
.meta({ writeOnly: true }),
domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'),
signingSecret: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only webhook signing secret.')
.meta({ writeOnly: true }),
botToken: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only bot token.')
.meta({ writeOnly: true }),
clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'),
clientSecret: z
.string()
.trim()
.min(1)
.max(1024)
.optional()
.describe('Write-only OAuth client secret.')
.meta({ writeOnly: true }),
certificateId: z
.string()
.trim()
.min(1)
.max(512)
.optional()
.describe('Provider certificate mapping identifier.'),
orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'),
dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'),
authMethod: z
.string()
.trim()
.min(1)
.max(64)
.optional()
.describe('Provider authentication method.'),
privateKey: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only PEM private key.')
.meta({ writeOnly: true }),
username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'),
} as const
const v2ServiceAccountCredentialFieldsSchema = z
.object({
serviceAccountJson: z
.string()
.min(1)
.max(65_536)
.optional()
.describe('Write-only Google service-account JSON key.')
.meta({ writeOnly: true }),
apiToken: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only provider API token.')
.meta({ writeOnly: true }),
domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'),
signingSecret: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only webhook signing secret.')
.meta({ writeOnly: true }),
botToken: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only bot token.')
.meta({ writeOnly: true }),
clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'),
clientSecret: z
.string()
.trim()
.min(1)
.max(1024)
.optional()
.describe('Write-only OAuth client secret.')
.meta({ writeOnly: true }),
certificateId: z
.string()
.trim()
.min(1)
.max(512)
.optional()
.describe('Provider certificate mapping identifier.'),
orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'),
dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'),
authMethod: z
.string()
.trim()
.min(1)
.max(64)
.optional()
.describe('Provider authentication method.'),
privateKey: z
.string()
.trim()
.min(1)
.max(8192)
.optional()
.describe('Write-only PEM private key.')
.meta({ writeOnly: true }),
username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'),
})
.strict()
type V2ServiceAccountCredentialFields = z.output<typeof v2ServiceAccountCredentialFieldsSchema>
const v2ServiceAccountCredentialsJsonSchema = z
.string({ error: missingFieldError('credentials is required') })
.min(1, 'credentials cannot be empty')
.max(131_072, 'credentials must be at most 131072 characters')
.describe(
'Write-only JSON object string containing the fields declared by credential-provider discovery.'
)
.meta({ writeOnly: true })
.transform((value, ctx): V2ServiceAccountCredentialFields => {
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
ctx.addIssue({ code: 'custom', message: 'credentials must be valid JSON' })
return z.NEVER
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
ctx.addIssue({ code: 'custom', message: 'credentials must be a JSON object' })
return z.NEVER
}
const result = v2ServiceAccountCredentialFieldsSchema.safeParse(parsed)
if (result.success) return result.data
for (const issue of result.error.issues) {
ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message })
}
return z.NEVER
})
export const v2CreateServiceAccountCredentialBodySchema = z
.object({
@@ -382,7 +422,7 @@ export const v2CreateServiceAccountCredentialBodySchema = z
.uuid('id must be a valid UUID')
.optional()
.describe('Required only when provider discovery requests a client-generated ID.'),
...v2ServiceAccountSecretFieldsShape,
credentials: v2ServiceAccountCredentialsJsonSchema,
})
.strict()
.superRefine((body, ctx) => {
@@ -402,10 +442,10 @@ export const v2CreateServiceAccountCredentialBodySchema = z
})
}
for (const field of getServiceAccountRequiredFields(body.providerId)) {
if (!body[field]) {
if (!body.credentials[field]) {
ctx.addIssue({
code: 'custom',
path: [field],
path: ['credentials', field],
message: `${field} is required for ${body.providerId} credentials`,
})
}
+385 -2
View File
@@ -3,10 +3,15 @@ import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base'
import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents'
import {
knowledgeBaseParamsSchema,
knowledgeConnectorParamsSchema,
knowledgeDocumentParamsSchema,
nullableWireDateSchema,
} from '@/lib/api/contracts/knowledge/shared'
import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import {
booleanQueryFlagSchema,
noInputSchema,
workspaceIdSchema,
} from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import {
KNOWLEDGE_TAG_FILTER_OPERATORS_BY_FIELD_TYPE,
@@ -41,7 +46,10 @@ import {
v2UploadTokenHeadersSchema,
v2UploadTransferSchema,
} from '@/lib/api/contracts/v2/uploads'
import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants'
import {
DEFAULT_CHUNKING_CONFIG,
MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS,
} from '@/lib/knowledge/constants'
import {
DEFAULT_RERANKER_MODEL,
rerankerModelSchema,
@@ -1463,3 +1471,378 @@ export const v2DeleteKnowledgeDocumentContract = defineRouteContract({
schema: v2DataResponse(v2KnowledgeDeleteDataSchema),
},
})
export const v2KnowledgeConnectorSchema = z
.object({
id: z.string().min(1).describe('Unique connector identifier.'),
knowledgeBaseId: z.string().min(1).describe('Knowledge base synced by the connector.'),
connectorType: z.string().min(1).describe('Registered external source type.'),
credentialId: z
.string()
.nullable()
.describe('OAuth credential identifier, or null for API-key and unauthenticated sources.'),
sourceConfig: z
.record(z.string(), z.unknown().describe('Connector-specific source configuration value.'))
.describe('Connector-specific source selection and filtering configuration.'),
syncMode: z.string().describe('Synchronization mode used by the connector.'),
syncIntervalMinutes: z
.number()
.int()
.nonnegative()
.describe('Scheduled synchronization interval in minutes; zero disables scheduled syncs.'),
status: z
.enum(['active', 'paused', 'syncing', 'error', 'disabled'])
.describe('Current connector state.'),
lastSyncAt: v2TimestampSchema
.nullable()
.describe('Time of the most recent synchronization, or null before the first sync.'),
lastSyncError: z
.string()
.nullable()
.describe('Most recent synchronization error, or null when none is recorded.'),
lastSyncDocCount: z
.number()
.int()
.nonnegative()
.nullable()
.describe('Documents observed by the most recent synchronization.'),
nextSyncAt: v2TimestampSchema
.nullable()
.describe('Next scheduled synchronization time, or null when not scheduled.'),
consecutiveFailures: z
.number()
.int()
.nonnegative()
.describe('Number of consecutive synchronization failures.'),
createdAt: v2TimestampSchema.describe('Time the connector was created.'),
updatedAt: v2TimestampSchema.describe('Time the connector was last updated.'),
})
.strict()
.meta({
id: 'V2KnowledgeConnector',
title: 'Knowledge connector',
description: 'An external document source linked to a knowledge base, without secret material.',
})
export type V2KnowledgeConnector = z.output<typeof v2KnowledgeConnectorSchema>
export const v2KnowledgeConnectorSyncLogSchema = z
.object({
id: z.string().min(1).describe('Unique synchronization log identifier.'),
connectorId: z.string().min(1).describe('Connector that produced the log.'),
status: z.string().min(1).describe('Synchronization outcome or current state.'),
startedAt: v2TimestampSchema.describe('Time synchronization started.'),
completedAt: v2TimestampSchema
.nullable()
.describe('Time synchronization completed, or null while it is running.'),
docsAdded: z.number().int().nonnegative().describe('Documents added.'),
docsUpdated: z.number().int().nonnegative().describe('Documents updated.'),
docsDeleted: z.number().int().nonnegative().describe('Documents deleted.'),
docsUnchanged: z.number().int().nonnegative().describe('Documents unchanged.'),
docsFailed: z.number().int().nonnegative().describe('Documents that failed to synchronize.'),
errorMessage: z.string().nullable().describe('Synchronization error, or null.'),
})
.strict()
.meta({
id: 'V2KnowledgeConnectorSyncLog',
title: 'Knowledge connector sync log',
description: 'One synchronization attempt for a knowledge connector.',
})
export type V2KnowledgeConnectorSyncLog = z.output<typeof v2KnowledgeConnectorSyncLogSchema>
export const v2KnowledgeConnectorDetailSchema = v2KnowledgeConnectorSchema
.extend({
syncLogs: z
.array(v2KnowledgeConnectorSyncLogSchema)
.describe('The ten most recent synchronization attempts.'),
})
.meta({
id: 'V2KnowledgeConnectorDetail',
title: 'Knowledge connector detail',
description: 'A knowledge connector and its recent synchronization history.',
})
export type V2KnowledgeConnectorDetail = z.output<typeof v2KnowledgeConnectorDetailSchema>
export const v2KnowledgeConnectorDocumentSchema = z
.object({
id: z.string().min(1).describe('Unique document identifier.'),
filename: z.string().min(1).describe('Document filename.'),
externalId: z.string().nullable().describe('Identifier assigned by the external source.'),
sourceUrl: z.string().nullable().describe('Original external source URL.'),
enabled: z.boolean().describe('Whether the document is enabled for knowledge search.'),
userExcluded: z
.boolean()
.describe('Whether a user explicitly excluded the document from connector sync results.'),
createdAt: v2TimestampSchema.describe('Time the document was first synchronized.'),
processingStatus: z.string().describe('Current document processing state.'),
})
.strict()
.meta({
id: 'V2KnowledgeConnectorDocument',
title: 'Knowledge connector document',
description: 'A knowledge document produced by an external connector.',
})
export type V2KnowledgeConnectorDocument = z.output<typeof v2KnowledgeConnectorDocumentSchema>
export const v2KnowledgeConnectorParamsSchema = knowledgeConnectorParamsSchema
.extend({
id: knowledgeConnectorParamsSchema.shape.id.describe('Knowledge base that owns the connector.'),
connectorId: knowledgeConnectorParamsSchema.shape.connectorId.describe(
'Connector selected for the operation.'
),
})
.strict()
export type V2KnowledgeConnectorParams = z.output<typeof v2KnowledgeConnectorParamsSchema>
export const v2KnowledgeConnectorWorkspaceQuerySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'),
})
.strict()
export type V2KnowledgeConnectorWorkspaceQuery = z.output<
typeof v2KnowledgeConnectorWorkspaceQuerySchema
>
export const v2KnowledgeConnectorSortFields = ['connectorType', 'createdAt', 'updatedAt'] as const
export const v2ListKnowledgeConnectorsQuerySchema = v2KnowledgeConnectorWorkspaceQuerySchema
.extend({
...v2SortFields(v2KnowledgeConnectorSortFields, {
sortBy: 'createdAt',
sortOrder: 'desc',
}),
...v2PaginationFields({ description: 'Maximum connectors to return per page.' }),
})
.strict()
export type V2ListKnowledgeConnectorsQuery = z.output<typeof v2ListKnowledgeConnectorsQuerySchema>
export const v2CreateKnowledgeConnectorBodySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'),
connectorType: z.string().trim().min(1).max(100).describe('Registered connector type.'),
credentialId: z
.string()
.trim()
.min(1)
.max(255)
.optional()
.describe('OAuth credential identifier for connectors that require OAuth.'),
apiKey: z
.string()
.min(1)
.max(10_000)
.optional()
.describe('Write-only API key for connectors that use API-key authentication.'),
sourceConfig: z
.record(z.string(), z.unknown().describe('Connector-specific source configuration value.'))
.describe('Connector-specific source selection and filtering configuration.'),
syncIntervalMinutes: z
.number()
.int()
.min(0)
.max(525_600)
.default(1440)
.describe('Scheduled synchronization interval in minutes; zero disables scheduling.'),
})
.strict()
export type V2CreateKnowledgeConnectorBody = z.input<typeof v2CreateKnowledgeConnectorBodySchema>
export const v2UpdateKnowledgeConnectorBodySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'),
sourceConfig: z
.record(z.string(), z.unknown().describe('Connector-specific source configuration value.'))
.optional()
.describe('Replacement source selection and filtering configuration.'),
syncIntervalMinutes: z
.number()
.int()
.min(0)
.max(525_600)
.optional()
.describe('New scheduled synchronization interval in minutes.'),
status: z.enum(['active', 'paused']).optional().describe('New connector state.'),
})
.strict()
.superRefine((body, ctx) => {
if (
body.sourceConfig === undefined &&
body.syncIntervalMinutes === undefined &&
body.status === undefined
) {
ctx.addIssue({
code: 'custom',
path: ['sourceConfig'],
message: 'At least one of sourceConfig, syncIntervalMinutes, or status is required',
})
}
})
export type V2UpdateKnowledgeConnectorBody = z.input<typeof v2UpdateKnowledgeConnectorBodySchema>
export const v2DeleteKnowledgeConnectorQuerySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'),
deleteDocuments: booleanQueryFlagSchema
.optional()
.default(false)
.describe('Also permanently delete documents produced by this connector.'),
})
.strict()
export type V2DeleteKnowledgeConnectorQuery = z.input<typeof v2DeleteKnowledgeConnectorQuerySchema>
export const v2SyncKnowledgeConnectorBodySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'),
rehydrate: z
.boolean()
.optional()
.default(false)
.describe('Re-fetch and re-index every existing connector document.'),
})
.strict()
export type V2SyncKnowledgeConnectorBody = z.input<typeof v2SyncKnowledgeConnectorBodySchema>
export const v2ListKnowledgeConnectorDocumentsQuerySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'),
includeExcluded: booleanQueryFlagSchema
.optional()
.default(false)
.describe('Include documents explicitly excluded by a user.'),
...v2PaginationFields({ description: 'Maximum connector documents to return per page.' }),
})
.strict()
export type V2ListKnowledgeConnectorDocumentsQuery = z.output<
typeof v2ListKnowledgeConnectorDocumentsQuerySchema
>
export const v2UpdateKnowledgeConnectorDocumentsBodySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the knowledge base.'),
operation: z
.enum(['restore', 'exclude'])
.describe('Whether to restore or exclude the selected documents.'),
documentIds: z
.array(z.string().min(1).max(255))
.min(1, 'At least one document id is required')
.max(MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS)
.describe('Connector document identifiers to update.'),
})
.strict()
export type V2UpdateKnowledgeConnectorDocumentsBody = z.input<
typeof v2UpdateKnowledgeConnectorDocumentsBodySchema
>
export const v2KnowledgeConnectorDeleteDataSchema = z
.object({
id: z.string().min(1).describe('Deleted connector identifier.'),
deleted: z.literal(true).describe('Whether the connector was deleted.'),
documentsDeleted: z.number().int().nonnegative().describe('Connector documents deleted.'),
documentsKept: z.number().int().nonnegative().describe('Connector documents retained.'),
})
.strict()
.meta({
id: 'V2KnowledgeConnectorDeleteData',
title: 'Knowledge connector deletion data',
description: 'Connector deletion acknowledgement and affected document counts.',
})
export type V2KnowledgeConnectorDeleteData = z.output<typeof v2KnowledgeConnectorDeleteDataSchema>
export const v2KnowledgeConnectorSyncDataSchema = z
.object({
id: z.string().min(1).describe('Connector queued for synchronization.'),
syncTriggered: z.literal(true).describe('Whether synchronization was queued.'),
})
.strict()
.meta({
id: 'V2KnowledgeConnectorSyncData',
title: 'Knowledge connector sync data',
description: 'Acknowledgement that connector synchronization was queued.',
})
export type V2KnowledgeConnectorSyncData = z.output<typeof v2KnowledgeConnectorSyncDataSchema>
export const v2KnowledgeConnectorDocumentsUpdateDataSchema = z
.object({
operation: z.enum(['restore', 'exclude']).describe('Operation that was applied.'),
updatedCount: z.number().int().nonnegative().describe('Documents changed.'),
documentIds: z.array(z.string()).describe('Identifiers of documents changed.'),
})
.strict()
.meta({
id: 'V2KnowledgeConnectorDocumentsUpdateData',
title: 'Knowledge connector documents update data',
description: 'Outcome of restoring or excluding connector documents.',
})
export type V2KnowledgeConnectorDocumentsUpdateData = z.output<
typeof v2KnowledgeConnectorDocumentsUpdateDataSchema
>
export const v2ListKnowledgeConnectorsContract = defineRouteContract({
method: 'GET',
path: '/api/v2/knowledge/[id]/connectors',
params: v2KnowledgeBaseParamsSchema,
query: v2ListKnowledgeConnectorsQuerySchema,
response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeConnectorSchema) },
})
export const v2CreateKnowledgeConnectorContract = defineRouteContract({
method: 'POST',
path: '/api/v2/knowledge/[id]/connectors',
params: v2KnowledgeBaseParamsSchema,
query: noInputSchema,
body: v2CreateKnowledgeConnectorBodySchema,
response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorSchema), status: 201 },
})
export const v2GetKnowledgeConnectorContract = defineRouteContract({
method: 'GET',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]',
params: v2KnowledgeConnectorParamsSchema,
query: v2KnowledgeConnectorWorkspaceQuerySchema,
response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorDetailSchema) },
})
export const v2UpdateKnowledgeConnectorContract = defineRouteContract({
method: 'PATCH',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]',
params: v2KnowledgeConnectorParamsSchema,
query: noInputSchema,
body: v2UpdateKnowledgeConnectorBodySchema,
response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorSchema) },
})
export const v2DeleteKnowledgeConnectorContract = defineRouteContract({
method: 'DELETE',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]',
params: v2KnowledgeConnectorParamsSchema,
query: v2DeleteKnowledgeConnectorQuerySchema,
response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorDeleteDataSchema) },
})
export const v2SyncKnowledgeConnectorContract = defineRouteContract({
method: 'POST',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]/sync',
params: v2KnowledgeConnectorParamsSchema,
query: noInputSchema,
body: v2SyncKnowledgeConnectorBodySchema,
response: { mode: 'json', schema: v2DataResponse(v2KnowledgeConnectorSyncDataSchema) },
})
export const v2ListKnowledgeConnectorDocumentsContract = defineRouteContract({
method: 'GET',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents',
params: v2KnowledgeConnectorParamsSchema,
query: v2ListKnowledgeConnectorDocumentsQuerySchema,
response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeConnectorDocumentSchema) },
})
export const v2UpdateKnowledgeConnectorDocumentsContract = defineRouteContract({
method: 'PATCH',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents',
params: v2KnowledgeConnectorParamsSchema,
query: noInputSchema,
body: v2UpdateKnowledgeConnectorDocumentsBodySchema,
response: {
mode: 'json',
schema: v2DataResponse(v2KnowledgeConnectorDocumentsUpdateDataSchema),
},
})
@@ -3,21 +3,29 @@ import {
v2BulkUpdateKnowledgeDocumentsContract,
v2CompleteKnowledgeDocumentUploadContract,
v2CreateKnowledgeBaseContract,
v2CreateKnowledgeConnectorContract,
v2CreateKnowledgeDocumentUploadContract,
v2CreateKnowledgeDocumentUploadPartUrlsContract,
v2CreateKnowledgeFolderContract,
v2DeleteKnowledgeBaseContract,
v2DeleteKnowledgeConnectorContract,
v2DeleteKnowledgeDocumentContract,
v2DeleteKnowledgeFolderContract,
v2GetKnowledgeBaseContract,
v2GetKnowledgeConnectorContract,
v2GetKnowledgeDocumentContract,
v2ListKnowledgeBasesContract,
v2ListKnowledgeConnectorDocumentsContract,
v2ListKnowledgeConnectorsContract,
v2ListKnowledgeDocumentsContract,
v2ListKnowledgeFoldersContract,
v2ListKnowledgeTagsContract,
v2RelocateKnowledgeFolderContract,
v2SearchKnowledgeContract,
v2SyncKnowledgeConnectorContract,
v2UpdateKnowledgeBaseContract,
v2UpdateKnowledgeConnectorContract,
v2UpdateKnowledgeConnectorDocumentsContract,
v2UpdateKnowledgeDocumentContract,
v2UploadKnowledgeDocumentContract,
v2UploadKnowledgeDocumentFormSchema,
@@ -48,6 +56,36 @@ import {
const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64'
const KNOWLEDGE_BASE_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7'
const KNOWLEDGE_CONNECTOR_ID = 'kc-9f8e7d6c'
const KNOWLEDGE_CONNECTOR_EXAMPLE = {
id: KNOWLEDGE_CONNECTOR_ID,
knowledgeBaseId: KNOWLEDGE_BASE_ID,
connectorType: 'notion',
credentialId: 'cred-4b3a2c1d',
sourceConfig: { pageIds: ['page-123'] },
syncMode: 'full',
syncIntervalMinutes: 1440,
status: 'active',
lastSyncAt: '2026-06-20T14:02:11.000Z',
lastSyncError: null,
lastSyncDocCount: 42,
nextSyncAt: '2026-06-21T14:02:11.000Z',
consecutiveFailures: 0,
createdAt: '2026-06-01T09:14:00.000Z',
updatedAt: '2026-06-20T14:02:11.000Z',
} as const
const KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE = {
id: 'doc-8a7b6c5d',
filename: 'Product requirements',
externalId: 'page-123',
sourceUrl: 'https://www.notion.so/page-123',
enabled: true,
userExcluded: false,
createdAt: '2026-06-01T09:15:00.000Z',
processingStatus: 'completed',
} as const
function knowledgeOperation(
operation: Omit<OpenApiOperationMetadata, 'tags' | 'success' | 'errors'> & {
@@ -208,6 +246,293 @@ const declaredRoutes = [
),
}
),
defineOpenApiRoute(
v2ListKnowledgeConnectorsContract,
knowledgeOperation({
operationId: 'listKnowledgeConnectors',
summary: 'List Knowledge Connectors',
description: `List external sources connected to a knowledge base with opaque cursor pagination. Stored API keys and encrypted secret material are never returned. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'A page of knowledge connectors.' },
}),
{
params: documentedSchema(
v2ListKnowledgeConnectorsContract.params,
'ListKnowledgeConnectorsParams',
'List knowledge connectors path parameters',
'Knowledge base whose connectors should be listed.'
),
query: documentedSchema(
v2ListKnowledgeConnectorsContract.query,
'ListKnowledgeConnectorsQuery',
'List knowledge connectors query',
'Workspace, sorting, and pagination controls.'
),
response: documentedSchema(
v2ListKnowledgeConnectorsContract.response.schema,
'V2KnowledgeConnectorListResponse',
'Knowledge connector list response',
'A cursor-paginated page of connectors without secret material.',
[{ data: [KNOWLEDGE_CONNECTOR_EXAMPLE], nextCursor: null }]
),
}
),
defineOpenApiRoute(
v2CreateKnowledgeConnectorContract,
knowledgeOperation({
operationId: 'createKnowledgeConnector',
summary: 'Create Knowledge Connector',
description: `Validate and connect an external source, then queue its initial synchronization. The apiKey field is write-only and is never returned. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_CONFLICT_ERRORS,
success: { description: 'The created connector without secret material.' },
}),
{
query: v2CreateKnowledgeConnectorContract.query,
params: documentedSchema(
v2CreateKnowledgeConnectorContract.params,
'CreateKnowledgeConnectorParams',
'Create knowledge connector path parameters',
'Knowledge base to connect to an external source.'
),
body: documentedSchema(
v2CreateKnowledgeConnectorContract.body,
'CreateKnowledgeConnectorRequest',
'Create knowledge connector request',
'Workspace, connector type, authentication reference, source configuration, and sync schedule.',
[
{
workspaceId: WORKSPACE_ID,
connectorType: 'notion',
credentialId: 'cred-4b3a2c1d',
sourceConfig: { pageIds: ['page-123'] },
syncIntervalMinutes: 1440,
},
]
),
response: documentedSchema(
v2CreateKnowledgeConnectorContract.response.schema,
'V2KnowledgeConnectorResponse',
'Knowledge connector response',
'A single connector without secret material.',
[{ data: KNOWLEDGE_CONNECTOR_EXAMPLE }]
),
}
),
defineOpenApiRoute(
v2GetKnowledgeConnectorContract,
knowledgeOperation({
operationId: 'getKnowledgeConnector',
summary: 'Get Knowledge Connector',
description: `Retrieve one connector and its ten most recent synchronization attempts. Stored API keys and encrypted secret material are never returned. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'The connector and recent synchronization history.' },
}),
{
params: documentedSchema(
v2GetKnowledgeConnectorContract.params,
'GetKnowledgeConnectorParams',
'Get knowledge connector path parameters',
'Knowledge connector selected for retrieval.'
),
query: documentedSchema(
v2GetKnowledgeConnectorContract.query,
'GetKnowledgeConnectorQuery',
'Get knowledge connector query',
'Workspace scope for the knowledge base.'
),
response: documentedSchema(
v2GetKnowledgeConnectorContract.response.schema,
'V2KnowledgeConnectorDetailResponse',
'Knowledge connector detail response',
'A connector and recent synchronization history without secret material.',
[{ data: { ...KNOWLEDGE_CONNECTOR_EXAMPLE, syncLogs: [] } }]
),
}
),
defineOpenApiRoute(
v2UpdateKnowledgeConnectorContract,
knowledgeOperation({
operationId: 'updateKnowledgeConnector',
summary: 'Update Knowledge Connector',
description: `Update connector source configuration, schedule, or active state. Authentication material cannot be changed through this operation. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_CONFLICT_ERRORS,
success: { description: 'The updated connector.' },
}),
{
query: v2UpdateKnowledgeConnectorContract.query,
params: documentedSchema(
v2UpdateKnowledgeConnectorContract.params,
'UpdateKnowledgeConnectorParams',
'Update knowledge connector path parameters',
'Knowledge connector selected for update.'
),
body: documentedSchema(
v2UpdateKnowledgeConnectorContract.body,
'UpdateKnowledgeConnectorRequest',
'Update knowledge connector request',
'Workspace scope and at least one mutable connector field.',
[{ workspaceId: WORKSPACE_ID, status: 'paused' }]
),
response: documentedSchema(
v2UpdateKnowledgeConnectorContract.response.schema,
'V2KnowledgeConnectorResponse',
'Knowledge connector response',
'A single connector without secret material.',
[{ data: KNOWLEDGE_CONNECTOR_EXAMPLE }]
),
}
),
defineOpenApiRoute(
v2DeleteKnowledgeConnectorContract,
knowledgeOperation({
operationId: 'deleteKnowledgeConnector',
summary: 'Delete Knowledge Connector',
description: `Delete a connector and optionally its synchronized documents. Documents are retained by default. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'Connector deletion acknowledgement and document counts.' },
}),
{
params: documentedSchema(
v2DeleteKnowledgeConnectorContract.params,
'DeleteKnowledgeConnectorParams',
'Delete knowledge connector path parameters',
'Knowledge connector selected for deletion.'
),
query: documentedSchema(
v2DeleteKnowledgeConnectorContract.query,
'DeleteKnowledgeConnectorQuery',
'Delete knowledge connector query',
'Workspace scope and whether synchronized documents should also be deleted.'
),
response: documentedSchema(
v2DeleteKnowledgeConnectorContract.response.schema,
'V2KnowledgeConnectorDeleteResponse',
'Knowledge connector delete response',
'Deletion acknowledgement and affected document counts.',
[
{
data: {
id: KNOWLEDGE_CONNECTOR_ID,
deleted: true,
documentsDeleted: 0,
documentsKept: 42,
},
},
]
),
}
),
defineOpenApiRoute(
v2SyncKnowledgeConnectorContract,
knowledgeOperation({
operationId: 'syncKnowledgeConnector',
summary: 'Sync Knowledge Connector',
description: `Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_CONFLICT_ERRORS,
success: { description: 'Synchronization was queued.' },
}),
{
query: v2SyncKnowledgeConnectorContract.query,
params: documentedSchema(
v2SyncKnowledgeConnectorContract.params,
'SyncKnowledgeConnectorParams',
'Sync knowledge connector path parameters',
'Knowledge connector selected for synchronization.'
),
body: documentedSchema(
v2SyncKnowledgeConnectorContract.body,
'SyncKnowledgeConnectorRequest',
'Sync knowledge connector request',
'Workspace scope and optional full rehydration control.',
[{ workspaceId: WORKSPACE_ID, rehydrate: false }]
),
response: documentedSchema(
v2SyncKnowledgeConnectorContract.response.schema,
'V2KnowledgeConnectorSyncResponse',
'Knowledge connector sync response',
'Acknowledgement that synchronization was queued.',
[{ data: { id: KNOWLEDGE_CONNECTOR_ID, syncTriggered: true } }]
),
}
),
defineOpenApiRoute(
v2ListKnowledgeConnectorDocumentsContract,
knowledgeOperation({
operationId: 'listKnowledgeConnectorDocuments',
summary: 'List Knowledge Connector Documents',
description: `List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'A page of connector documents.' },
}),
{
params: documentedSchema(
v2ListKnowledgeConnectorDocumentsContract.params,
'ListKnowledgeConnectorDocumentsParams',
'List knowledge connector documents path parameters',
'Knowledge connector whose documents should be listed.'
),
query: documentedSchema(
v2ListKnowledgeConnectorDocumentsContract.query,
'ListKnowledgeConnectorDocumentsQuery',
'List knowledge connector documents query',
'Workspace, exclusion filter, and pagination controls.'
),
response: documentedSchema(
v2ListKnowledgeConnectorDocumentsContract.response.schema,
'V2KnowledgeConnectorDocumentListResponse',
'Knowledge connector document list response',
'A cursor-paginated page of connector documents.',
[{ data: [KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE], nextCursor: null }]
),
}
),
defineOpenApiRoute(
v2UpdateKnowledgeConnectorDocumentsContract,
knowledgeOperation({
operationId: 'updateKnowledgeConnectorDocuments',
summary: 'Update Knowledge Connector Documents',
description: `Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'The selected connector documents were updated.' },
}),
{
query: v2UpdateKnowledgeConnectorDocumentsContract.query,
params: documentedSchema(
v2UpdateKnowledgeConnectorDocumentsContract.params,
'UpdateKnowledgeConnectorDocumentsParams',
'Update knowledge connector documents path parameters',
'Knowledge connector whose documents should be updated.'
),
body: documentedSchema(
v2UpdateKnowledgeConnectorDocumentsContract.body,
'UpdateKnowledgeConnectorDocumentsRequest',
'Update knowledge connector documents request',
'Workspace, restore or exclude operation, and selected document identifiers.',
[
{
workspaceId: WORKSPACE_ID,
operation: 'exclude',
documentIds: [KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE.id],
},
]
),
response: documentedSchema(
v2UpdateKnowledgeConnectorDocumentsContract.response.schema,
'V2KnowledgeConnectorDocumentsUpdateResponse',
'Knowledge connector documents update response',
'Operation result and identifiers actually changed.',
[
{
data: {
operation: 'exclude',
updatedCount: 1,
documentIds: [KNOWLEDGE_CONNECTOR_DOCUMENT_EXAMPLE.id],
},
},
]
),
}
),
defineOpenApiRoute(
v2SearchKnowledgeContract,
knowledgeOperation({
@@ -45,12 +45,16 @@ import {
v2CreateSkillContract,
v2DeleteSkillContract,
v2GetSkillContract,
v2GrantSkillEditorContract,
v2ListSkillEditorsContract,
v2ListSkillsContract,
v2RevokeSkillEditorContract,
v2UpdateSkillContract,
} from '@/lib/api/contracts/v2/skills'
import {
v2GetWorkspaceContract,
v2ListWorkspaceMembersContract,
v2ListWorkspacesContract,
} from '@/lib/api/contracts/v2/workspaces'
import {
defineOpenApiDocument,
@@ -137,6 +141,13 @@ const SKILL_EXAMPLE = {
content: '# Refund policy\n\nAlways check the order date first.',
} as const
const SKILL_EDITOR_EXAMPLE = {
email: 'jane@example.com',
name: 'Jane Smith',
image: null,
isWorkspaceAdmin: false,
} as const
const CUSTOM_TOOL_DECLARATION_EXAMPLE = {
type: 'function',
function: {
@@ -278,6 +289,32 @@ function resourceOperation(
}
const declaredRoutes = [
defineOpenApiRoute(
v2ListWorkspacesContract,
resourceOperation('Workspaces', {
operationId: 'listWorkspaces',
summary: 'List Workspaces',
description:
'List active workspaces available to the API key with opaque cursor pagination. A personal API key sees every accessible workspace that permits personal API keys; a workspace API key sees only its bound workspace.',
errors: RESOURCE_ERRORS,
success: { description: 'Public metadata for workspaces available to the API key.' },
}),
{
query: documentedSchema(
v2ListWorkspacesContract.query,
'ListWorkspacesQuery',
'List workspaces query',
'Sorting and pagination controls for accessible workspaces.'
),
response: documentedSchema(
v2ListWorkspacesContract.response.schema,
'ListWorkspacesResponse',
'List workspaces response',
'Public metadata for workspaces available to the API key.',
[{ data: [WORKSPACE_EXAMPLE], nextCursor: null }]
),
}
),
defineOpenApiRoute(
v2GetWorkspaceContract,
resourceOperation('Workspaces', {
@@ -684,6 +721,107 @@ const declaredRoutes = [
),
}
),
defineOpenApiRoute(
v2ListSkillEditorsContract,
resourceOperation('Skills', {
operationId: 'listSkillEditors',
summary: 'List Skill Editors',
description:
'List explicit skill editors and workspace administrators with opaque cursor pagination. Internal user and membership identifiers are never returned.',
errors: RESOURCE_ERRORS,
success: { description: 'Users who can edit the skill.' },
}),
{
params: documentedSchema(
v2ListSkillEditorsContract.params,
'ListSkillEditorsParams',
'List skill editors path parameters',
'Skill whose editor roster should be listed.'
),
query: documentedSchema(
v2ListSkillEditorsContract.query,
'ListSkillEditorsQuery',
'List skill editors query',
'Workspace, sorting, and pagination controls for the editor roster.'
),
response: documentedSchema(
v2ListSkillEditorsContract.response.schema,
'ListSkillEditorsResponse',
'List skill editors response',
'Public identity fields for users who can edit the skill.',
[{ data: [SKILL_EDITOR_EXAMPLE], nextCursor: null }]
),
}
),
defineOpenApiRoute(
v2GrantSkillEditorContract,
resourceOperation('Skills', {
operationId: 'grantSkillEditor',
summary: 'Grant Skill Editor',
description: `Grant editor access to a current workspace member by email. The caller must already be a skill editor or workspace administrator. Workspace administrators already have derived editor access and cannot receive an explicit grant. A retried existing grant returns 200; a newly created grant returns 201. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: {
byStatus: {
200: { description: 'The workspace member was already a skill editor.' },
201: { description: 'The skill editor grant was created.' },
},
},
}),
{
query: v2GrantSkillEditorContract.query,
params: documentedSchema(
v2GrantSkillEditorContract.params,
'GrantSkillEditorParams',
'Grant skill editor path parameters',
'Skill whose editor roster should be changed.'
),
body: documentedSchema(
v2GrantSkillEditorContract.body,
'GrantSkillEditorRequest',
'Grant skill editor request',
'Workspace scope and email of the member to grant.',
[{ workspaceId: WORKSPACE_ID, email: SKILL_EDITOR_EXAMPLE.email }]
),
response: documentedSchema(
v2GrantSkillEditorContract.response.schema,
'GrantSkillEditorResponse',
'Grant skill editor response',
'Public identity fields for the editor.',
[{ data: SKILL_EDITOR_EXAMPLE }]
),
}
),
defineOpenApiRoute(
v2RevokeSkillEditorContract,
resourceOperation('Skills', {
operationId: 'revokeSkillEditor',
summary: 'Revoke Skill Editor',
description: `Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_ERRORS,
success: { description: 'The explicit editor grant was revoked.' },
}),
{
params: documentedSchema(
v2RevokeSkillEditorContract.params,
'RevokeSkillEditorParams',
'Revoke skill editor path parameters',
'Skill whose editor roster should be changed.'
),
query: documentedSchema(
v2RevokeSkillEditorContract.query,
'RevokeSkillEditorQuery',
'Revoke skill editor query',
'Workspace scope and email whose explicit grant should be revoked.'
),
response: documentedSchema(
v2RevokeSkillEditorContract.response.schema,
'RevokeSkillEditorResponse',
'Revoke skill editor response',
'Acknowledgement that the explicit editor grant was revoked.',
[{ data: { email: SKILL_EDITOR_EXAMPLE.email, revoked: true } }]
),
}
),
defineOpenApiRoute(
v2ListCustomToolsContract,
resourceOperation('Custom Tools', {
@@ -903,7 +1041,7 @@ const declaredRoutes = [
resourceOperation('Credentials', {
operationId: 'createServiceAccountCredential',
summary: 'Create Service-Account Credential',
description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`,
description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider, then encode its required fields as the JSON object string in credentials. The credentials string is write-only and is never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`,
errors: RESOURCE_CONFLICT_ERRORS,
success: {
byStatus: {
@@ -918,16 +1056,15 @@ const declaredRoutes = [
v2CreateServiceAccountCredentialContract.body,
'CreateServiceAccountCredentialRequest',
'Create service-account credential request',
'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.',
'Provider identifier, optional display metadata, and a write-only JSON object string containing the fields declared by provider discovery.',
[
{
workspaceId: WORKSPACE_ID,
type: 'service_account',
providerId: 'zoom-service-account',
displayName: 'Zoom automation',
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
orgId: 'YOUR_ACCOUNT_ID',
credentials:
'{"clientId":"YOUR_CLIENT_ID","clientSecret":"YOUR_CLIENT_SECRET","orgId":"YOUR_ACCOUNT_ID"}',
},
]
),
+96
View File
@@ -90,6 +90,34 @@ export const v2SkillDeleteDataSchema = z
})
export type V2SkillDeleteData = z.output<typeof v2SkillDeleteDataSchema>
export const v2SkillEditorSchema = z
.object({
email: z.string().email().describe('Email address of the skill editor.'),
name: z.string().nullable().describe('Display name of the skill editor.'),
image: z.string().nullable().describe('Profile image URL of the skill editor.'),
isWorkspaceAdmin: z
.boolean()
.describe('Whether editor access is derived from workspace administration.'),
})
.meta({
id: 'V2SkillEditor',
title: 'Skill editor',
description: 'Public identity fields for a user who can edit a skill.',
})
export type V2SkillEditor = z.output<typeof v2SkillEditorSchema>
export const v2SkillEditorDeleteDataSchema = z
.object({
email: z.string().email().describe('Email address whose explicit editor grant was revoked.'),
revoked: z.literal(true).describe('Whether the explicit editor grant was revoked.'),
})
.meta({
id: 'V2SkillEditorDeleteData',
title: 'Revoke skill editor data',
description: 'Skill editor revocation acknowledgement.',
})
export type V2SkillEditorDeleteData = z.output<typeof v2SkillEditorDeleteDataSchema>
export const v2SkillParamsSchema = z.object({
id: nonEmptyIdSchema.describe(
'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.'
@@ -118,6 +146,39 @@ export const v2ListSkillsQuerySchema = v2SkillWorkspaceQuerySchema
export type V2ListSkillsQuery = z.output<typeof v2ListSkillsQuerySchema>
export const v2SkillEditorSortFields = ['email', 'name'] as const
export type V2SkillEditorSortBy = (typeof v2SkillEditorSortFields)[number]
export const v2ListSkillEditorsQuerySchema = v2SkillWorkspaceQuerySchema
.extend({
...v2SortFields(v2SkillEditorSortFields, { sortBy: 'email', sortOrder: 'asc' }),
...v2PaginationFields({ description: 'Maximum skill editors to return per page.' }),
})
.strict()
export type V2ListSkillEditorsQuery = z.output<typeof v2ListSkillEditorsQuerySchema>
const skillEditorEmailSchema = z
.string()
.trim()
.email('A valid editor email is required')
.describe('Email address of a current workspace member.')
export const v2GrantSkillEditorBodySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'),
email: skillEditorEmailSchema,
})
.strict()
export type V2GrantSkillEditorBody = z.input<typeof v2GrantSkillEditorBodySchema>
export const v2RevokeSkillEditorQuerySchema = z
.object({
workspaceId: workspaceIdSchema.describe('Workspace that owns the skill.'),
email: skillEditorEmailSchema,
})
.strict()
export type V2RevokeSkillEditorQuery = z.input<typeof v2RevokeSkillEditorQuerySchema>
/**
* Create body. Every field is required, so each one carries the missing-value
* wording the shared field primitives cannot: those are also spelled `.optional()`
@@ -228,3 +289,38 @@ export const v2DeleteSkillContract = defineRouteContract({
schema: v2DataResponse(v2SkillDeleteDataSchema),
},
})
export const v2ListSkillEditorsContract = defineRouteContract({
method: 'GET',
path: '/api/v2/skills/[id]/editors',
params: v2SkillParamsSchema,
query: v2ListSkillEditorsQuerySchema,
response: {
mode: 'json',
schema: v2CursorListResponse(v2SkillEditorSchema),
},
})
export const v2GrantSkillEditorContract = defineRouteContract({
method: 'POST',
path: '/api/v2/skills/[id]/editors',
params: v2SkillParamsSchema,
query: noInputSchema,
body: v2GrantSkillEditorBodySchema,
response: {
mode: 'json',
schema: v2DataResponse(v2SkillEditorSchema),
status: [200, 201],
},
})
export const v2RevokeSkillEditorContract = defineRouteContract({
method: 'DELETE',
path: '/api/v2/skills/[id]/editors',
params: v2SkillParamsSchema,
query: v2RevokeSkillEditorQuerySchema,
response: {
mode: 'json',
schema: v2DataResponse(v2SkillEditorDeleteDataSchema),
},
})
@@ -5,6 +5,7 @@ import {
v2CursorListResponse,
v2DataResponse,
v2PaginationFields,
v2SortFields,
v2TimestampSchema,
} from '@/lib/api/contracts/v2/shared'
@@ -37,6 +38,17 @@ export const v2WorkspaceSchema = z
})
export type V2Workspace = z.output<typeof v2WorkspaceSchema>
export const v2WorkspaceSortFields = ['name', 'createdAt', 'updatedAt'] as const
export type V2WorkspaceSortBy = (typeof v2WorkspaceSortFields)[number]
export const v2ListWorkspacesQuerySchema = z
.object({
...v2SortFields(v2WorkspaceSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }),
...v2PaginationFields({ description: 'Maximum workspaces to return per page.' }),
})
.strict()
export type V2ListWorkspacesQuery = z.output<typeof v2ListWorkspacesQuerySchema>
export const v2WorkspaceMemberSchema = z
.object({
email: z.email().describe('Member email address and public member identifier.'),
@@ -77,6 +89,13 @@ export const v2GetWorkspaceContract = defineRouteContract({
response: { mode: 'json', schema: v2DataResponse(v2WorkspaceSchema) },
})
export const v2ListWorkspacesContract = defineRouteContract({
method: 'GET',
path: '/api/v2/workspaces',
query: v2ListWorkspacesQuerySchema,
response: { mode: 'json', schema: v2CursorListResponse(v2WorkspaceSchema) },
})
export const v2ListWorkspaceMembersContract = defineRouteContract({
method: 'GET',
path: '/api/v2/workspaces/[workspaceId]/members',
@@ -35,6 +35,7 @@ export const DOCS_MANIFEST: readonly string[] = [
'cli/logs.mdx',
'cli/mcp-servers.mdx',
'cli/output.mdx',
'cli/profiles.mdx',
'cli/reference.mdx',
'cli/scripting.mdx',
'cli/secrets.mdx',
@@ -534,8 +534,11 @@ describe('knowledge connector application use cases', () => {
{ id: 'document-4', filename: 'd.txt', userExcluded: true },
],
counts: { active: 5, excluded: 2 },
hasMore: false,
offset: 2,
limit: 2,
})
expect(dbChainMockFns.limit).toHaveBeenCalledWith(2)
expect(dbChainMockFns.limit).toHaveBeenCalledWith(3)
expect(dbChainMockFns.offset).toHaveBeenCalledWith(2)
})
@@ -12,7 +12,10 @@ import {
resolveCredentialTokenIdentity,
} from '@/lib/credentials/access'
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing'
import {
resolveKnowledgeAttributedUserId,
resolveKnowledgeBillingAttribution,
} from '@/lib/knowledge/application/billing'
import {
type ActiveKnowledgeResourceBaseContext,
resolveActiveKnowledgeConnectorContext,
@@ -46,6 +49,10 @@ interface KnowledgeConnectorApplicationInput {
export interface ListKnowledgeConnectorsInput extends KnowledgeConnectorApplicationInput {
knowledgeBaseId: string
sortBy?: 'connectorType' | 'createdAt' | 'updatedAt'
sortOrder?: 'asc' | 'desc'
limit?: number
offset?: number
}
export interface ReadKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput {
@@ -60,7 +67,7 @@ export interface CreateKnowledgeConnectorInput extends KnowledgeConnectorApplica
apiKey?: string
sourceConfig: Record<string, unknown>
syncIntervalMinutes: number
resolveBillingAttribution(workspaceId: string): Promise<BillingAttributionSnapshot>
resolveBillingAttribution?(workspaceId: string): Promise<BillingAttributionSnapshot>
}
export interface UpdateKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput {
@@ -80,7 +87,7 @@ export interface DeleteKnowledgeConnectorInput extends KnowledgeConnectorApplica
export interface SyncKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput {
connectorId: string
rehydrate?: boolean
resolveBillingAttribution(workspaceId: string): Promise<BillingAttributionSnapshot>
resolveBillingAttribution?(workspaceId: string): Promise<BillingAttributionSnapshot>
}
export interface ListKnowledgeConnectorDocumentsInput extends ReadKnowledgeConnectorInput {
@@ -224,8 +231,15 @@ export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({
operation: knowledgeOperations.listConnectors,
resolveContext: ({ input }: { input: ListKnowledgeConnectorsInput }) =>
resolveActiveKnowledgeResourceContext(input),
async execute({ context }) {
const connectors = await db
async execute({ input, context }) {
const sortOrder = input.sortOrder === 'asc' ? asc : desc
const sortColumn =
input.sortBy === 'connectorType'
? knowledgeConnector.connectorType
: input.sortBy === 'updatedAt'
? knowledgeConnector.updatedAt
: knowledgeConnector.createdAt
const orderedQuery = db
.select()
.from(knowledgeConnector)
.where(
@@ -235,8 +249,20 @@ export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({
isNull(knowledgeConnector.deletedAt)
)
)
.orderBy(desc(knowledgeConnector.createdAt))
return { connectors: connectors.map(({ encryptedApiKey: _encryptedApiKey, ...rest }) => rest) }
.orderBy(sortOrder(sortColumn), sortOrder(knowledgeConnector.id))
const offset = input.offset ?? 0
const rows =
input.limit === undefined
? await orderedQuery
: await orderedQuery.limit(input.limit + 1).offset(offset)
const hasMore = input.limit !== undefined && rows.length > input.limit
const page = input.limit === undefined ? rows : rows.slice(0, input.limit)
return {
connectors: page.map(({ encryptedApiKey: _encryptedApiKey, ...rest }) => rest),
hasMore,
offset,
limit: input.limit ?? page.length,
}
},
})
@@ -273,7 +299,9 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({
apiKey: input.apiKey,
sourceConfig: input.sourceConfig,
syncIntervalMinutes: input.syncIntervalMinutes,
resolveBillingAttribution: () => input.resolveBillingAttribution(workspaceId),
resolveBillingAttribution: () =>
input.resolveBillingAttribution?.(workspaceId) ??
resolveKnowledgeBillingAttribution(principal, context),
resolveAccessToken: (credentialId) =>
resolveConnectorCredentialAccessToken({
credentialId,
@@ -413,7 +441,9 @@ export const syncKnowledgeConnector = defineAuthorizedKnowledgeUseCase({
const outcome = await performSyncKnowledgeConnector({
knowledgeBase: connectorTarget(context),
connectorId: context.connectorId,
resolveBillingAttribution: () => input.resolveBillingAttribution(workspaceId),
resolveBillingAttribution: () =>
input.resolveBillingAttribution?.(workspaceId) ??
resolveKnowledgeBillingAttribution(principal, context),
rehydrate: input.rehydrate,
userId: resolveKnowledgeAttributedUserId(principal, context),
source: input.source ?? 'agent',
@@ -499,18 +529,23 @@ export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase(
: Promise.resolve([{ value: 0 }]),
])
const excludedCount = excludedCountRows[0]
const documents = await db
const rows = await db
.select(connectorDocumentSelection)
.from(document)
.where(
and(...baseConditions, input.includeExcluded ? undefined : eq(document.userExcluded, false))
)
.orderBy(asc(document.userExcluded), asc(document.filename))
.limit(limit)
.limit(limit + 1)
.offset(offset)
const hasMore = rows.length > limit
const documents = rows.slice(0, limit)
return {
documents,
counts: { active: activeCount?.value ?? 0, excluded: excludedCount?.value ?? 0 },
hasMore,
offset,
limit,
}
},
})
@@ -104,10 +104,14 @@ describe('knowledge operation registry', () => {
knowledgeOperations.updateTag,
knowledgeOperations.deleteTag,
knowledgeOperations.readTagUsage,
knowledgeOperations.listConnectors,
knowledgeOperations.readConnector,
knowledgeOperations.createConnector,
knowledgeOperations.updateConnector,
knowledgeOperations.deleteConnector,
knowledgeOperations.syncConnector,
knowledgeOperations.listConnectorDocuments,
knowledgeOperations.updateConnectorDocuments,
]
for (const operation of operations) {
expect(operation.workspaceApiKey).toBe('deny')
@@ -0,0 +1,291 @@
/**
* @vitest-environment node
*/
import { skill } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
loadWorkspace: vi.fn(),
resolvePermission: vi.fn(),
getActor: vi.fn(),
listEditors: vi.fn(),
listWorkspaceMembers: vi.fn(),
recordAudit: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
AuditAction: {
SKILL_CREATED: 'skill.created',
SKILL_UPDATED: 'skill.updated',
SKILL_DELETED: 'skill.deleted',
SKILL_MEMBER_ADDED: 'skill.member_added',
SKILL_MEMBER_REMOVED: 'skill.member_removed',
},
AuditResourceType: { SKILL: 'skill' },
recordAudit: mocks.recordAudit,
}))
vi.mock('@sim/platform-authz/workspace', () => ({
permissionSatisfies: (actual: string | null, required: string) => {
const rank = { read: 1, write: 2, admin: 3 } as const
return (
actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank]
)
},
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
}))
vi.mock('@/lib/uploads/contexts/workspace', () => ({
loadActiveWorkspaceContext: mocks.loadWorkspace,
}))
vi.mock('@/lib/skills/access', () => ({
getSkillActorContext: mocks.getActor,
listSkillEditors: mocks.listEditors,
}))
vi.mock('@/lib/skills/orchestration', () => ({
createSkill: vi.fn(),
deleteSkillRecord: vi.fn(),
updateSkill: vi.fn(),
upsertSkillBatch: vi.fn(),
}))
vi.mock('@/lib/workflows/skills/operations', () => ({
getSkillById: vi.fn(),
listSkillSummariesPage: vi.fn(),
listSkillsForUser: vi.fn(),
}))
vi.mock('@/lib/workflows/skills/builtin-skills', () => ({
isBuiltinSkillId: (id: string) => id.startsWith('builtin-'),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUsersWithPermissions: mocks.listWorkspaceMembers,
}))
import {
grantSkillEditorUseCase,
listSkillEditorsUseCase,
revokeSkillEditorUseCase,
} from '@/lib/skills/application/use-cases'
const WORKSPACE_ID = 'workspace-1'
const SKILL_ID = 'skill-1'
const ACTOR_ID = 'user-1'
const TARGET_ID = 'user-2'
const TARGET_EMAIL = 'ada@example.com'
const principal = { kind: 'personal_api_key' as const, userId: ACTOR_ID, keyId: 'key-1' }
const skillRow = {
id: SKILL_ID,
workspaceId: WORKSPACE_ID,
userId: ACTOR_ID,
name: 'research',
description: 'Research instructions',
content: 'Investigate the topic.',
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-02T00:00:00Z'),
}
const workspaceContext = {
workspaceId: WORKSPACE_ID,
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'billing-owner-1',
}
const targetMember = {
userId: TARGET_ID,
email: TARGET_EMAIL,
name: 'Ada',
image: null,
permissionType: 'write',
isExternal: false,
joinedAt: '2026-01-01T00:00:00.000Z',
roleSource: 'explicit',
isOrgAdmin: false,
isBilledAccount: false,
}
const targetEditor = {
id: 'skill-member-1',
userId: TARGET_ID,
userName: targetMember.name,
userEmail: targetMember.email,
userImage: targetMember.image,
isWorkspaceAdmin: false,
}
function queueSkill(): void {
queueTableRows(skill, [skillRow])
}
describe('skill editor application use cases', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mocks.loadWorkspace.mockResolvedValue(workspaceContext)
mocks.resolvePermission.mockResolvedValue('read')
mocks.getActor.mockResolvedValue({
skill: skillRow,
hasWorkspaceAccess: true,
canEdit: true,
})
mocks.listEditors.mockResolvedValue([])
mocks.listWorkspaceMembers.mockResolvedValue([targetMember])
})
afterAll(resetDbChainMock)
it('lists and paginates the full editor roster after workspace authorization', async () => {
queueSkill()
mocks.listEditors.mockResolvedValue([
targetEditor,
{
...targetEditor,
id: 'workspace-admin-user-3',
userId: 'user-3',
userName: 'Grace',
userEmail: 'grace@example.com',
isWorkspaceAdmin: true,
},
])
const result = await listSkillEditorsUseCase.execute({
principal,
input: {
workspaceId: WORKSPACE_ID,
skillId: SKILL_ID,
sortBy: 'name',
sortOrder: 'desc',
limit: 1,
offset: 0,
},
})
expect(result.editors.map(({ userName }) => userName)).toEqual(['Grace'])
expect(result.hasMore).toBe(true)
expect(mocks.resolvePermission).toHaveBeenCalled()
})
it('creates an explicit grant and audits the authoritative result', async () => {
queueSkill()
dbChainMockFns.returning.mockResolvedValueOnce([{ id: targetEditor.id }])
const result = await grantSkillEditorUseCase.execute({
principal,
input: {
workspaceId: WORKSPACE_ID,
skillId: SKILL_ID,
target: { kind: 'email', email: TARGET_EMAIL },
},
})
expect(result).toMatchObject({ created: true, editor: targetEditor })
expect(dbChainMockFns.insert).toHaveBeenCalled()
expect(mocks.recordAudit).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: WORKSPACE_ID,
action: 'skill.member_added',
metadata: expect.objectContaining({ targetUserId: TARGET_ID }),
})
)
})
it('treats an existing editor grant as an unaudited idempotent success', async () => {
queueSkill()
mocks.listEditors.mockResolvedValue([targetEditor])
const result = await grantSkillEditorUseCase.execute({
principal,
input: {
workspaceId: WORKSPACE_ID,
skillId: SKILL_ID,
target: { kind: 'email', email: TARGET_EMAIL },
},
})
expect(result.created).toBe(false)
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})
it('requires the actor to be a current editor before resolving the target', async () => {
queueSkill()
mocks.getActor.mockResolvedValue({
skill: skillRow,
hasWorkspaceAccess: true,
canEdit: false,
})
await expect(
grantSkillEditorUseCase.execute({
principal,
input: {
workspaceId: WORKSPACE_ID,
skillId: SKILL_ID,
target: { kind: 'email', email: TARGET_EMAIL },
},
})
).rejects.toMatchObject({ code: 'forbidden', detailCode: 'SKILL_EDITOR_ACCESS_REQUIRED' })
expect(mocks.listWorkspaceMembers).not.toHaveBeenCalled()
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('refuses explicit grants for derived workspace administrators', async () => {
queueSkill()
mocks.listWorkspaceMembers.mockResolvedValue([
{ ...targetMember, permissionType: 'admin', roleSource: 'org-admin', isOrgAdmin: true },
])
await expect(
grantSkillEditorUseCase.execute({
principal,
input: {
workspaceId: WORKSPACE_ID,
skillId: SKILL_ID,
target: { kind: 'email', email: TARGET_EMAIL },
},
})
).rejects.toMatchObject({ code: 'validation' })
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
it('returns not-found when revoking a member without an explicit grant', async () => {
queueSkill()
await expect(
revokeSkillEditorUseCase.execute({
principal,
input: {
workspaceId: WORKSPACE_ID,
skillId: SKILL_ID,
target: { kind: 'email', email: TARGET_EMAIL },
},
})
).rejects.toMatchObject({ code: 'not_found' })
expect(dbChainMockFns.delete).toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})
it('conceals an asserted workspace mismatch before workspace authorization', async () => {
queueSkill()
await expect(
listSkillEditorsUseCase.execute({
principal,
input: {
workspaceId: 'workspace-2',
skillId: SKILL_ID,
sortBy: 'email',
sortOrder: 'asc',
},
})
).rejects.toMatchObject({ code: 'not_found' })
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
expect(mocks.resolvePermission).not.toHaveBeenCalled()
})
})
@@ -80,6 +80,21 @@ describe('skill operation registry', () => {
).toThrow(/does not represent a human subject/)
})
it('allows workspace keys to read editor rosters but keeps roster mutations personal', () => {
expect(skillOperations.listEditors).toMatchObject({
minimumRole: 'read',
workspaceApiKey: 'allow',
principalKinds: ['session', 'personal_api_key', 'workspace_api_key'],
})
for (const operation of [skillOperations.grantEditor, skillOperations.revokeEditor]) {
expect(operation).toMatchObject({
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['session', 'personal_api_key'],
})
}
})
it('uses unique stable operation IDs', () => {
const ids = Object.values(skillOperations).map((operation) => operation.id)
expect(new Set(ids).size).toBe(ids.length)
@@ -8,6 +8,12 @@ const HUMAN_PRINCIPAL_POLICY = {
principalKinds: ['session', 'personal_api_key', 'delegated'],
delegatedServices: ['copilot'],
} as const
const HTTP_SKILL_EDITOR_READ_POLICY = {
principalKinds: ['session', 'personal_api_key', 'workspace_api_key'],
} as const
const HUMAN_HTTP_SKILL_EDITOR_POLICY = {
principalKinds: ['session', 'personal_api_key'],
} as const
/**
* Every skill write is human-subject-only. Reads are not.
@@ -83,6 +89,24 @@ export const skillOperations = {
workspaceApiKey: 'deny',
...HUMAN_PRINCIPAL_POLICY,
}),
listEditors: defineWorkspaceOperation({
id: 'skills.editors.list',
minimumRole: 'read',
workspaceApiKey: 'allow',
...HTTP_SKILL_EDITOR_READ_POLICY,
}),
grantEditor: defineWorkspaceOperation({
id: 'skills.editors.grant',
minimumRole: 'read',
workspaceApiKey: 'deny',
...HUMAN_HTTP_SKILL_EDITOR_POLICY,
}),
revokeEditor: defineWorkspaceOperation({
id: 'skills.editors.revoke',
minimumRole: 'read',
workspaceApiKey: 'deny',
...HUMAN_HTTP_SKILL_EDITOR_POLICY,
}),
} as const
export type SkillOperation = (typeof skillOperations)[keyof typeof skillOperations]
+215 -1
View File
@@ -1,12 +1,17 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal'
import type { skill } from '@sim/db/schema'
import { db } from '@sim/db'
import { skill, skillMember } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import type { ListSortOrder } from '@/lib/api/list-query'
import {
authorizeWorkspaceOperation,
defineAuthorizedWorkspaceUseCase,
ForbiddenOperationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { getSkillActorContext, listSkillEditors, type SkillEditor } from '@/lib/skills/access'
import { skillDelegationPolicy } from '@/lib/skills/application/authorization'
import { skillOperations } from '@/lib/skills/application/operations'
import {
@@ -17,12 +22,14 @@ import {
upsertSkillBatch,
} from '@/lib/skills/orchestration'
import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace'
import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills'
import {
getSkillById,
listSkillSummariesPage,
listSkillsForUser,
type SkillSortBy,
} from '@/lib/workflows/skills/operations'
import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils'
type SkillRow = typeof skill.$inferSelect
type SkillWriteSource = 'api' | 'settings' | 'tool_input'
@@ -51,8 +58,78 @@ async function resolveSkillContext(workspaceId: string, skillId: string): Promis
return { ...workspace, skill: row }
}
async function resolveSkillEditorContext(
skillId: string,
assertedWorkspaceId?: string
): Promise<SkillContext> {
if (isBuiltinSkillId(skillId)) throw new OrchestrationError('not_found', 'Skill not found')
const [row] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1)
if (!row?.workspaceId || (assertedWorkspaceId && row.workspaceId !== assertedWorkspaceId)) {
throw new OrchestrationError('not_found', 'Skill not found')
}
const workspace = await resolveWorkspaceContext(row.workspaceId)
return { ...workspace, skill: row }
}
const authorizationOptions = { delegation: skillDelegationPolicy }
async function requireSkillEditorAccess(userId: string, context: SkillContext): Promise<void> {
const actor = await getSkillActorContext(context.skill.id, userId)
if (
!actor.skill ||
actor.skill.workspaceId !== context.workspaceId ||
!actor.hasWorkspaceAccess
) {
throw new OrchestrationError('not_found', 'Skill not found')
}
if (!actor.canEdit) {
throw new ForbiddenOperationError(
'SKILL_EDITOR_ACCESS_REQUIRED',
'Skill editor access required'
)
}
}
export type SkillEditorTarget =
| { kind: 'user_id'; userId: string }
| { kind: 'email'; email: string }
async function resolveSkillEditorTarget(
context: SkillContext,
target: SkillEditorTarget
): Promise<SkillEditor> {
const editors = await listSkillEditors({
id: context.skill.id,
workspaceId: context.workspaceId,
})
const workspaceMembers = await getUsersWithPermissions(context.workspaceId)
const member =
target.kind === 'user_id'
? workspaceMembers.find(({ userId }) => userId === target.userId)
: workspaceMembers.find(
({ email }) => email.toLowerCase() === target.email.trim().toLowerCase()
)
if (!member) {
throw new OrchestrationError('validation', 'User is not a member of this workspace')
}
if (member.permissionType === 'admin') {
throw new OrchestrationError('validation', 'Workspace admins can always edit skills')
}
const existing = editors.find(({ userId }) => userId === member.userId)
return (
existing ?? {
id: '',
userId: member.userId,
userName: member.name,
userEmail: member.email,
userImage: member.image,
isWorkspaceAdmin: false,
}
)
}
export interface ListSkillsInput {
workspaceId: string
search?: string
@@ -278,3 +355,140 @@ export const deleteSkillUseCase = defineAuthorizedWorkspaceUseCase({
metadata: { source: input.source },
}),
})
export interface ListSkillEditorsInput {
workspaceId?: string
skillId: string
sortBy: 'email' | 'name'
sortOrder: ListSortOrder
limit?: number
offset?: number
}
export const listSkillEditorsUseCase = defineAuthorizedWorkspaceUseCase({
operation: skillOperations.listEditors,
resolveContext: ({ input }: { input: ListSkillEditorsInput }) =>
resolveSkillEditorContext(input.skillId, input.workspaceId),
authorizationOptions,
async execute({ input, context }) {
const editors = await listSkillEditors({
id: context.skill.id,
workspaceId: context.workspaceId,
})
const direction = input.sortOrder === 'asc' ? 1 : -1
const sorted = editors.sort((left, right) => {
const leftValue = input.sortBy === 'email' ? (left.userEmail ?? '') : (left.userName ?? '')
const rightValue = input.sortBy === 'email' ? (right.userEmail ?? '') : (right.userName ?? '')
const primary = leftValue.localeCompare(rightValue)
return primary === 0
? left.userId.localeCompare(right.userId) * direction
: primary * direction
})
if (input.limit === undefined) {
return { editors: sorted, hasMore: false, offset: 0, limit: sorted.length }
}
const offset = input.offset ?? 0
return {
editors: sorted.slice(offset, offset + input.limit),
hasMore: sorted.length > offset + input.limit,
offset,
limit: input.limit,
}
},
})
export interface GrantSkillEditorInput {
workspaceId?: string
skillId: string
target: SkillEditorTarget
}
export const grantSkillEditorUseCase = defineAuthorizedWorkspaceUseCase({
operation: skillOperations.grantEditor,
resolveContext: ({ input }: { input: GrantSkillEditorInput }) =>
resolveSkillEditorContext(input.skillId, input.workspaceId),
authorizationOptions,
authorizeResource: ({ principal, context }) =>
requireSkillEditorAccess(requirePrincipalSubjectUserId(principal), context),
async execute({ principal, input, context }) {
const target = await resolveSkillEditorTarget(context, input.target)
if (target.id) return { editor: target, created: false, workspaceId: context.workspaceId }
const now = new Date()
const [inserted] = await db
.insert(skillMember)
.values({
id: generateId(),
skillId: context.skill.id,
userId: target.userId,
invitedBy: requirePrincipalSubjectUserId(principal),
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] })
.returning({ id: skillMember.id })
if (inserted) {
return {
editor: { ...target, id: inserted.id },
created: true,
workspaceId: context.workspaceId,
}
}
const [concurrent] = await db
.select({ id: skillMember.id })
.from(skillMember)
.where(and(eq(skillMember.skillId, context.skill.id), eq(skillMember.userId, target.userId)))
.limit(1)
if (!concurrent) throw new Error('Skill editor insert conflicted without an existing row')
return {
editor: { ...target, id: concurrent.id },
created: false,
workspaceId: context.workspaceId,
}
},
projectAudit: ({ result, context }) =>
result.created
? {
action: AuditAction.SKILL_MEMBER_ADDED,
resourceType: AuditResourceType.SKILL,
resourceId: context.skill.id,
resourceName: context.skill.name,
description: 'Added skill editor',
metadata: { targetUserId: result.editor.userId },
}
: [],
})
export interface RevokeSkillEditorInput {
workspaceId?: string
skillId: string
target: SkillEditorTarget
}
export const revokeSkillEditorUseCase = defineAuthorizedWorkspaceUseCase({
operation: skillOperations.revokeEditor,
resolveContext: ({ input }: { input: RevokeSkillEditorInput }) =>
resolveSkillEditorContext(input.skillId, input.workspaceId),
authorizationOptions,
authorizeResource: ({ principal, context }) =>
requireSkillEditorAccess(requirePrincipalSubjectUserId(principal), context),
async execute({ input, context }) {
const target = await resolveSkillEditorTarget(context, input.target)
const [removed] = await db
.delete(skillMember)
.where(and(eq(skillMember.skillId, context.skill.id), eq(skillMember.userId, target.userId)))
.returning({ id: skillMember.id })
if (!removed) throw new OrchestrationError('not_found', 'Editor not found')
return { editor: target, workspaceId: context.workspaceId }
},
projectAudit: ({ result, context }) => ({
action: AuditAction.SKILL_MEMBER_REMOVED,
resourceType: AuditResourceType.SKILL,
resourceId: context.skill.id,
resourceName: context.skill.name,
description: 'Removed skill editor',
metadata: { targetUserId: result.editor.userId },
}),
})
@@ -0,0 +1,95 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
listAccessible: vi.fn(),
getDetail: vi.fn(),
loadContext: vi.fn(),
}))
vi.mock('@/lib/workspaces/utils', () => ({
listAccessibleWorkspaceRowsForUser: mocks.listAccessible,
}))
vi.mock('@/lib/workspaces/public-queries', () => ({
getPublicWorkspaceDetail: mocks.getDetail,
}))
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
loadActiveWorkspaceApplicationContext: mocks.loadContext,
}))
import { listPublicWorkspaces } from '@/lib/workspaces/application/list-public-workspaces'
const workspace = (id: string, name: string, allowPersonalApiKeys: boolean, day: number) => ({
id,
name,
allowPersonalApiKeys,
createdAt: new Date(`2026-01-${String(day).padStart(2, '0')}T00:00:00Z`),
updatedAt: new Date(`2026-02-${String(day).padStart(2, '0')}T00:00:00Z`),
})
describe('listPublicWorkspaces', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getDetail.mockImplementation(async (id: string) => ({
id,
name: id,
color: '#33C482',
logoUrl: null,
memberCount: 1,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-02T00:00:00Z'),
}))
})
it('lists every accessible workspace where personal keys are enabled', async () => {
mocks.listAccessible.mockResolvedValue([
{
workspace: workspace('workspace-b', 'Beta', true, 2),
permissionType: 'read',
viaOrgAdmin: false,
},
{
workspace: workspace('workspace-disabled', 'Disabled', false, 3),
permissionType: 'admin',
viaOrgAdmin: true,
},
{
workspace: workspace('workspace-a', 'Alpha', true, 1),
permissionType: 'write',
viaOrgAdmin: false,
},
])
const result = await listPublicWorkspaces.execute({
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
input: { sortBy: 'name', sortOrder: 'asc', limit: 1, offset: 0 },
})
expect(result.workspaces.map(({ id }) => id)).toEqual(['workspace-a'])
expect(result.hasMore).toBe(true)
expect(mocks.getDetail).not.toHaveBeenCalledWith('workspace-disabled')
})
it('limits a workspace key to its bound active workspace', async () => {
mocks.loadContext.mockResolvedValue({
workspaceId: 'workspace-bound',
workspaceOrganizationId: null,
allowPersonalApiKeys: false,
billedAccountUserId: 'owner-1',
})
const result = await listPublicWorkspaces.execute({
principal: {
kind: 'workspace_api_key',
workspaceId: 'workspace-bound',
keyId: 'key-1',
},
input: { sortBy: 'createdAt', sortOrder: 'desc', limit: 50, offset: 0 },
})
expect(result.workspaces.map(({ id }) => id)).toEqual(['workspace-bound'])
expect(mocks.listAccessible).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,91 @@
import type { ListSortOrder } from '@/lib/api/list-query'
import {
authorizeWorkspaceOperation,
type OperationUseCase,
requireAllowedWorkspacePrincipal,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { workspaceOperations } from '@/lib/workspaces/application/operations'
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
import {
getPublicWorkspaceDetail,
type PublicWorkspaceDetail,
} from '@/lib/workspaces/public-queries'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
export interface ListPublicWorkspacesInput {
sortBy: 'name' | 'createdAt' | 'updatedAt'
sortOrder: ListSortOrder
limit: number
offset: number
}
export interface ListPublicWorkspacesResult {
workspaces: PublicWorkspaceDetail[]
hasMore: boolean
offset: number
limit: number
}
type WorkspaceRow = Awaited<
ReturnType<typeof listAccessibleWorkspaceRowsForUser>
>[number]['workspace']
function compareWorkspaceRows(
left: WorkspaceRow,
right: WorkspaceRow,
sortBy: ListPublicWorkspacesInput['sortBy'],
sortOrder: ListSortOrder
): number {
const direction = sortOrder === 'asc' ? 1 : -1
const primary =
sortBy === 'name'
? left.name.localeCompare(right.name)
: left[sortBy].getTime() - right[sortBy].getTime()
return primary === 0 ? left.id.localeCompare(right.id) * direction : primary * direction
}
async function requirePublicWorkspaceDetail(workspaceId: string): Promise<PublicWorkspaceDetail> {
const workspace = await getPublicWorkspaceDetail(workspaceId)
if (!workspace) throw new Error(`Accessible workspace ${workspaceId} disappeared during listing`)
return workspace
}
export const listPublicWorkspaces: OperationUseCase<
typeof workspaceOperations.listPublic,
ListPublicWorkspacesInput,
ListPublicWorkspacesResult
> = {
operation: workspaceOperations.listPublic,
async execute({ principal, input }) {
requireAllowedWorkspacePrincipal(principal, workspaceOperations.listPublic)
if (principal.kind === 'workspace_api_key') {
const context = await loadActiveWorkspaceApplicationContext(principal.workspaceId)
if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
await authorizeWorkspaceOperation(principal, workspaceOperations.listPublic, context)
const workspace = await requirePublicWorkspaceDetail(context.workspaceId)
return {
workspaces: input.offset === 0 ? [workspace] : [],
hasMore: false,
offset: input.offset,
limit: input.limit,
}
}
const accessible = await listAccessibleWorkspaceRowsForUser(principal.userId, 'active')
const sorted = accessible
.filter(({ workspace }) => workspace.allowPersonalApiKeys)
.map(({ workspace }) => workspace)
.sort((left, right) => compareWorkspaceRows(left, right, input.sortBy, input.sortOrder))
const page = sorted.slice(input.offset, input.offset + input.limit)
const workspaces = await Promise.all(page.map(({ id }) => requirePublicWorkspaceDetail(id)))
return {
workspaces,
hasMore: sorted.length > input.offset + input.limit,
offset: input.offset,
limit: input.limit,
}
},
}
@@ -3,6 +3,12 @@ import { defineWorkspaceOperation } from '@/lib/core/application'
const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const
export const workspaceOperations = {
listPublic: defineWorkspaceOperation({
id: 'workspaces.list_public',
minimumRole: 'read',
workspaceApiKey: 'allow',
principalKinds: PUBLIC_API_PRINCIPAL_KINDS,
}),
readPublicDetail: defineWorkspaceOperation({
id: 'workspaces.read_public_detail',
minimumRole: 'read',
+16 -7
View File
@@ -12,9 +12,9 @@ Full documentation: **https://docs.sim.ai/cli**
## Profiles
Profiles work like the AWS CLI: one identity and one set of defaults per named
profile, selected with `-P`, `--profile`, or `SIM_PROFILE`. This is what lets you keep
production and a local dev stack side by side without re-authenticating.
Profiles work like the AWS CLI and are selected with `-P`, `--profile`, or
`SIM_PROFILE`. A profile normally owns one identity and one set of defaults; a
workspace profile can instead share a stored identity through `auth_profile`.
Non-secret settings live in `~/.sim/config`:
@@ -27,6 +27,10 @@ output = table
[profile dev]
endpoint = http://localhost:3000
workspace = ws_local
[profile acme]
auth_profile = default
workspace = ws_acme
```
Keys live in `~/.sim/credentials`, written `0600`:
@@ -45,7 +49,8 @@ The section-naming asymmetry — `[profile dev]` in config, `[dev]` in credentia
```bash
sim configure --set-endpoint http://localhost:3000 --profile dev
sim configure --set-workspace ws_local --profile dev
sim profiles # list them; * marks the active one
sim profiles # list them; * marks the active one
sim profile add acme --workspace ws_acme # share the active stored login
sim whoami # resolved values, where each came from, and whether they work
```
@@ -62,7 +67,7 @@ Each setting resolves independently, first match wins:
indefinitely) and `SIM_DEBUG=1` traces requests to stderr. Node ignores
`HTTPS_PROXY` unless `NODE_USE_ENV_PROXY=1` is also set, on Node 22.21+ or
24.5+; the CLI warns when a proxy is configured but will not be used.
| 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile |
| 3 | `~/.sim/config` for the selected profile and credentials for its `auth_profile`, when set |
| 4 | Built-in default (`https://www.sim.ai`, `table`) |
Formats are listed under [Output formats](#output-formats).
@@ -112,8 +117,11 @@ the key can access.
`sim login --workspace <id>` preselects a workspace in the picker, and an
existing profile's workspace preselects itself on re-login.
`sim logout` removes the stored key. It does not revoke it — do that in
Settings → API keys.
`sim logout` removes the stored key. A shared workspace profile cannot remove
its authentication profile's key; use `sim logout --all --profile <name>` to
remove only the workspace profile. An authentication profile cannot be removed
entirely while workspace profiles reference it. Logging out does not revoke a
key — do that in Settings → API keys.
## Commands
@@ -147,6 +155,7 @@ sim logs get <runId>
sim audit-logs list --organization <organizationId> [--all-workspaces]
sim audit-logs get <id> --organization <organizationId>
sim workspaces list
sim workspaces get
sim workspaces members
+302 -2
View File
@@ -5,9 +5,12 @@ const mocks = vi.hoisted(() => ({
buildApprovalUrl: vi.fn(() => 'https://sim.ai/cli/auth?code=ABCD'),
createAuthRequest: vi.fn(() => ({ pairing: 'ABCD', verifier: 'verifier' })),
createInterface: vi.fn(),
deleteProfile: vi.fn(() => ({ config: false, credentials: false })),
listAuthenticationDependents: vi.fn<() => string[]>(() => []),
listProfiles: vi.fn<() => string[]>(() => []),
request: vi.fn(),
readCredentialsProfile: vi.fn<() => Record<string, string>>(() => ({})),
resolveAuthenticationProfileName: vi.fn((profile: string) => profile),
pollForKey: vi.fn(async () => ({
apiKey: 'sim-key',
scope: 'platform' as const,
@@ -38,20 +41,25 @@ vi.mock('../auth/device-flow', () => ({
pollForKey: mocks.pollForKey,
}))
vi.mock('../config/index', () => ({
configPath: () => '/tmp/sim-config',
credentialsPath: () => '/tmp/sim-credentials',
deleteProfile: vi.fn(),
DEFAULT_PROFILE: 'default',
deleteProfile: mocks.deleteProfile,
listAuthenticationDependents: mocks.listAuthenticationDependents,
listProfiles: mocks.listProfiles,
readCredentialsProfile: mocks.readCredentialsProfile,
resolveAuthenticationProfileName: mocks.resolveAuthenticationProfileName,
writeConfigProfile: mocks.writeConfigProfile,
writeCredentialsProfile: mocks.writeCredentialsProfile,
}))
vi.mock('../context', () => ({
globalsOf: (command: Command) => command.optsWithGlobals(),
profileFrom: mocks.profileFrom,
clientFrom: () => ({ client: { request: mocks.request }, profile: mocks.profileFrom() }),
}))
import { SimApiError } from '../http/client'
import { loginCommand, profilesCommand, whoamiCommand } from './auth'
import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './auth'
const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY')
@@ -71,11 +79,27 @@ async function whoami(...args: string[]): Promise<void> {
await root.parseAsync(['node', 'sim', 'whoami', ...args])
}
async function profiles(...args: string[]): Promise<void> {
const root = new Command('sim')
.exitOverride()
.option('-P, --profile <name>')
.option('-w, --workspace <id>')
root.addCommand(profilesCommand())
await root.parseAsync(['node', 'sim', 'profiles', ...args])
}
async function logout(...args: string[]): Promise<void> {
const root = new Command('sim').exitOverride().option('-P, --profile <name>')
root.addCommand(logoutCommand())
await root.parseAsync(['node', 'sim', 'logout', ...args])
}
describe('login command', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listProfiles.mockReturnValue([])
mocks.readCredentialsProfile.mockReturnValue({})
mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile)
mocks.profileFrom.mockReturnValue({
name: 'default',
endpoint: 'https://sim.ai',
@@ -115,6 +139,29 @@ describe('login command', () => {
expect(mocks.createAuthRequest).toHaveBeenCalledOnce()
})
it('refuses to replace authentication through a shared workspace profile', async () => {
setInteractive(false)
mocks.profileFrom.mockReturnValue({
name: 'acme',
endpoint: 'https://sim.ai',
apiKey: 'sim-key',
workspaceId: 'ws_acme',
output: 'table',
sources: {
endpoint: 'config',
apiKey: 'credentials',
workspaceId: 'config',
output: 'default',
},
})
mocks.resolveAuthenticationProfileName.mockReturnValue('default')
await expect(login()).rejects.toThrow(
'Profile "acme" shares authentication with "default". Run: sim login --profile default'
)
expect(mocks.createAuthRequest).not.toHaveBeenCalled()
})
it('requires --yes before overwriting non-interactively', async () => {
setInteractive(false)
mocks.readCredentialsProfile.mockReturnValue({ api_key: 'existing-key' })
@@ -203,9 +250,262 @@ describe('login command', () => {
})
describe('profiles command', () => {
beforeEach(() => {
vi.clearAllMocks()
setInteractive(false)
mocks.listProfiles.mockReturnValue([])
mocks.readCredentialsProfile.mockReturnValue({ api_key: 'stored-key' })
mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile)
mocks.profileFrom.mockReturnValue({
name: 'default',
endpoint: 'https://sim.ai',
apiKey: 'stored-key',
workspaceId: 'ws_default',
output: 'table',
sources: {
endpoint: 'config',
apiKey: 'credentials',
workspaceId: 'config',
output: 'default',
},
})
mocks.request.mockResolvedValue({
data: { id: 'ws_acme', name: 'Acme', memberCount: 3 },
})
mocks.createInterface.mockReturnValue({
question: vi.fn(async () => '1'),
close: vi.fn(),
})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
if (originalIsTTY) Object.defineProperty(process.stdin, 'isTTY', originalIsTTY)
else Reflect.deleteProperty(process.stdin, 'isTTY')
})
it('accepts the singular profile alias', () => {
expect(profilesCommand().alias()).toBe('profile')
})
it('keeps the existing bare profiles command as the list shortcut', async () => {
mocks.listProfiles.mockReturnValue(['default'])
await profiles()
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('default'))
})
it('adds a validated workspace profile using the active stored login', async () => {
await profiles('add', 'acme', '--workspace', 'ws_acme')
expect(mocks.request).toHaveBeenCalledWith('/api/v2/workspaces/ws_acme', { method: 'GET' })
expect(mocks.writeConfigProfile).toHaveBeenCalledWith('acme', {
auth_profile: 'default',
workspace: 'ws_acme',
})
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
})
it('does not write a profile when the active key cannot reach the workspace', async () => {
mocks.request.mockRejectedValue(new SimApiError('Workspace not found', 404))
await expect(profiles('add', 'acme', '--workspace', 'ws_missing')).rejects.toThrow(
'Workspace not found'
)
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
})
it('flattens an active workspace profile to its canonical authentication profile', async () => {
mocks.profileFrom.mockReturnValue({
name: 'engineering',
endpoint: 'https://sim.ai',
apiKey: 'stored-key',
workspaceId: 'ws_engineering',
output: 'table',
sources: {
endpoint: 'config',
apiKey: 'credentials',
workspaceId: 'config',
output: 'default',
},
})
mocks.resolveAuthenticationProfileName.mockReturnValue('corporate')
await profiles('add', 'finance', '--workspace', 'ws_acme')
expect(mocks.writeConfigProfile).toHaveBeenCalledWith('finance', {
auth_profile: 'corporate',
workspace: 'ws_acme',
})
})
it('refuses to persist a profile from an environment-only key', async () => {
mocks.profileFrom.mockReturnValue({
name: 'default',
endpoint: 'https://sim.ai',
apiKey: 'environment-key',
workspaceId: null,
output: 'table',
sources: {
endpoint: 'default',
apiKey: 'env',
workspaceId: 'unset',
output: 'default',
},
})
await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow(
'the active API key is not stored'
)
expect(mocks.request).not.toHaveBeenCalled()
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
})
it('refuses to persist a profile from an ephemeral endpoint override', async () => {
mocks.profileFrom.mockReturnValue({
name: 'default',
endpoint: 'https://temporary.example',
apiKey: 'stored-key',
workspaceId: null,
output: 'table',
sources: {
endpoint: 'env',
apiKey: 'credentials',
workspaceId: 'unset',
output: 'default',
},
})
await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow(
'the active endpoint comes from env'
)
expect(mocks.request).not.toHaveBeenCalled()
})
it('requires an explicit workspace outside an interactive terminal', async () => {
await expect(profiles('add', 'acme')).rejects.toThrow(
'Pass --workspace <id> when creating a profile non-interactively.'
)
expect(mocks.request).not.toHaveBeenCalled()
})
it('offers every accessible workspace in an interactive picker', async () => {
setInteractive(true)
const question = vi.fn(async () => '2')
const close = vi.fn()
mocks.createInterface.mockReturnValue({ question, close })
mocks.request.mockResolvedValue({
data: [
{ id: 'ws_acme', name: 'Acme' },
{ id: 'ws_beta', name: 'Beta' },
],
nextCursor: null,
})
await profiles('add', 'beta')
expect(mocks.request).toHaveBeenCalledWith('/api/v2/workspaces', {
method: 'GET',
query: { sortBy: 'name', sortOrder: 'asc', limit: 100, cursor: null },
})
expect(question).toHaveBeenCalledWith('Choose a workspace [1-2]: ')
expect(close).toHaveBeenCalledOnce()
expect(mocks.writeConfigProfile).toHaveBeenCalledWith('beta', {
auth_profile: 'default',
workspace: 'ws_beta',
})
})
it('caps the interactive workspace roster before prompting', async () => {
setInteractive(true)
mocks.request.mockResolvedValue({
data: Array.from({ length: 1001 }, (_, index) => ({
id: `ws_${index}`,
name: `Workspace ${index}`,
})),
nextCursor: null,
})
await expect(profiles('add', 'large')).rejects.toThrow(
'more than 1000 workspaces, which is too many to show interactively'
)
expect(mocks.createInterface).not.toHaveBeenCalled()
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
})
it('does not overwrite an existing profile', async () => {
mocks.listProfiles.mockReturnValue(['acme'])
await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow(
'Profile "acme" already exists.'
)
expect(mocks.request).not.toHaveBeenCalled()
})
it('lists a shared profile as authenticated by its referenced profile', async () => {
mocks.listProfiles.mockReturnValue(['acme', 'default'])
mocks.resolveAuthenticationProfileName.mockImplementation((profile) =>
profile === 'acme' ? 'default' : profile
)
await profiles('list')
const output = vi.mocked(console.log).mock.calls.flat().join('\n')
expect(output).toContain('acme (auth: default)')
expect(output).not.toContain('acme (no key)')
})
})
describe('logout command', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listAuthenticationDependents.mockReturnValue([])
mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile)
mocks.readCredentialsProfile.mockReturnValue({ api_key: 'stored-key' })
mocks.profileFrom.mockReturnValue({
name: 'acme',
endpoint: 'https://sim.ai',
apiKey: 'stored-key',
workspaceId: 'ws_acme',
output: 'table',
sources: {
endpoint: 'config',
apiKey: 'credentials',
workspaceId: 'config',
output: 'default',
},
})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('does not remove a key through a shared workspace profile', async () => {
mocks.resolveAuthenticationProfileName.mockReturnValue('default')
await expect(logout()).rejects.toThrow(
'Log out of the authentication profile instead: sim logout --profile default'
)
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
})
it('removes only the selected alias under --all', async () => {
mocks.deleteProfile.mockReturnValue({ config: true, credentials: false })
await logout('--all', '--profile', 'acme')
expect(mocks.deleteProfile).toHaveBeenCalledWith('acme')
expect(mocks.resolveAuthenticationProfileName).not.toHaveBeenCalled()
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
})
it('refuses to remove an authentication profile while workspace profiles use it', async () => {
mocks.listAuthenticationDependents.mockReturnValue(['acme', 'beta'])
await expect(logout('--all', '--profile', 'default')).rejects.toThrow(
'Cannot remove authentication profile "default" because it is used by: acme, beta.'
)
expect(mocks.deleteProfile).not.toHaveBeenCalled()
})
})
describe('whoami command', () => {
+191 -23
View File
@@ -9,20 +9,33 @@ import {
pollForKey,
} from '../auth/device-flow'
import {
configPath,
credentialsPath,
DEFAULT_PROFILE,
deleteProfile,
listAuthenticationDependents,
listProfiles,
type ResolvedProfile,
readCredentialsProfile,
resolveAuthenticationProfileName,
type SettingSource,
writeConfigProfile,
writeCredentialsProfile,
} from '../config/index'
import { clientFrom, profileFrom } from '../context'
import { type GetWorkspaceResponse, V2_OPERATIONS } from '../generated/v2-api'
import { resolvePath, SimApiError, type SimClient } from '../http/client'
import { clientFrom, globalsOf, profileFrom } from '../context'
import {
type GetWorkspaceResponse,
type ListWorkspacesResponse,
V2_OPERATIONS,
} from '../generated/v2-api'
import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client'
import { printRecord, safeOneLine } from '../output/render'
type SelectableWorkspace = ListWorkspacesResponse['data'][number]
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
const MAX_INTERACTIVE_WORKSPACES = 1000
/**
* Best-effort browser launch. Failure is not an error: the URL is always printed
* first, so a headless box, an SSH session, or a machine with no handler just
@@ -88,6 +101,128 @@ async function confirmProfileOverwrite(profileName: string): Promise<boolean> {
}
}
function selectedProfileName(command: Command): string {
return globalsOf(command).profile || process.env.SIM_PROFILE || DEFAULT_PROFILE
}
function validateNewProfileName(profileName: string): void {
if (!PROFILE_NAME_PATTERN.test(profileName)) {
throw new SimApiError(
`Invalid profile name "${profileName}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.`,
0
)
}
if (listProfiles().includes(profileName)) {
throw new SimApiError(
`Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`,
0
)
}
}
function requireStoredAuthentication(profile: ResolvedProfile): string {
const authProfile = resolveAuthenticationProfileName(profile.name)
const storedKey = readCredentialsProfile(authProfile).api_key
if (profile.sources.apiKey !== 'credentials' || !storedKey) {
throw new SimApiError(
`Cannot create a shared profile from "${profile.name}": the active API key is not stored. Run: sim login --profile ${authProfile}`,
0
)
}
if (profile.sources.endpoint === 'flag' || profile.sources.endpoint === 'env') {
throw new SimApiError(
`Cannot create a shared profile from "${profile.name}": the active endpoint comes from ${profile.sources.endpoint}. Save it with: sim configure --profile ${authProfile} --set-endpoint ${profile.endpoint}`,
0
)
}
return authProfile
}
async function getWorkspaceById(
client: Pick<SimClient, 'request'>,
workspaceId: string
): Promise<SelectableWorkspace> {
const operation = V2_OPERATIONS.getWorkspace
const response = await client.request<GetWorkspaceResponse>(
resolvePath(operation.path, { workspaceId }),
{ method: operation.method }
)
return response.data
}
async function chooseWorkspace(client: Pick<SimClient, 'request'>): Promise<SelectableWorkspace> {
if (!process.stdin.isTTY) {
throw new SimApiError(
'No workspace provided. Pass --workspace <id> when creating a profile non-interactively.',
0
)
}
const operation = V2_OPERATIONS.listWorkspaces
const workspaces = await requestAllPages<SelectableWorkspace>(client, operation.path, {
method: operation.method,
query: { sortBy: 'name', sortOrder: 'asc' },
pageSize: 100,
limit: MAX_INTERACTIVE_WORKSPACES + 1,
})
if (workspaces.length === 0) {
throw new SimApiError('The active API key cannot access any workspaces.', 0)
}
if (workspaces.length > MAX_INTERACTIVE_WORKSPACES) {
throw new SimApiError(
`The active API key can access more than ${MAX_INTERACTIVE_WORKSPACES} workspaces, which is too many to show interactively. Pass --workspace <id> instead.`,
0
)
}
console.log('\nAvailable workspaces:')
for (const [index, workspace] of workspaces.entries()) {
console.log(` ${index + 1}) ${safeOneLine(workspace.name)} (${workspace.id})`)
}
const prompt = createInterface({ input: process.stdin, output: process.stderr })
try {
const answer = await prompt.question(`Choose a workspace [1-${workspaces.length}]: `)
const selected = Number(answer.trim())
if (!Number.isInteger(selected) || selected < 1 || selected > workspaces.length) {
throw new SimApiError(
`Invalid workspace selection "${safeOneLine(answer)}". Choose a number from 1 to ${workspaces.length}.`,
0
)
}
return workspaces[selected - 1]
} finally {
prompt.close()
}
}
function addProfileCommand(): Command {
return new Command('add')
.description('Add a workspace profile that shares the active stored login')
.argument('<name>', 'Name for the new profile')
.option('-w, --workspace <id>', 'Existing workspace to use; omit for an interactive picker')
.action(async (profileName: string, _options: unknown, command: Command) => {
validateNewProfileName(profileName)
const { client, profile } = clientFrom(command)
const authProfile = requireStoredAuthentication(profile)
const workspaceId = globalsOf(command).workspace
const workspace = workspaceId
? await getWorkspaceById(client, workspaceId)
: await chooseWorkspace(client)
writeConfigProfile(profileName, {
auth_profile: authProfile,
workspace: workspace.id,
})
console.log(chalk.green(`✓ Added profile "${profileName}" in ${configPath()}`))
console.log(` Workspace: ${safeOneLine(workspace.name)} (${workspace.id})`)
console.log(` Authentication: ${authProfile}`)
console.log(chalk.dim(` Try: sim --profile ${profileName} whoami`))
})
}
export function loginCommand(): Command {
return new Command('login')
.description('Authorize this terminal and store an API key for the profile')
@@ -97,6 +232,14 @@ export function loginCommand(): Command {
.action(
async (options: { scope: string; browser: boolean; yes?: boolean }, command: Command) => {
const profile = profileFrom(command)
const authProfile = resolveAuthenticationProfileName(profile.name)
if (authProfile !== profile.name) {
throw new SimApiError(
`Profile "${profile.name}" shares authentication with "${authProfile}". Run: sim login --profile ${authProfile}`,
0
)
}
if (options.scope !== 'platform' && options.scope !== 'copilot') {
throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0)
@@ -180,18 +323,33 @@ export function logoutCommand(): Command {
.description("Remove the profile's stored API key")
.option('--all', 'Remove the profile entirely, including its settings')
.action((options: { all?: boolean }, command: Command) => {
const profile = profileFrom(command)
if (options.all) {
const removed = deleteProfile(profile.name)
const profileName = selectedProfileName(command)
const dependents = listAuthenticationDependents(profileName)
if (dependents.length > 0) {
throw new SimApiError(
`Cannot remove authentication profile "${profileName}" because it is used by: ${dependents.join(', ')}. Remove those profiles first.`,
0
)
}
const removed = deleteProfile(profileName)
if (!removed.config && !removed.credentials) {
console.log(chalk.dim(`Nothing stored for profile "${profile.name}".`))
console.log(chalk.dim(`Nothing stored for profile "${profileName}".`))
return
}
console.log(chalk.green(`✓ Removed profile "${profile.name}".`))
console.log(chalk.green(`✓ Removed profile "${profileName}".`))
return
}
const profile = profileFrom(command)
const authProfile = resolveAuthenticationProfileName(profile.name)
if (authProfile !== profile.name) {
throw new SimApiError(
`Profile "${profile.name}" shares authentication with "${authProfile}". Log out of the authentication profile instead: sim logout --profile ${authProfile}`,
0
)
}
if (!readCredentialsProfile(profile.name).api_key) {
console.log(chalk.dim(`No stored key for profile "${profile.name}".`))
return
@@ -392,21 +550,31 @@ export function whoamiCommand(): Command {
}
export function profilesCommand(): Command {
return new Command('profiles')
const command = new Command('profiles')
.alias('profile')
.description('List the profiles defined in the config and credentials files')
.action((_options: unknown, command: Command) => {
const profiles = listProfiles()
if (profiles.length === 0) {
console.log(chalk.dim('No profiles yet. Run: sim login'))
return
}
.description('List profiles or add a workspace profile that shares a stored login')
const active = profileFrom(command).name
for (const name of profiles) {
const marker = name === active ? chalk.green('*') : ' '
const hasKey = Boolean(readCredentialsProfile(name).api_key)
console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}`)
}
})
const printProfiles = (_options: unknown, actionCommand: Command): void => {
const profiles = listProfiles()
if (profiles.length === 0) {
console.log(chalk.dim('No profiles yet. Run: sim login'))
return
}
const active = selectedProfileName(actionCommand)
for (const name of profiles) {
const marker = name === active ? chalk.green('*') : ' '
const authProfile = resolveAuthenticationProfileName(name)
const hasKey = Boolean(readCredentialsProfile(authProfile).api_key)
const authentication = authProfile === name ? '' : chalk.dim(` (auth: ${authProfile})`)
console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}${authentication}`)
}
}
command.action(printProfiles)
command.addCommand(
new Command('list').description('List configured profiles').action(printProfiles)
)
command.addCommand(addProfileCommand())
return command
}
@@ -3,11 +3,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Command } from 'commander'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readConfigProfile } from '../config/index'
import { readConfigProfile, writeConfigProfile, writeCredentialsProfile } from '../config/index'
import { configureCommand } from './configure'
const mocks = vi.hoisted(() => ({ profileName: 'default' }))
vi.mock('../context', () => ({
profileFrom: () => ({ name: 'default' }),
profileFrom: () => ({ name: mocks.profileName }),
}))
let dir: string
@@ -21,6 +23,7 @@ function run(...args: string[]): Promise<Command> {
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'sim-cli-'))
process.env.SIM_CONFIG_DIR = dir
mocks.profileName = 'default'
vi.spyOn(console, 'log').mockImplementation(() => {})
})
@@ -49,4 +52,19 @@ describe('configure --set-endpoint', () => {
await run('--set-endpoint', 'http://localhost:3000//')
expect(readConfigProfile('default')).toMatchObject({ endpoint: 'http://localhost:3000' })
})
it('refuses to set an endpoint locally on a shared workspace profile', async () => {
writeConfigProfile('default', { endpoint: 'https://sim.example' })
writeCredentialsProfile('default', 'stored-key')
writeConfigProfile('acme', { auth_profile: 'default', workspace: 'ws_acme' })
mocks.profileName = 'acme'
await expect(run('--set-endpoint', 'https://other.example')).rejects.toThrow(
'Profile "acme" shares its endpoint with authentication profile "default".'
)
expect(readConfigProfile('acme')).toEqual({
auth_profile: 'default',
workspace: 'ws_acme',
})
})
})
+20 -1
View File
@@ -1,6 +1,12 @@
import chalk from 'chalk'
import { Command } from 'commander'
import { configPath, OUTPUT_FORMATS, readConfigProfile, writeConfigProfile } from '../config/index'
import {
configPath,
OUTPUT_FORMATS,
readConfigProfile,
resolveAuthenticationProfileName,
writeConfigProfile,
} from '../config/index'
import { normalizeEndpoint } from '../config/profile'
import { profileFrom } from '../context'
import { SimApiError } from '../http/client'
@@ -28,9 +34,16 @@ export function configureCommand(): Command {
command: Command
) => {
const profile = profileFrom(command)
const authProfile = resolveAuthenticationProfileName(profile.name)
const updates: Record<string, string | null> = {}
if (options.setEndpoint) {
if (authProfile !== profile.name) {
throw new SimApiError(
`Profile "${profile.name}" shares its endpoint with authentication profile "${authProfile}". Run: sim configure --profile ${authProfile} --set-endpoint ${options.setEndpoint}`,
0
)
}
updates.endpoint = normalizeEndpoint(options.setEndpoint, '--set-endpoint')
}
if (options.setWorkspace) updates.workspace = options.setWorkspace
@@ -48,6 +61,12 @@ export function configureCommand(): Command {
if (!['endpoint', 'workspace', 'output'].includes(key)) {
throw new SimApiError(`Cannot unset "${key}". Use endpoint, workspace, or output.`, 0)
}
if (key === 'endpoint' && authProfile !== profile.name) {
throw new SimApiError(
`Profile "${profile.name}" shares its endpoint with authentication profile "${authProfile}". Run: sim configure --profile ${authProfile} --unset endpoint`,
0
)
}
updates[key] = null
}
@@ -137,9 +137,11 @@ describe('credential connection commands', () => {
type: 'service_account',
providerId: 'zoom-service-account',
displayName: 'Production Zoom',
clientId: 'client',
clientSecret: 'secret',
orgId: 'account',
credentials: JSON.stringify({
clientId: 'client',
clientSecret: 'secret',
orgId: 'account',
}),
},
})
})
+2 -2
View File
@@ -119,7 +119,7 @@ async function createServiceAccount(
throw new SimApiError(`--id is required for ${providerId}.`, 0)
}
const credentials = credentialValues(provider, options.credentials)
const credentialFields = credentialValues(provider, options.credentials)
const operation = V2_OPERATIONS.createServiceAccountCredential
const response = await client.request<CreateServiceAccountCredentialResponse>(operation.path, {
method: operation.method,
@@ -130,7 +130,7 @@ async function createServiceAccount(
displayName: options.name,
...(options.description ? { description: options.description } : {}),
...(options.id ? { id: options.id } : {}),
...credentials,
credentials: JSON.stringify(credentialFields),
},
})
+2
View File
@@ -3,6 +3,7 @@ export {
DEFAULT_ENDPOINT,
DEFAULT_PROFILE,
deleteProfile,
listAuthenticationDependents,
listProfiles,
OUTPUT_FORMATS,
type OutputFormat,
@@ -11,6 +12,7 @@ export {
type ResolvedProfile,
readConfigProfile,
readCredentialsProfile,
resolveAuthenticationProfileName,
resolveProfile,
type SettingSource,
writeConfigProfile,
@@ -6,8 +6,10 @@ import { configPath, credentialsPath } from './paths'
import {
DEFAULT_ENDPOINT,
deleteProfile,
listAuthenticationDependents,
listProfiles,
OUTPUT_FORMATS,
resolveAuthenticationProfileName,
resolveProfile,
writeConfigProfile,
writeCredentialsProfile,
@@ -71,6 +73,88 @@ describe('profile resolution', () => {
})
})
it('keeps existing profiles self-authenticating when auth_profile is absent', () => {
writeConfigProfile('dev', { endpoint: 'https://dev.example', workspace: 'ws_dev' })
writeCredentialsProfile('dev', 'key_dev')
expect(resolveAuthenticationProfileName('dev')).toBe('dev')
expect(resolveProfile({ profile: 'dev' })).toMatchObject({
endpoint: 'https://dev.example',
workspaceId: 'ws_dev',
apiKey: 'key_dev',
})
})
it('shares only authentication and endpoint through auth_profile', () => {
writeConfigProfile('default', {
endpoint: 'https://sim.example',
workspace: 'ws_default',
output: 'yaml',
})
writeCredentialsProfile('default', 'key_default')
writeConfigProfile('acme', {
auth_profile: 'default',
workspace: 'ws_acme',
output: 'json',
})
expect(resolveAuthenticationProfileName('acme')).toBe('default')
expect(resolveProfile({ profile: 'acme' })).toMatchObject({
name: 'acme',
endpoint: 'https://sim.example',
workspaceId: 'ws_acme',
output: 'json',
apiKey: 'key_default',
sources: {
endpoint: 'config',
workspaceId: 'config',
output: 'config',
apiKey: 'credentials',
},
})
})
it('fails fast on empty, missing, self-referential, or chained auth profiles', () => {
writeConfigProfile('empty', { auth_profile: '' })
expect(() => resolveProfile({ profile: 'empty' })).toThrow(
'Profile "empty" has an empty auth_profile.'
)
writeConfigProfile('missing', { auth_profile: 'gone' })
expect(() => resolveProfile({ profile: 'missing' })).toThrow(
'Profile "missing" references missing auth_profile "gone".'
)
writeConfigProfile('self', { auth_profile: 'self' })
expect(() => resolveProfile({ profile: 'self' })).toThrow(
'Profile "self" cannot use itself as auth_profile.'
)
writeConfigProfile('base', { auth_profile: 'root' })
writeCredentialsProfile('root', 'key_root')
writeConfigProfile('chained', { auth_profile: 'base' })
expect(() => resolveProfile({ profile: 'chained' })).toThrow(
'Profile "chained" references auth_profile "base", which also has auth_profile set.'
)
})
it('rejects ambiguous local authentication settings on a shared profile', () => {
writeCredentialsProfile('default', 'key_default')
writeConfigProfile('endpoint-alias', {
auth_profile: 'default',
endpoint: 'https://other.example',
})
expect(() => resolveProfile({ profile: 'endpoint-alias' })).toThrow(
'Profile "endpoint-alias" cannot set both auth_profile and endpoint.'
)
writeConfigProfile('key-alias', { auth_profile: 'default' })
writeCredentialsProfile('key-alias', 'key_alias')
expect(() => resolveProfile({ profile: 'key-alias' })).toThrow(
'Profile "key-alias" cannot set both auth_profile and its own API key.'
)
})
it('lets a flag beat the environment, and the environment beat the file', () => {
writeConfigProfile('default', { endpoint: 'https://file.example' })
@@ -186,6 +270,16 @@ describe('profile resolution', () => {
expect(listProfiles()).toEqual(['ci', 'default', 'dev'])
})
it('lists direct authentication dependents without treating a bad self-reference as one', () => {
writeCredentialsProfile('default', 'key')
writeConfigProfile('acme', { auth_profile: 'default', workspace: 'ws_acme' })
writeConfigProfile('beta', { auth_profile: 'default', workspace: 'ws_beta' })
writeConfigProfile('broken', { auth_profile: 'broken' })
expect(listAuthenticationDependents('default')).toEqual(['acme', 'beta'])
expect(listAuthenticationDependents('broken')).toEqual([])
})
it('deletes a profile from both files', () => {
writeConfigProfile('dev', { endpoint: 'http://localhost:3000' })
writeCredentialsProfile('dev', 'key')
+60 -2
View File
@@ -101,6 +101,54 @@ export function readCredentialsProfile(profile: string): Record<string, string>
return getSection(readIni(credentialsPath()), profile) ?? {}
}
/**
* Resolves the one stored identity a profile authenticates through.
*
* Existing profiles authenticate through their same-named credentials section.
* A workspace alias may instead name one direct `auth_profile`; references are
* deliberately non-recursive so a hand-edited cycle or missing target fails
* with the setting that needs repair rather than surfacing later as "no key".
*/
export function resolveAuthenticationProfileName(profile: string): string {
const config = readConfigProfile(profile)
if (!Object.hasOwn(config, 'auth_profile')) return profile
const authProfile = config.auth_profile.trim()
if (!authProfile) {
throw new ProfileConfigError(`Profile "${profile}" has an empty auth_profile.`)
}
if (authProfile === profile) {
throw new ProfileConfigError(
`Profile "${profile}" cannot use itself as auth_profile. Remove the auth_profile setting instead.`
)
}
if (Object.hasOwn(config, 'endpoint')) {
throw new ProfileConfigError(
`Profile "${profile}" cannot set both auth_profile and endpoint. Set the endpoint on authentication profile "${authProfile}".`
)
}
if (readCredentialsProfile(profile).api_key) {
throw new ProfileConfigError(
`Profile "${profile}" cannot set both auth_profile and its own API key. Remove one of them.`
)
}
const authConfig = readConfigProfile(authProfile)
const credentials = readCredentialsProfile(authProfile)
if (Object.keys(authConfig).length === 0 && Object.keys(credentials).length === 0) {
throw new ProfileConfigError(
`Profile "${profile}" references missing auth_profile "${authProfile}".`
)
}
if (Object.hasOwn(authConfig, 'auth_profile')) {
throw new ProfileConfigError(
`Profile "${profile}" references auth_profile "${authProfile}", which also has auth_profile set. Authentication profile references cannot be chained.`
)
}
return authProfile
}
/** Every profile named by either file, deduplicated and sorted. */
export function listProfiles(): string[] {
const names = new Set<string>()
@@ -116,6 +164,14 @@ export function listProfiles(): string[] {
return [...names].sort()
}
/** Profiles that directly share the named profile's stored authentication. */
export function listAuthenticationDependents(authProfile: string): string[] {
return listProfiles().filter(
(profile) =>
profile !== authProfile && readConfigProfile(profile).auth_profile?.trim() === authProfile
)
}
export function writeConfigProfile(profile: string, values: Record<string, string | null>): void {
const doc = readIni(configPath())
setSectionValues(doc, configSectionName(profile), values)
@@ -195,13 +251,15 @@ function resolve<T>(
export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfile {
const name = overrides.profile || process.env.SIM_PROFILE || DEFAULT_PROFILE
const config = readConfigProfile(name)
const credentials = readCredentialsProfile(name)
const authProfile = resolveAuthenticationProfileName(name)
const authConfig = authProfile === name ? config : readConfigProfile(authProfile)
const credentials = readCredentialsProfile(authProfile)
const endpoint = resolve<string>(
[
['flag', overrides.endpoint],
['env', process.env.SIM_ENDPOINT],
['config', config.endpoint],
['config', authConfig.endpoint],
],
DEFAULT_ENDPOINT,
'default'
+16
View File
@@ -139,6 +139,15 @@ export const CLI_CONTRACT: CliContract = {
selectAll: { boolean: true, describe: 'Apply to every document in the knowledge base' },
},
},
listKnowledgeConnectorDocuments: {
command: 'knowledge connectors documents list',
},
updateKnowledgeConnectorDocuments: {
command: 'knowledge connectors documents update',
flags: {
documentIds: { name: 'document', list: true },
},
},
// `DELETE /workflows/[id]/deploy` is an undeploy, not a delete.
undeployWorkflow: {
command: 'workflows undeploy',
@@ -167,11 +176,18 @@ export const CLI_CONTRACT: CliContract = {
pathArgumentNames: KNOWLEDGE_BASE_PATH_ARGUMENT,
confirm: 'This deletes the document and its embeddings.',
},
deleteKnowledgeConnector: {
confirm:
'This deletes the connector; --delete-documents also deletes its synchronized documents.',
},
deleteFile: { confirm: 'This archives the file.' },
deleteCredential: {
confirm: 'This disconnects the credential and removes its stored authentication.',
},
deleteSkill: { confirm: 'This deletes the skill.' },
revokeSkillEditor: {
confirm: 'This revokes the explicit skill editor grant for the selected email.',
},
deleteCustomTool: { confirm: 'This deletes the custom tool.' },
deleteMcpServer: {
confirm: 'This removes the MCP server and the tools it provides.',
+684 -27
View File
@@ -910,6 +910,44 @@ export type CreateKnowledgeBaseResponse = {
data: CreateKnowledgeBaseResponseRef1
}
/** `POST /api/v2/knowledge/[id]/connectors` */
export type CreateKnowledgeConnectorParams = {
id: string
}
export type CreateKnowledgeConnectorQuery = Record<string, unknown>
export type CreateKnowledgeConnectorBody = {
workspaceId: string
connectorType: string
credentialId?: string
apiKey?: string
sourceConfig: Record<string, unknown>
syncIntervalMinutes?: number
}
type CreateKnowledgeConnectorResponseRef0 = {
id: string
knowledgeBaseId: string
connectorType: string
credentialId: string | null
sourceConfig: Record<string, unknown>
syncMode: string
syncIntervalMinutes: number
status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled'
lastSyncAt: string | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: string | null
consecutiveFailures: number
createdAt: string
updatedAt: string
}
export type CreateKnowledgeConnectorResponse = {
data: CreateKnowledgeConnectorResponseRef0
}
/** `POST /api/v2/knowledge/[id]/documents/uploads` */
export type CreateKnowledgeDocumentUploadParams = {
id: string
@@ -1106,19 +1144,7 @@ export type CreateServiceAccountCredentialBody = {
displayName?: string
description?: string
id?: string
serviceAccountJson?: string
apiToken?: string
domain?: string
signingSecret?: string
botToken?: string
clientId?: string
clientSecret?: string
certificateId?: string
orgId?: string
dataCenter?: string
authMethod?: string
privateKey?: string
username?: string
credentials: string
}
type CreateServiceAccountCredentialResponseRef0 = {
@@ -1787,6 +1813,28 @@ export type DeleteKnowledgeBaseResponse = {
data: DeleteKnowledgeBaseResponseRef0
}
/** `DELETE /api/v2/knowledge/[id]/connectors/[connectorId]` */
export type DeleteKnowledgeConnectorParams = {
id: string
connectorId: string
}
export type DeleteKnowledgeConnectorQuery = {
workspaceId: string
deleteDocuments?: boolean
}
type DeleteKnowledgeConnectorResponseRef0 = {
id: string
deleted: true
documentsDeleted: number
documentsKept: number
}
export type DeleteKnowledgeConnectorResponse = {
data: DeleteKnowledgeConnectorResponseRef0
}
/** `DELETE /api/v2/knowledge/[id]/documents/[documentId]` */
export type DeleteKnowledgeDocumentParams = {
id: string
@@ -2637,6 +2685,53 @@ export type GetKnowledgeBaseResponse = {
data: GetKnowledgeBaseResponseRef1
}
/** `GET /api/v2/knowledge/[id]/connectors/[connectorId]` */
export type GetKnowledgeConnectorParams = {
id: string
connectorId: string
}
export type GetKnowledgeConnectorQuery = {
workspaceId: string
}
type GetKnowledgeConnectorResponseRef0 = {
id: string
connectorId: string
status: string
startedAt: string
completedAt: string | null
docsAdded: number
docsUpdated: number
docsDeleted: number
docsUnchanged: number
docsFailed: number
errorMessage: string | null
}
type GetKnowledgeConnectorResponseRef1 = {
id: string
knowledgeBaseId: string
connectorType: string
credentialId: string | null
sourceConfig: Record<string, unknown>
syncMode: string
syncIntervalMinutes: number
status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled'
lastSyncAt: string | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: string | null
consecutiveFailures: number
createdAt: string
updatedAt: string
syncLogs: Array<GetKnowledgeConnectorResponseRef0>
}
export type GetKnowledgeConnectorResponse = {
data: GetKnowledgeConnectorResponseRef1
}
/** `GET /api/v2/knowledge/[id]/documents/[documentId]` */
export type GetKnowledgeDocumentParams = {
id: string
@@ -3206,6 +3301,29 @@ export type GetWorkspaceResponse = {
data: GetWorkspaceResponseRef0
}
/** `POST /api/v2/skills/[id]/editors` */
export type GrantSkillEditorParams = {
id: string
}
export type GrantSkillEditorQuery = Record<string, unknown>
export type GrantSkillEditorBody = {
workspaceId: string
email: string
}
type GrantSkillEditorResponseRef0 = {
email: string
name: string | null
image: string | null
isWorkspaceAdmin: boolean
}
export type GrantSkillEditorResponse = {
data: GrantSkillEditorResponseRef0
}
/** `POST /api/v2/workflows/import` */
export type ImportWorkflowQuery = Record<string, unknown>
@@ -3547,6 +3665,71 @@ export type ListKnowledgeBasesResponse = {
nextCursor: string | null
}
/** `GET /api/v2/knowledge/[id]/connectors/[connectorId]/documents` */
export type ListKnowledgeConnectorDocumentsParams = {
id: string
connectorId: string
}
export type ListKnowledgeConnectorDocumentsQuery = {
workspaceId: string
includeExcluded?: boolean
limit?: number
cursor?: string
}
type ListKnowledgeConnectorDocumentsResponseRef0 = {
id: string
filename: string
externalId: string | null
sourceUrl: string | null
enabled: boolean
userExcluded: boolean
createdAt: string
processingStatus: string
}
export type ListKnowledgeConnectorDocumentsResponse = {
data: Array<ListKnowledgeConnectorDocumentsResponseRef0>
nextCursor: string | null
}
/** `GET /api/v2/knowledge/[id]/connectors` */
export type ListKnowledgeConnectorsParams = {
id: string
}
export type ListKnowledgeConnectorsQuery = {
workspaceId: string
sortBy?: 'connectorType' | 'createdAt' | 'updatedAt'
sortOrder?: 'asc' | 'desc'
limit?: number
cursor?: string
}
type ListKnowledgeConnectorsResponseRef0 = {
id: string
knowledgeBaseId: string
connectorType: string
credentialId: string | null
sourceConfig: Record<string, unknown>
syncMode: string
syncIntervalMinutes: number
status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled'
lastSyncAt: string | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: string | null
consecutiveFailures: number
createdAt: string
updatedAt: string
}
export type ListKnowledgeConnectorsResponse = {
data: Array<ListKnowledgeConnectorsResponseRef0>
nextCursor: string | null
}
/** `GET /api/v2/knowledge/[id]/documents` */
export type ListKnowledgeDocumentsParams = {
id: string
@@ -3818,6 +4001,31 @@ export type ListSecretsResponse = {
nextCursor: string | null
}
/** `GET /api/v2/skills/[id]/editors` */
export type ListSkillEditorsParams = {
id: string
}
export type ListSkillEditorsQuery = {
workspaceId: string
sortBy?: 'email' | 'name'
sortOrder?: 'asc' | 'desc'
limit?: number
cursor?: string
}
type ListSkillEditorsResponseRef0 = {
email: string
name: string | null
image: string | null
isWorkspaceAdmin: boolean
}
export type ListSkillEditorsResponse = {
data: Array<ListSkillEditorsResponseRef0>
nextCursor: string | null
}
/** `GET /api/v2/skills` */
export type ListSkillsQuery = {
workspaceId: string
@@ -4167,6 +4375,29 @@ export type ListWorkspaceMembersResponse = {
nextCursor: string | null
}
/** `GET /api/v2/workspaces` */
export type ListWorkspacesQuery = {
sortBy?: 'name' | 'createdAt' | 'updatedAt'
sortOrder?: 'asc' | 'desc'
limit?: number
cursor?: string
}
type ListWorkspacesResponseRef0 = {
id: string
name: string
color: string
logoUrl: string | null
memberCount: number
createdAt: string
updatedAt: string
}
export type ListWorkspacesResponse = {
data: Array<ListWorkspacesResponseRef0>
nextCursor: string | null
}
/** `POST /api/v2/files/move` */
export type MoveFileItemsQuery = Record<string, unknown>
@@ -4567,6 +4798,25 @@ export type ResumeWorkflowResponse =
data: ResumeWorkflowResponseRef2
}
/** `DELETE /api/v2/skills/[id]/editors` */
export type RevokeSkillEditorParams = {
id: string
}
export type RevokeSkillEditorQuery = {
workspaceId: string
email: string
}
type RevokeSkillEditorResponseRef0 = {
email: string
revoked: true
}
export type RevokeSkillEditorResponse = {
data: RevokeSkillEditorResponseRef0
}
/** `POST /api/v2/workflows/[id]/rollback` */
export type RollbackWorkflowParams = {
id: string
@@ -4820,6 +5070,28 @@ export type SetSecretResponse = {
data: SetSecretResponseRef0
}
/** `POST /api/v2/knowledge/[id]/connectors/[connectorId]/sync` */
export type SyncKnowledgeConnectorParams = {
id: string
connectorId: string
}
export type SyncKnowledgeConnectorQuery = Record<string, unknown>
export type SyncKnowledgeConnectorBody = {
workspaceId: string
rehydrate?: boolean
}
type SyncKnowledgeConnectorResponseRef0 = {
id: string
syncTriggered: true
}
export type SyncKnowledgeConnectorResponse = {
data: SyncKnowledgeConnectorResponseRef0
}
/** `GET /api/v2/tables/exports/[exportId]/download` */
export type TableExportDownloadParams = {
exportId: string
@@ -5025,6 +5297,67 @@ export type UpdateKnowledgeBaseResponse = {
data: UpdateKnowledgeBaseResponseRef1
}
/** `PATCH /api/v2/knowledge/[id]/connectors/[connectorId]` */
export type UpdateKnowledgeConnectorParams = {
id: string
connectorId: string
}
export type UpdateKnowledgeConnectorQuery = Record<string, unknown>
export type UpdateKnowledgeConnectorBody = {
workspaceId: string
sourceConfig?: Record<string, unknown>
syncIntervalMinutes?: number
status?: 'active' | 'paused'
}
type UpdateKnowledgeConnectorResponseRef0 = {
id: string
knowledgeBaseId: string
connectorType: string
credentialId: string | null
sourceConfig: Record<string, unknown>
syncMode: string
syncIntervalMinutes: number
status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled'
lastSyncAt: string | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: string | null
consecutiveFailures: number
createdAt: string
updatedAt: string
}
export type UpdateKnowledgeConnectorResponse = {
data: UpdateKnowledgeConnectorResponseRef0
}
/** `PATCH /api/v2/knowledge/[id]/connectors/[connectorId]/documents` */
export type UpdateKnowledgeConnectorDocumentsParams = {
id: string
connectorId: string
}
export type UpdateKnowledgeConnectorDocumentsQuery = Record<string, unknown>
export type UpdateKnowledgeConnectorDocumentsBody = {
workspaceId: string
operation: 'restore' | 'exclude'
documentIds: Array<string>
}
type UpdateKnowledgeConnectorDocumentsResponseRef0 = {
operation: 'restore' | 'exclude'
updatedCount: number
documentIds: Array<string>
}
export type UpdateKnowledgeConnectorDocumentsResponse = {
data: UpdateKnowledgeConnectorDocumentsResponseRef0
}
/** `PATCH /api/v2/knowledge/[id]/documents/[documentId]` */
export type UpdateKnowledgeDocumentParams = {
id: string
@@ -6146,6 +6479,40 @@ export const V2_OPERATIONS = {
},
},
},
createKnowledgeConnector: {
method: 'POST',
path: '/api/v2/knowledge/[id]/connectors',
pathParams: ['id'] as const,
pathParamDocs: { id: 'Unique knowledge base identifier.' },
responseMode: 'json',
summary: 'Create Knowledge Connector',
body: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
connectorType: { kind: 'string', required: true, describe: 'Registered connector type.' },
credentialId: {
kind: 'string',
describe: 'OAuth credential identifier for connectors that require OAuth.',
},
apiKey: {
kind: 'string',
describe: 'Write-only API key for connectors that use API-key authentication.',
},
sourceConfig: {
kind: 'object',
required: true,
describe: 'Connector-specific source selection and filtering configuration.',
},
syncIntervalMinutes: {
kind: 'integer',
default: 1440,
describe: 'Scheduled synchronization interval in minutes; zero disables scheduling.',
},
},
},
createKnowledgeDocumentUpload: {
method: 'POST',
path: '/api/v2/knowledge/[id]/documents/uploads',
@@ -6318,22 +6685,12 @@ export const V2_OPERATIONS = {
kind: 'string',
describe: 'Required only when provider discovery requests a client-generated ID.',
},
serviceAccountJson: {
credentials: {
kind: 'string',
describe: 'Write-only Google service-account JSON key.',
required: true,
describe:
'Write-only JSON object string containing the fields declared by credential-provider discovery.',
},
apiToken: { kind: 'string', describe: 'Write-only provider API token.' },
domain: { kind: 'string', describe: 'Provider account domain.' },
signingSecret: { kind: 'string', describe: 'Write-only webhook signing secret.' },
botToken: { kind: 'string', describe: 'Write-only bot token.' },
clientId: { kind: 'string', describe: 'OAuth client identifier.' },
clientSecret: { kind: 'string', describe: 'Write-only OAuth client secret.' },
certificateId: { kind: 'string', describe: 'Provider certificate mapping identifier.' },
orgId: { kind: 'string', describe: 'Provider organization ID.' },
dataCenter: { kind: 'string', describe: 'Provider data center.' },
authMethod: { kind: 'string', describe: 'Provider authentication method.' },
privateKey: { kind: 'string', describe: 'Write-only PEM private key.' },
username: { kind: 'string', describe: 'Provider run-as username.' },
},
},
createSkill: {
@@ -6604,6 +6961,28 @@ export const V2_OPERATIONS = {
},
},
},
deleteKnowledgeConnector: {
method: 'DELETE',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]',
pathParams: ['id', 'connectorId'] as const,
pathParamDocs: {
id: 'Knowledge base that owns the connector.',
connectorId: 'Connector selected for the operation.',
},
responseMode: 'json',
summary: 'Delete Knowledge Connector',
query: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
deleteDocuments: {
kind: 'boolean',
describe: 'Also permanently delete documents produced by this connector.',
},
},
},
deleteKnowledgeDocument: {
method: 'DELETE',
path: '/api/v2/knowledge/[id]/documents/[documentId]',
@@ -7046,6 +7425,24 @@ export const V2_OPERATIONS = {
},
},
},
getKnowledgeConnector: {
method: 'GET',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]',
pathParams: ['id', 'connectorId'] as const,
pathParamDocs: {
id: 'Knowledge base that owns the connector.',
connectorId: 'Connector selected for the operation.',
},
responseMode: 'json',
summary: 'Get Knowledge Connector',
query: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
},
},
getKnowledgeDocument: {
method: 'GET',
path: '/api/v2/knowledge/[id]/documents/[documentId]',
@@ -7215,6 +7612,24 @@ export const V2_OPERATIONS = {
responseMode: 'json',
summary: 'Get Workspace',
},
grantSkillEditor: {
method: 'POST',
path: '/api/v2/skills/[id]/editors',
pathParams: ['id'] as const,
pathParamDocs: {
id: 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.',
},
responseMode: 'json',
summary: 'Grant Skill Editor',
body: {
workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' },
email: {
kind: 'string',
required: true,
describe: 'Email address of a current workspace member.',
},
},
},
importWorkflow: {
method: 'POST',
path: '/api/v2/workflows/import',
@@ -7610,6 +8025,77 @@ export const V2_OPERATIONS = {
},
},
},
listKnowledgeConnectorDocuments: {
method: 'GET',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents',
pathParams: ['id', 'connectorId'] as const,
pathParamDocs: {
id: 'Knowledge base that owns the connector.',
connectorId: 'Connector selected for the operation.',
},
responseMode: 'json',
summary: 'List Knowledge Connector Documents',
query: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
includeExcluded: {
kind: 'boolean',
describe: 'Include documents explicitly excluded by a user.',
},
limit: {
kind: 'integer',
default: 50,
describe:
'Maximum connector documents to return per page. Must be a whole number from 1 to 100. Defaults to 50.',
},
cursor: {
kind: 'string',
describe:
'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.',
},
},
},
listKnowledgeConnectors: {
method: 'GET',
path: '/api/v2/knowledge/[id]/connectors',
pathParams: ['id'] as const,
pathParamDocs: { id: 'Unique knowledge base identifier.' },
responseMode: 'json',
summary: 'List Knowledge Connectors',
query: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
sortBy: {
kind: 'enum',
values: ['connectorType', 'createdAt', 'updatedAt'] as const,
default: 'createdAt',
describe: 'Field used to sort the result.',
},
sortOrder: {
kind: 'enum',
values: ['asc', 'desc'] as const,
default: 'desc',
describe: 'Sort direction.',
},
limit: {
kind: 'integer',
default: 50,
describe:
'Maximum connectors to return per page. Must be a whole number from 1 to 100. Defaults to 50.',
},
cursor: {
kind: 'string',
describe:
'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.',
},
},
},
listKnowledgeDocuments: {
method: 'GET',
path: '/api/v2/knowledge/[id]/documents',
@@ -7931,6 +8417,43 @@ export const V2_OPERATIONS = {
},
},
},
listSkillEditors: {
method: 'GET',
path: '/api/v2/skills/[id]/editors',
pathParams: ['id'] as const,
pathParamDocs: {
id: 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.',
},
responseMode: 'json',
summary: 'List Skill Editors',
query: {
workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' },
sortBy: {
kind: 'enum',
values: ['email', 'name'] as const,
default: 'email',
describe:
'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.',
},
sortOrder: {
kind: 'enum',
values: ['asc', 'desc'] as const,
default: 'asc',
describe: 'Sort direction.',
},
limit: {
kind: 'integer',
default: 50,
describe:
'Maximum skill editors to return per page. Must be a whole number from 1 to 100. Defaults to 50.',
},
cursor: {
kind: 'string',
describe:
'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.',
},
},
},
listSkills: {
method: 'GET',
path: '/api/v2/skills',
@@ -8267,6 +8790,39 @@ export const V2_OPERATIONS = {
},
},
},
listWorkspaces: {
method: 'GET',
path: '/api/v2/workspaces',
pathParams: [] as const,
responseMode: 'json',
summary: 'List Workspaces',
query: {
sortBy: {
kind: 'enum',
values: ['name', 'createdAt', 'updatedAt'] as const,
default: 'createdAt',
describe:
'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.',
},
sortOrder: {
kind: 'enum',
values: ['asc', 'desc'] as const,
default: 'desc',
describe: 'Sort direction.',
},
limit: {
kind: 'integer',
default: 50,
describe:
'Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.',
},
cursor: {
kind: 'string',
describe:
'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.',
},
},
},
moveFileItems: {
method: 'POST',
path: '/api/v2/files/move',
@@ -8427,6 +8983,24 @@ export const V2_OPERATIONS = {
input: { kind: 'unknown', describe: 'Input supplied to the paused workflow block.' },
},
},
revokeSkillEditor: {
method: 'DELETE',
path: '/api/v2/skills/[id]/editors',
pathParams: ['id'] as const,
pathParamDocs: {
id: 'Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`.',
},
responseMode: 'json',
summary: 'Revoke Skill Editor',
query: {
workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' },
email: {
kind: 'string',
required: true,
describe: 'Email address of a current workspace member.',
},
},
},
rollbackWorkflow: {
method: 'POST',
path: '/api/v2/workflows/[id]/rollback',
@@ -8577,6 +9151,29 @@ export const V2_OPERATIONS = {
},
},
},
syncKnowledgeConnector: {
method: 'POST',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]/sync',
pathParams: ['id', 'connectorId'] as const,
pathParamDocs: {
id: 'Knowledge base that owns the connector.',
connectorId: 'Connector selected for the operation.',
},
responseMode: 'json',
summary: 'Sync Knowledge Connector',
body: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
rehydrate: {
kind: 'boolean',
default: false,
describe: 'Re-fetch and re-index every existing connector document.',
},
},
},
tableExportDownload: {
method: 'GET',
path: '/api/v2/tables/exports/[exportId]/download',
@@ -8660,6 +9257,66 @@ export const V2_OPERATIONS = {
folderPath: { kind: 'string', describe: 'New containing-folder path.' },
},
},
updateKnowledgeConnector: {
method: 'PATCH',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]',
pathParams: ['id', 'connectorId'] as const,
pathParamDocs: {
id: 'Knowledge base that owns the connector.',
connectorId: 'Connector selected for the operation.',
},
responseMode: 'json',
summary: 'Update Knowledge Connector',
body: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
sourceConfig: {
kind: 'object',
describe: 'Replacement source selection and filtering configuration.',
},
syncIntervalMinutes: {
kind: 'integer',
describe: 'New scheduled synchronization interval in minutes.',
},
status: {
kind: 'enum',
values: ['active', 'paused'] as const,
describe: 'New connector state.',
},
},
},
updateKnowledgeConnectorDocuments: {
method: 'PATCH',
path: '/api/v2/knowledge/[id]/connectors/[connectorId]/documents',
pathParams: ['id', 'connectorId'] as const,
pathParamDocs: {
id: 'Knowledge base that owns the connector.',
connectorId: 'Connector selected for the operation.',
},
responseMode: 'json',
summary: 'Update Knowledge Connector Documents',
body: {
workspaceId: {
kind: 'string',
required: true,
describe: 'Workspace that owns the knowledge base.',
},
operation: {
kind: 'enum',
required: true,
values: ['restore', 'exclude'] as const,
describe: 'Whether to restore or exclude the selected documents.',
},
documentIds: {
kind: 'array',
required: true,
describe: 'Connector document identifiers to update.',
},
},
},
updateKnowledgeDocument: {
method: 'PATCH',
path: '/api/v2/knowledge/[id]/documents/[documentId]',
+1
View File
@@ -18,6 +18,7 @@ with -P, --profile, or SIM_PROFILE.
Examples:
$ sim login Authorize the default profile
$ sim profile add acme --workspace ws_123 Reuse that login for a workspace
$ sim login --profile dev --endpoint http://localhost:3000
$ sim workflows list
$ sim logs list --level error --limit 20
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 1132,
zodRoutes: 1132,
totalRoutes: 1138,
zodRoutes: 1138,
nonZodRoutes: 0,
} as const
+3 -3
View File
@@ -35,9 +35,9 @@ const EXPECTED_OPERATION_COUNTS = new Map<string, number>([
['apps/docs/openapi-v2-logs.json', 2],
['apps/docs/openapi-v2-files-audit.json', 22],
['apps/docs/openapi-v2-tables.json', 44],
['apps/docs/openapi-v2-knowledge.json', 21],
['apps/docs/openapi-v2-knowledge.json', 29],
['apps/docs/openapi-v2-billing.json', 2],
['apps/docs/openapi-v2-resources.json', 26],
['apps/docs/openapi-v2-resources.json', 30],
])
function getOperation(spec: JsonObject, path: string, method: string): JsonObject {
@@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => {
})
}
}
expect(totalOperations).toBe(139)
expect(totalOperations).toBe(151)
})
it('documents mixed workflow execution and resume responses', () => {