mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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'],
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"}',
|
||||
},
|
||||
]
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user