diff --git a/apps/docs/content/docs/cli/audit-logs.mdx b/apps/docs/content/docs/cli/audit-logs.mdx index 1c06a2785ee..05d0e9e3acc 100644 --- a/apps/docs/content/docs/cli/audit-logs.mdx +++ b/apps/docs/content/docs/cli/audit-logs.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim audit-logs get [options] ``` -Get Audit Log (personal API key required) +Get Audit Log (OAuth login or personal API key required) **Arguments** @@ -33,7 +33,7 @@ Get Audit Log (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | @@ -43,7 +43,7 @@ Get Audit Log (personal API key required) sim audit-logs list [options] ``` -List Audit Logs (personal API key required) +List Audit Logs (OAuth login or personal API key required) **Options** @@ -59,8 +59,8 @@ List Audit Logs (personal API key required) | `--include-departed` | No | Include actions by users who have left the organization. | | `--no-include-departed` | No | Send --include-departed as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | | `--actor-email ` | No | Filter by actor email address. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | diff --git a/apps/docs/content/docs/cli/authentication.mdx b/apps/docs/content/docs/cli/authentication.mdx index 68461afd01f..3c9ec9e4e6c 100644 --- a/apps/docs/content/docs/cli/authentication.mdx +++ b/apps/docs/content/docs/cli/authentication.mdx @@ -5,8 +5,9 @@ description: Sign in from the terminal, authenticate CI with an API key, and kee import { Callout } from 'fumadocs-ui/components/callout' -The CLI authenticates with a Sim API key. `sim login` mints and stores one; in CI -you supply one through the environment instead. +`sim login` signs you in through your browser and stores a short-lived login +that renews itself and can be revoked at any time. In CI you supply an API key +through the environment instead. ## Signing in @@ -14,9 +15,59 @@ you supply one through the environment instead. sim login ``` -The terminal prints a pairing code and a URL: +The CLI opens your browser on Sim's sign-in page, then on a consent page that +names the Sim CLI and what it will be able to do. Approve, and the browser hands +control back to the terminal: ``` +Signing in to https://www.sim.ai as profile default + +https://www.sim.ai/api/auth/oauth2/authorize?client_id=sim-cli&… + +Waiting for you to approve in the browser… + +✓ Logged in. Login stored in /Users/you/.sim/credentials + Renews itself; revoke it any time in Settings → Authorized apps, or with: sim logout + No default workspace. Set one with: sim configure --set-workspace +``` + +This is the OAuth 2.0 authorization-code flow with PKCE and a loopback redirect, +aligned with current OAuth security guidance. The browser only ever carries a one-time code; +the tokens are exchanged over the terminal's own connection and written to +`~/.sim/credentials` with `0600` permissions. Access tokens last an hour and are +renewed automatically from a refresh token. The complete login has a fixed +30-day lifetime; after it expires, run `sim logout`, then sign in again. + + +Only approve a consent page you reached by running `sim login` yourself. A +consent page that appears unprompted, or one you were sent a link to, is not +your login. + + +| Option | What it does | +| --- | --- | +| `--no-browser` | Print the URL instead of opening a browser | +| `--browserless` | Use the pairing-code handoff instead (see below) | +| `--read-only` | Ask only for permission to read, never to change anything | +| `--callback-port ` | Pin the loopback callback port, primarily for an SSH session that forwards the same fixed port | +| `--scope ` | Key space for the pairing-code handoff. Only `copilot` changes anything, and it forces that flow | +| `-y, --yes` | Overwrite an existing API-key profile without prompting | + +### Over SSH or in a container + +The browser login needs your browser to reach a listener on the machine running +`sim`. When it cannot — an SSH session, a dev container, a remote box — use the +pairing-code handoff, which the CLI selects automatically in an SSH session: + +```bash +sim login --browserless +``` + +The terminal prints a pairing code and a URL you can open on any device: + +``` +Signing in to https://www.sim.ai as profile default + Pairing code: K7M2-P9XT Confirm this code matches what the browser shows before approving. @@ -27,45 +78,50 @@ Waiting for approval… Personal key, defaulting to 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67. Override per command with --workspace. ``` -There is no loopback listener, so this works over SSH and inside containers. - Confirm the pairing code in the browser matches the one in your terminal before approving. That check is what binds the approval to your terminal. -| Option | What it does | -| --- | --- | -| `--no-browser` | Print the URL instead of opening a browser | -| `--scope ` | Key space to mint from: `platform` (default) or `copilot` | -| `-y, --yes` | Overwrite an existing profile without prompting | +The handoff issues a permanent personal API key rather than a renewing login, +so revoke it under **Settings → API keys** when you are done with that machine. +It is also the path for a deployment that predates OAuth sign-in, or one with +the provider switched off; the CLI detects that and falls back on its own. + +`--read-only` and `--callback-port` belong to the browser login and have no +meaning here, so combining either with the handoff stops the login rather than +storing a credential you did not ask for. If your SSH session forwards a port +from the remote loopback interface to the browser's machine, pass that same +`--callback-port ` on its own. An ordinary container port publication +cannot reach a listener bound to the container's own loopback interface; use +`--browserless` there. ### Picking a workspace -You choose the workspace on the approval page. `sim login` issues a **personal** key. The workspace you pick becomes the -profile's default `workspace`; it does **not** restrict the key to that -workspace. Target another workspace the key can reach with `--workspace`: +A normal login can act across every workspace you belong to; `--read-only` +limits it to read operations. The profile's `workspace` setting only decides the default target. +Set it after signing in, or pass `--workspace` per command: ```bash +sim workspaces list +sim configure --set-workspace 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 sim workflows list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a ``` -`sim login --workspace ` preselects a workspace in the picker, and -re-logging into an existing profile preselects the one already configured. +With the pairing-code handoff you choose the default workspace on the approval +page instead. -To save another workspace without minting or copying another personal key, add -a workspace profile: +To target another workspace without a second login, add a workspace profile: ```bash -sim workspaces list sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 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. +`--workspace` in an interactive terminal to choose from the workspaces your +login 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 @@ -74,9 +130,10 @@ sim whoami # resolved settings, plus a live check that they work sim whoami --no-verify # resolved settings only, no request ``` -Prints the resolved endpoint, workspace, and output format, and which source each -value came from, then reads the configured workspace to prove the key is accepted -and can reach it. +Prints the resolved endpoint, workspace, and output format, which source each +value came from, and whether the profile holds an OAuth login or an API key, +then reads the configured workspace to prove the credential is accepted and can +reach it. It exits `0` when the check passes, `1` when the credentials are wrong, and `2` when the check could not be made at all — no workspace to check against, or an @@ -86,25 +143,31 @@ logging in again. ## Signing out ```bash -sim logout # remove the stored key +sim logout # sign out of Sim and remove the stored login sim logout --all # remove the profile entirely, including its settings ``` -A workspace profile that shares authentication cannot remove the shared key. +For an OAuth login, `sim logout` revokes that login's complete token family +before removing it from disk, including access tokens issued before earlier +rotations. Other machines that ran their own `sim login` remain signed in. To +cut off every independent login for the client, revoke the grant under +**Settings → Authorized apps**. + +A workspace profile that shares authentication cannot remove the shared login. Remove only that local profile with `sim logout --all --profile `, 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. -`sim logout` removes the key from disk but does **not** revoke it. Revoke keys in -Sim under **Settings → API keys**. +For a login created with `--browserless`, `sim logout` removes the API key from +disk but does **not** revoke it. Revoke keys under **Settings → API keys**. ## Authenticating CI -Set the key and workspace in the environment; the CLI never reads or writes a -config file: +Set an API key and workspace in the environment; the CLI never reads or writes +a config file, and an explicit key outranks any stored login: ```bash export SIM_API_KEY="sim_…" @@ -148,7 +211,7 @@ sim workflows list --profile dev sim workflows list --profile prod ``` -Use workspace profiles when one personal key should target several workspaces: +Use workspace profiles when one login should target several workspaces: ```bash sim profile add marketing --workspace c3a70e58-9f21-4d6b-b842-05e7f19c6a3d @@ -174,21 +237,43 @@ Save it to avoid repeating the flag: sim configure --set-endpoint http://localhost:3000 --profile local ``` -## Where the key is stored +A self-hosted deployment offers OAuth sign-in when +`OAUTH_PROVIDER_ENABLED=true` and authentication is enabled. Leave it unset to +use the pairing-code handoff; `DISABLE_AUTH=true` also forces OAuth off. + +## Where the login is stored -Keys live in `~/.sim/credentials`, written `0600`, separate from the non-secret +Logins live in `~/.sim/credentials`, written `0600`, separate from the non-secret `~/.sim/config`. Commit `config` to a dotfiles repo if you like; never `credentials`. ```ini title="~/.sim/credentials" [default] -api_key = sim_… - -[dev] +access_token = sim_oat_… +refresh_token = sim_ort_… +token_expires_at = 1788547200000 +oauth_issuer = https://www.sim.ai/api/auth +oauth_login_id = … +oauth_scope = offline_access api:read api:write + +[ci-box] api_key = sim_… ``` +A profile holds one login. A stored API key can be replaced after confirmation +or with `--yes`; a live OAuth login must be revoked with `sim logout` before +signing in again. Several `sim` commands running at once share one renewal, so +a parallel shell loop cannot sign itself out. + +Run `sim login` separately on each machine. Copying `~/.sim/credentials` copies +one single-use refresh-token family; simultaneous use from both copies is +treated as token replay and revokes that login. If a refresh response is lost +because the process or connection stops, the CLI does not retry the consumed +token: run `sim logout`, then `sim login` again. This fail-closed behavior keeps +a copied token from surviving an ambiguous refresh. + ## Organization audit logs -`sim audit-logs` requires a **personal** API key — the kind `sim login` issues. -A workspace-scoped key cannot read organization-level audit logs. +`sim audit-logs` requires a **personal** credential — an OAuth login, or the +personal API key `sim login --browserless` issues. A workspace-scoped key cannot +read organization-level audit logs. diff --git a/apps/docs/content/docs/cli/billing.mdx b/apps/docs/content/docs/cli/billing.mdx index 2df8ad9d9dc..7460d6e630b 100644 --- a/apps/docs/content/docs/cli/billing.mdx +++ b/apps/docs/content/docs/cli/billing.mdx @@ -13,7 +13,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim billing status [options] ``` -Show billing status and current-period credit usage (credits and storage require a personal API key) +Show billing status and current-period credit usage (credits and storage require an OAuth login or personal API key) **Options** @@ -21,7 +21,7 @@ Show billing status and current-period credit usage (credits and storage require | Option | Required | Description | | --- | --- | --- | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -31,7 +31,7 @@ Show billing status and current-period credit usage (credits and storage require sim billing logs [options] ``` -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) +List credit usage events (an OAuth login or personal API key reports only your events; a workspace API key reports every member's in aggregate, unattributed) **Options** @@ -44,6 +44,6 @@ List credit usage events (a personal API key reports only your own events; a wor | `--start-date ` | No | Custom period start (ISO 8601). | | `--end-date ` | No | Custom period end (ISO 8601). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index e8a54f5acc6..c55d7e89044 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -52,7 +52,7 @@ These apply to every command, and may be written before or after it. | [`sim workflows`](/cli/workflows) | Manage workflows | | [`sim workspaces`](/cli/workspaces) | Manage workspaces | -## Authorize this terminal and store an API key for the profile +## Sign in through the browser and store the login for the profile ```bash sim login [options] @@ -64,13 +64,16 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Key space to mint from: platform or copilot. Defaults to `platform`. | +| `--scope ` | No | Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow. Defaults to `platform`. | | `--no-browser` | No | Print the URL instead of opening a browser. | -| `-y, --yes` | No | Overwrite an existing profile without prompting. | +| `--browserless` | No | Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers). | +| `--read-only` | No | Ask only for permission to read, never to change anything. | +| `--callback-port ` | No | Pin the local port the browser returns to. | +| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | -## Remove the profile's stored API key +## Sign out and remove the profile's stored login ```bash sim logout [options] diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index aec2144c459..cbab5289619 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim credentials delete [options] ``` -Disconnect Credential (personal API key required) +Disconnect Credential (OAuth login or personal API key required) **Arguments** @@ -80,7 +80,7 @@ sim credentials list [options] sim credentials update [options] ``` -Update Credential (personal API key required) +Update Credential (OAuth login or personal API key required) **Arguments** @@ -123,7 +123,7 @@ Update Credential (personal API key required) sim credentials create [options] ``` -Create a service-account credential using its discovered provider schema (personal API key required) +Create a service-account credential using its discovered provider schema (OAuth login or personal API key required) **Arguments** @@ -154,7 +154,7 @@ Create a service-account credential using its discovered provider schema (person sim credentials connect [options] ``` -Create a short-lived link for connecting an OAuth provider (personal API key required) +Create a short-lived link for connecting an OAuth provider (OAuth login or personal API key required) **Arguments** @@ -182,7 +182,7 @@ Create a short-lived link for connecting an OAuth provider (personal API key req sim credentials reconnect ``` -Create a short-lived link for reconnecting an OAuth credential (personal API key required) +Create a short-lived link for reconnecting an OAuth credential (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index be9e2330c84..89d5db8aadc 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -248,7 +248,7 @@ sim files share get sim files share set [options] ``` -Enable or disable sharing for a file (personal API key required) +Enable or disable sharing for a file (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/knowledge.mdx b/apps/docs/content/docs/cli/knowledge.mdx index 5d697bb22b1..8ffe1dd8613 100644 --- a/apps/docs/content/docs/cli/knowledge.mdx +++ b/apps/docs/content/docs/cli/knowledge.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim knowledge from-workspace-files create [options] ``` -Index files the workspace already stores (personal API key required) +Index files the workspace already stores (OAuth login or personal API key required) **Arguments** @@ -43,7 +43,7 @@ Index files the workspace already stores (personal API key required) sim knowledge tags save [options] ``` -Declare the tag definitions a knowledge base needs (personal API key required) +Declare the tag definitions a knowledge base needs (OAuth login or personal API key required) **Arguments** @@ -71,7 +71,7 @@ Declare the tag definitions a knowledge base needs (personal API key required) sim knowledge tags create [options] ``` -Create Tag (personal API key required) +Create Tag (OAuth login or personal API key required) **Arguments** @@ -101,7 +101,7 @@ Create Tag (personal API key required) sim knowledge tags delete [options] ``` -Delete Tag (personal API key required) +Delete Tag (OAuth login or personal API key required) **Arguments** @@ -130,7 +130,7 @@ Delete Tag (personal API key required) sim knowledge tags cleanup [options] ``` -Remove tag definitions no document still uses (personal API key required) +Remove tag definitions no document still uses (OAuth login or personal API key required) **Arguments** @@ -160,7 +160,7 @@ Remove tag definitions no document still uses (personal API key required) sim knowledge tags next-slot [options] ``` -Show which tag slot a create would take for a field type (personal API key required) +Show which tag slot a create would take for a field type (OAuth login or personal API key required) **Arguments** @@ -204,7 +204,7 @@ sim knowledge tags list sim knowledge tags usage ``` -Show how many documents and chunks carry each tag (personal API key required) +Show how many documents and chunks carry each tag (OAuth login or personal API key required) **Arguments** @@ -222,7 +222,7 @@ Show how many documents and chunks carry each tag (personal API key required) sim knowledge tags update [options] ``` -Update Tag (personal API key required) +Update Tag (OAuth login or personal API key required) **Arguments** @@ -252,7 +252,7 @@ Update Tag (personal API key required) sim knowledge chunks batch-update [options] ``` -Enable, disable, or delete many chunks at once (personal API key required) +Enable, disable, or delete many chunks at once (OAuth login or personal API key required) **Arguments** @@ -283,7 +283,7 @@ Enable, disable, or delete many chunks at once (personal API key required) sim knowledge chunks create [options] ``` -Create Chunk (personal API key required) +Create Chunk (OAuth login or personal API key required) **Arguments** @@ -314,7 +314,7 @@ Create Chunk (personal API key required) sim knowledge chunks delete [options] ``` -Delete Chunk (personal API key required) +Delete Chunk (OAuth login or personal API key required) **Arguments** @@ -344,7 +344,7 @@ Delete Chunk (personal API key required) sim knowledge chunks get ``` -Get Chunk (personal API key required) +Get Chunk (OAuth login or personal API key required) **Arguments** @@ -364,7 +364,7 @@ Get Chunk (personal API key required) sim knowledge chunks list [options] ``` -List Chunks (personal API key required) +List Chunks (OAuth login or personal API key required) **Arguments** @@ -397,7 +397,7 @@ List Chunks (personal API key required) sim knowledge chunks update [options] ``` -Update Chunk (personal API key required) +Update Chunk (OAuth login or personal API key required) **Arguments** @@ -429,7 +429,7 @@ Update Chunk (personal API key required) sim knowledge documents batch-update [options] ``` -Enable or disable every matching document (personal API key required) +Enable or disable every matching document (OAuth login or personal API key required) **Arguments** @@ -535,7 +535,7 @@ sim knowledge documents list [options] sim knowledge documents update [options] ``` -Update Document (personal API key required) +Update Document (OAuth login or personal API key required) **Arguments** @@ -636,7 +636,7 @@ sim knowledge create [options] sim knowledge connectors create [options] ``` -Create Knowledge Connector (personal API key required) +Create Knowledge Connector (OAuth login or personal API key required) **Arguments** @@ -668,7 +668,7 @@ Create Knowledge Connector (personal API key required) sim knowledge connectors delete [options] ``` -Delete Knowledge Connector (personal API key required) +Delete Knowledge Connector (OAuth login or personal API key required) **Arguments** @@ -699,7 +699,7 @@ Delete Knowledge Connector (personal API key required) sim knowledge connectors get ``` -Get Knowledge Connector (personal API key required) +Get Knowledge Connector (OAuth login or personal API key required) **Arguments** @@ -718,7 +718,7 @@ Get Knowledge Connector (personal API key required) sim knowledge connectors documents list [options] ``` -List Knowledge Connector Documents (personal API key required) +List Knowledge Connector Documents (OAuth login or personal API key required) **Arguments** @@ -749,7 +749,7 @@ List Knowledge Connector Documents (personal API key required) sim knowledge connectors documents update [options] ``` -Update Knowledge Connector Documents (personal API key required) +Update Knowledge Connector Documents (OAuth login or personal API key required) **Arguments** @@ -779,7 +779,7 @@ Update Knowledge Connector Documents (personal API key required) sim knowledge connectors list [options] ``` -List Knowledge Connectors (personal API key required) +List Knowledge Connectors (OAuth login or personal API key required) **Arguments** @@ -809,7 +809,7 @@ List Knowledge Connectors (personal API key required) sim knowledge connectors sync [options] ``` -Queue a knowledge connector synchronization (personal API key required) +Queue a knowledge connector synchronization (OAuth login or personal API key required) **Arguments** @@ -839,7 +839,7 @@ Queue a knowledge connector synchronization (personal API key required) sim knowledge connectors update [options] ``` -Update Knowledge Connector (personal API key required) +Update Knowledge Connector (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/logs.mdx b/apps/docs/content/docs/cli/logs.mdx index df85aaa0bf1..186a39255de 100644 --- a/apps/docs/content/docs/cli/logs.mdx +++ b/apps/docs/content/docs/cli/logs.mdx @@ -70,7 +70,7 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | | `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -85,7 +85,7 @@ sim logs list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | diff --git a/apps/docs/content/docs/cli/mcp-servers.mdx b/apps/docs/content/docs/cli/mcp-servers.mdx index db0f65a115a..ca1607cc7f9 100644 --- a/apps/docs/content/docs/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/cli/mcp-servers.mdx @@ -103,7 +103,7 @@ sim mcp-servers list [options] sim mcp-servers tools list [options] ``` -List MCP Server Tools (personal API key required) +List MCP Server Tools (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 28bab99672f..792ec073437 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -26,7 +26,7 @@ These apply to every command, and may be written before or after it. ## sim login -Authorize this terminal and store an API key for the profile +Sign in through the browser and store the login for the profile ```bash sim login [options] @@ -38,15 +38,18 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Key space to mint from: platform or copilot. Defaults to `platform`. | +| `--scope ` | No | Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow. Defaults to `platform`. | | `--no-browser` | No | Print the URL instead of opening a browser. | -| `-y, --yes` | No | Overwrite an existing profile without prompting. | +| `--browserless` | No | Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers). | +| `--read-only` | No | Ask only for permission to read, never to change anything. | +| `--callback-port ` | No | Pin the local port the browser returns to. | +| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | ## sim logout -Remove the profile's stored API key +Sign out and remove the profile's stored login ```bash sim logout [options] @@ -175,7 +178,7 @@ Also spelled `sim audit-log`. ### sim audit-logs get -Get Audit Log (personal API key required) +Get Audit Log (OAuth login or personal API key required) ```bash sim audit-logs get [options] @@ -197,13 +200,13 @@ sim audit-logs get [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | ### sim audit-logs list -List Audit Logs (personal API key required) +List Audit Logs (OAuth login or personal API key required) ```bash sim audit-logs list [options] @@ -223,9 +226,9 @@ sim audit-logs list [options] | `--include-departed` | No | Include actions by users who have left the organization. | | `--no-include-departed` | No | Send --include-departed as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | | `--actor-email ` | No | Filter by actor email address. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -233,7 +236,7 @@ sim audit-logs list [options] ### sim billing status -Show billing status and current-period credit usage (credits and storage require a personal API key) +Show billing status and current-period credit usage (credits and storage require an OAuth login or personal API key) ```bash sim billing status [options] @@ -245,13 +248,13 @@ sim billing status [options] | Option | Required | Description | | --- | --- | --- | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | ### sim billing logs -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) +List credit usage events (an OAuth login or personal API key reports only your events; a workspace API key reports every member's in aggregate, unattributed) ```bash sim billing logs [options] @@ -268,7 +271,7 @@ sim billing logs [options] | `--start-date ` | No | Custom period start (ISO 8601). | | `--end-date ` | No | Custom period end (ISO 8601). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -367,7 +370,7 @@ Also spelled `sim credential`. ### sim credentials delete -Disconnect Credential (personal API key required) +Disconnect Credential (OAuth login or personal API key required) ```bash sim credentials delete [options] @@ -436,7 +439,7 @@ sim credentials list [options] ### sim credentials update -Update Credential (personal API key required) +Update Credential (OAuth login or personal API key required) ```bash sim credentials update [options] @@ -479,7 +482,7 @@ sim credentials update [options] ### sim credentials create -Create a service-account credential using its discovered provider schema (personal API key required) +Create a service-account credential using its discovered provider schema (OAuth login or personal API key required) ```bash sim credentials create [options] @@ -510,7 +513,7 @@ sim credentials create [options] ### sim credentials connect -Create a short-lived link for connecting an OAuth provider (personal API key required) +Create a short-lived link for connecting an OAuth provider (OAuth login or personal API key required) ```bash sim credentials connect [options] @@ -538,7 +541,7 @@ sim credentials connect [options] ### sim credentials reconnect -Create a short-lived link for reconnecting an OAuth credential (personal API key required) +Create a short-lived link for reconnecting an OAuth credential (OAuth login or personal API key required) ```bash sim credentials reconnect @@ -936,7 +939,7 @@ sim files share get ### sim files share set -Enable or disable sharing for a file (personal API key required) +Enable or disable sharing for a file (OAuth login or personal API key required) ```bash sim files share set [options] @@ -1278,7 +1281,7 @@ Also spelled `sim kb`. ### sim knowledge from-workspace-files create -Index files the workspace already stores (personal API key required) +Index files the workspace already stores (OAuth login or personal API key required) ```bash sim knowledge from-workspace-files create [options] @@ -1306,7 +1309,7 @@ sim knowledge from-workspace-files create [options] ### sim knowledge tags save -Declare the tag definitions a knowledge base needs (personal API key required) +Declare the tag definitions a knowledge base needs (OAuth login or personal API key required) ```bash sim knowledge tags save [options] @@ -1334,7 +1337,7 @@ sim knowledge tags save [options] ### sim knowledge tags create -Create Tag (personal API key required) +Create Tag (OAuth login or personal API key required) ```bash sim knowledge tags create [options] @@ -1364,7 +1367,7 @@ sim knowledge tags create [options] ### sim knowledge tags delete -Delete Tag (personal API key required) +Delete Tag (OAuth login or personal API key required) ```bash sim knowledge tags delete [options] @@ -1393,7 +1396,7 @@ sim knowledge tags delete [options] ### sim knowledge tags cleanup -Remove tag definitions no document still uses (personal API key required) +Remove tag definitions no document still uses (OAuth login or personal API key required) ```bash sim knowledge tags cleanup [options] @@ -1423,7 +1426,7 @@ sim knowledge tags cleanup [options] ### sim knowledge tags next-slot -Show which tag slot a create would take for a field type (personal API key required) +Show which tag slot a create would take for a field type (OAuth login or personal API key required) ```bash sim knowledge tags next-slot [options] @@ -1469,7 +1472,7 @@ sim knowledge tags list ### sim knowledge tags usage -Show how many documents and chunks carry each tag (personal API key required) +Show how many documents and chunks carry each tag (OAuth login or personal API key required) ```bash sim knowledge tags usage @@ -1487,7 +1490,7 @@ sim knowledge tags usage ### sim knowledge tags update -Update Tag (personal API key required) +Update Tag (OAuth login or personal API key required) ```bash sim knowledge tags update [options] @@ -1517,7 +1520,7 @@ sim knowledge tags update [options] ### sim knowledge chunks batch-update -Enable, disable, or delete many chunks at once (personal API key required) +Enable, disable, or delete many chunks at once (OAuth login or personal API key required) ```bash sim knowledge chunks batch-update [options] @@ -1548,7 +1551,7 @@ sim knowledge chunks batch-update [options] ### sim knowledge chunks create -Create Chunk (personal API key required) +Create Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks create [options] @@ -1579,7 +1582,7 @@ sim knowledge chunks create [options] ### sim knowledge chunks delete -Delete Chunk (personal API key required) +Delete Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks delete [options] @@ -1609,7 +1612,7 @@ sim knowledge chunks delete [options] ### sim knowledge chunks get -Get Chunk (personal API key required) +Get Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks get @@ -1629,7 +1632,7 @@ sim knowledge chunks get ### sim knowledge chunks list -List Chunks (personal API key required) +List Chunks (OAuth login or personal API key required) ```bash sim knowledge chunks list [options] @@ -1662,7 +1665,7 @@ sim knowledge chunks list [options] ### sim knowledge chunks update -Update Chunk (personal API key required) +Update Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks update [options] @@ -1694,7 +1697,7 @@ sim knowledge chunks update [options] ### sim knowledge documents batch-update -Enable or disable every matching document (personal API key required) +Enable or disable every matching document (OAuth login or personal API key required) ```bash sim knowledge documents batch-update [options] @@ -1806,7 +1809,7 @@ sim knowledge documents list [options] ### sim knowledge documents update -Update Document (personal API key required) +Update Document (OAuth login or personal API key required) ```bash sim knowledge documents update [options] @@ -1911,7 +1914,7 @@ sim knowledge create [options] ### sim knowledge connectors create -Create Knowledge Connector (personal API key required) +Create Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors create [options] @@ -1943,7 +1946,7 @@ sim knowledge connectors create [options] ### sim knowledge connectors delete -Delete Knowledge Connector (personal API key required) +Delete Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors delete [options] @@ -1974,7 +1977,7 @@ sim knowledge connectors delete [options] ### sim knowledge connectors get -Get Knowledge Connector (personal API key required) +Get Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors get @@ -1993,7 +1996,7 @@ sim knowledge connectors get ### sim knowledge connectors documents list -List Knowledge Connector Documents (personal API key required) +List Knowledge Connector Documents (OAuth login or personal API key required) ```bash sim knowledge connectors documents list [options] @@ -2024,7 +2027,7 @@ sim knowledge connectors documents list [options ### sim knowledge connectors documents update -Update Knowledge Connector Documents (personal API key required) +Update Knowledge Connector Documents (OAuth login or personal API key required) ```bash sim knowledge connectors documents update [options] @@ -2054,7 +2057,7 @@ sim knowledge connectors documents update [optio ### sim knowledge connectors list -List Knowledge Connectors (personal API key required) +List Knowledge Connectors (OAuth login or personal API key required) ```bash sim knowledge connectors list [options] @@ -2084,7 +2087,7 @@ sim knowledge connectors list [options] ### sim knowledge connectors sync -Queue a knowledge connector synchronization (personal API key required) +Queue a knowledge connector synchronization (OAuth login or personal API key required) ```bash sim knowledge connectors sync [options] @@ -2114,7 +2117,7 @@ sim knowledge connectors sync [options] ### sim knowledge connectors update -Update Knowledge Connector (personal API key required) +Update Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors update [options] @@ -2515,7 +2518,7 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | | `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2530,7 +2533,7 @@ sim logs list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | @@ -2665,7 +2668,7 @@ sim mcp-servers list [options] ### sim mcp-servers tools list -List MCP Server Tools (personal API key required) +List MCP Server Tools (OAuth login or personal API key required) ```bash sim mcp-servers tools list [options] @@ -2747,7 +2750,7 @@ Also spelled `sim sandbox`. ### sim sandboxes create -Create Sandbox (personal API key required) +Create Sandbox (OAuth login or personal API key required) ```bash sim sandboxes create [options] @@ -2769,7 +2772,7 @@ sim sandboxes create [options] ### sim sandboxes delete -Delete Sandbox (personal API key required) +Delete Sandbox (OAuth login or personal API key required) ```bash sim sandboxes delete [options] @@ -2836,7 +2839,7 @@ sim sandboxes list [options] ### sim sandboxes update -Update Sandbox (personal API key required) +Update Sandbox (OAuth login or personal API key required) ```bash sim sandboxes update [options] @@ -2872,7 +2875,7 @@ Also spelled `sim secret`. ### sim secrets delete -Delete Secret (personal API key required) +Delete Secret (OAuth login or personal API key required) ```bash sim secrets delete [options] @@ -2901,7 +2904,7 @@ sim secrets delete [options] ### sim secrets list -List Secrets (personal API key required) +List Secrets (OAuth login or personal API key required) ```bash sim secrets list [options] @@ -2923,7 +2926,7 @@ sim secrets list [options] ### sim secrets set -Create or replace a named secret (personal API key required) +Create or replace a named secret (OAuth login or personal API key required) ```bash sim secrets set [options] @@ -2959,7 +2962,7 @@ Also spelled `sim skill`. ### sim skills create -Create Skill (personal API key required) +Create Skill (OAuth login or personal API key required) ```bash sim skills create [options] @@ -2979,7 +2982,7 @@ sim skills create [options] ### sim skills delete -Delete Skill (personal API key required) +Delete Skill (OAuth login or personal API key required) ```bash sim skills delete [options] @@ -3025,7 +3028,7 @@ sim skills get ### sim skills editors create -Grant Skill Editor (personal API key required) +Grant Skill Editor (OAuth login or personal API key required) ```bash sim skills editors create [options] @@ -3083,7 +3086,7 @@ sim skills editors list [options] ### sim skills editors delete -Revoke Skill Editor (personal API key required) +Revoke Skill Editor (OAuth login or personal API key required) ```bash sim skills editors delete [options] @@ -3133,7 +3136,7 @@ sim skills list [options] ### sim skills update -Update Skill (personal API key required) +Update Skill (OAuth login or personal API key required) ```bash sim skills update [options] @@ -4548,7 +4551,7 @@ sim tables mkdir ### sim tools execute -Run one built-in tool and print what it produced (personal API key required) +Run one built-in tool and print what it produced (OAuth login or personal API key required) ```bash sim tools execute [options] @@ -4621,7 +4624,7 @@ sim tools list [options] ### sim workflow-mcp-servers create -Create Workflow MCP Server (personal API key required) +Create Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers create [options] @@ -4643,7 +4646,7 @@ sim workflow-mcp-servers create [options] ### sim workflow-mcp-servers delete -Delete Workflow MCP Server (personal API key required) +Delete Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers delete [options] @@ -4671,7 +4674,7 @@ sim workflow-mcp-servers delete [options] ### sim workflow-mcp-servers tools create -Publish Workflow As MCP Tool (personal API key required) +Publish Workflow As MCP Tool (OAuth login or personal API key required) ```bash sim workflow-mcp-servers tools create [options] @@ -4702,7 +4705,7 @@ sim workflow-mcp-servers tools create [options] ### sim workflow-mcp-servers tools list -List Workflow MCP Tools (personal API key required) +List Workflow MCP Tools (OAuth login or personal API key required) ```bash sim workflow-mcp-servers tools list @@ -4720,7 +4723,7 @@ sim workflow-mcp-servers tools list ### sim workflow-mcp-servers tools delete -Unpublish Workflow MCP Tool (personal API key required) +Unpublish Workflow MCP Tool (OAuth login or personal API key required) ```bash sim workflow-mcp-servers tools delete [options] @@ -4749,7 +4752,7 @@ sim workflow-mcp-servers tools delete [options] ### sim workflow-mcp-servers get -Get Workflow MCP Server (personal API key required) +Get Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers get @@ -4767,7 +4770,7 @@ sim workflow-mcp-servers get ### sim workflow-mcp-servers list -List Workflow MCP Servers (personal API key required) +List Workflow MCP Servers (OAuth login or personal API key required) ```bash sim workflow-mcp-servers list [options] @@ -4787,7 +4790,7 @@ sim workflow-mcp-servers list [options] ### sim workflow-mcp-servers update -Update Workflow MCP Server (personal API key required) +Update Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers update [options] @@ -4822,7 +4825,7 @@ Also spelled `sim workflow`. ### sim workflows activate create -Activate Workflow Version (personal API key required) +Activate Workflow Version (OAuth login or personal API key required) ```bash sim workflows activate create [options] @@ -4851,7 +4854,7 @@ sim workflows activate create [options] ### sim workflows operations apply -Apply Workflow Operations (personal API key required) +Apply Workflow Operations ```bash sim workflows operations apply [options] @@ -5198,7 +5201,7 @@ sim workflows delete [options] ### sim workflows chat unpublish -Take a workflow’s chat deployment offline (personal API key required) +Take a workflow’s chat deployment offline (OAuth login or personal API key required) ```bash sim workflows chat unpublish [options] @@ -5226,7 +5229,7 @@ sim workflows chat unpublish [options] ### sim workflows chat status -Show a workflow’s chat deployment (personal API key required) +Show a workflow’s chat deployment (OAuth login or personal API key required) ```bash sim workflows chat status @@ -5244,7 +5247,7 @@ sim workflows chat status ### sim workflows chat publish -Publish or replace a workflow’s chat deployment (personal API key required) +Publish or replace a workflow’s chat deployment ```bash sim workflows chat publish [options] @@ -5284,7 +5287,7 @@ sim workflows chat publish [options] ### sim workflows deploy -Deploy Workflow (personal API key required) +Deploy Workflow (OAuth login or personal API key required) ```bash sim workflows deploy [options] @@ -5439,7 +5442,7 @@ sim workflows deployment status ### sim workflows deployment update -Update Workflow Public API Access (personal API key required) +Update Workflow Public API Access (OAuth login or personal API key required) ```bash sim workflows deployment update [options] @@ -5485,7 +5488,7 @@ sim workflows state get ### sim workflows state replace -Replace Workflow State (personal API key required) +Replace Workflow State ```bash sim workflows state replace [options] @@ -5680,7 +5683,7 @@ sim workflows restore ### sim workflows revert create -Revert Workflow To Version (personal API key required) +Revert Workflow To Version (OAuth login or personal API key required) ```bash sim workflows revert create [options] @@ -5709,7 +5712,7 @@ sim workflows revert create [options] ### sim workflows rollback -Rollback Workflow (personal API key required) +Rollback Workflow (OAuth login or personal API key required) ```bash sim workflows rollback [options] @@ -5738,7 +5741,7 @@ sim workflows rollback [options] ### sim workflows undeploy -Take a workflow out of deployment (personal API key required) +Take a workflow out of deployment (OAuth login or personal API key required) ```bash sim workflows undeploy [options] diff --git a/apps/docs/content/docs/cli/sandboxes.mdx b/apps/docs/content/docs/cli/sandboxes.mdx index 9a73cc47dae..fc2a3c125a3 100644 --- a/apps/docs/content/docs/cli/sandboxes.mdx +++ b/apps/docs/content/docs/cli/sandboxes.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim sandboxes create [options] ``` -Create Sandbox (personal API key required) +Create Sandbox (OAuth login or personal API key required) **Options** @@ -37,7 +37,7 @@ Create Sandbox (personal API key required) sim sandboxes delete [options] ``` -Delete Sandbox (personal API key required) +Delete Sandbox (OAuth login or personal API key required) **Arguments** @@ -100,7 +100,7 @@ sim sandboxes list [options] sim sandboxes update [options] ``` -Update Sandbox (personal API key required) +Update Sandbox (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/secrets.mdx b/apps/docs/content/docs/cli/secrets.mdx index f9bbbdb2cdb..6cd7d80aa29 100644 --- a/apps/docs/content/docs/cli/secrets.mdx +++ b/apps/docs/content/docs/cli/secrets.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim secrets delete [options] ``` -Delete Secret (personal API key required) +Delete Secret (OAuth login or personal API key required) **Arguments** @@ -44,7 +44,7 @@ Delete Secret (personal API key required) sim secrets list [options] ``` -List Secrets (personal API key required) +List Secrets (OAuth login or personal API key required) **Options** @@ -66,7 +66,7 @@ List Secrets (personal API key required) sim secrets set [options] ``` -Create or replace a named secret (personal API key required) +Create or replace a named secret (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/skills.mdx b/apps/docs/content/docs/cli/skills.mdx index 77d928a5ead..b11c57d4030 100644 --- a/apps/docs/content/docs/cli/skills.mdx +++ b/apps/docs/content/docs/cli/skills.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim skills create [options] ``` -Create Skill (personal API key required) +Create Skill (OAuth login or personal API key required) **Options** @@ -35,7 +35,7 @@ Create Skill (personal API key required) sim skills delete [options] ``` -Delete Skill (personal API key required) +Delete Skill (OAuth login or personal API key required) **Arguments** @@ -79,7 +79,7 @@ sim skills get sim skills editors create [options] ``` -Grant Skill Editor (personal API key required) +Grant Skill Editor (OAuth login or personal API key required) **Arguments** @@ -135,7 +135,7 @@ sim skills editors list [options] sim skills editors delete [options] ``` -Revoke Skill Editor (personal API key required) +Revoke Skill Editor (OAuth login or personal API key required) **Arguments** @@ -183,7 +183,7 @@ sim skills list [options] sim skills update [options] ``` -Update Skill (personal API key required) +Update Skill (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/tools.mdx b/apps/docs/content/docs/cli/tools.mdx index 83bdb04bbc5..2248ec6c063 100644 --- a/apps/docs/content/docs/cli/tools.mdx +++ b/apps/docs/content/docs/cli/tools.mdx @@ -13,7 +13,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim tools execute [options] ``` -Run one built-in tool and print what it produced (personal API key required) +Run one built-in tool and print what it produced (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/cli/workflow-mcp-servers.mdx index ca7ca03a730..56fdb984f24 100644 --- a/apps/docs/content/docs/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/cli/workflow-mcp-servers.mdx @@ -13,7 +13,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflow-mcp-servers create [options] ``` -Create Workflow MCP Server (personal API key required) +Create Workflow MCP Server (OAuth login or personal API key required) **Options** @@ -35,7 +35,7 @@ Create Workflow MCP Server (personal API key required) sim workflow-mcp-servers delete [options] ``` -Delete Workflow MCP Server (personal API key required) +Delete Workflow MCP Server (OAuth login or personal API key required) **Arguments** @@ -63,7 +63,7 @@ Delete Workflow MCP Server (personal API key required) sim workflow-mcp-servers tools create [options] ``` -Publish Workflow As MCP Tool (personal API key required) +Publish Workflow As MCP Tool (OAuth login or personal API key required) **Arguments** @@ -94,7 +94,7 @@ Publish Workflow As MCP Tool (personal API key required) sim workflow-mcp-servers tools list ``` -List Workflow MCP Tools (personal API key required) +List Workflow MCP Tools (OAuth login or personal API key required) **Arguments** @@ -112,7 +112,7 @@ List Workflow MCP Tools (personal API key required) sim workflow-mcp-servers tools delete [options] ``` -Unpublish Workflow MCP Tool (personal API key required) +Unpublish Workflow MCP Tool (OAuth login or personal API key required) **Arguments** @@ -141,7 +141,7 @@ Unpublish Workflow MCP Tool (personal API key required) sim workflow-mcp-servers get ``` -Get Workflow MCP Server (personal API key required) +Get Workflow MCP Server (OAuth login or personal API key required) **Arguments** @@ -159,7 +159,7 @@ Get Workflow MCP Server (personal API key required) sim workflow-mcp-servers list [options] ``` -List Workflow MCP Servers (personal API key required) +List Workflow MCP Servers (OAuth login or personal API key required) **Options** @@ -179,7 +179,7 @@ List Workflow MCP Servers (personal API key required) sim workflow-mcp-servers update [options] ``` -Update Workflow MCP Server (personal API key required) +Update Workflow MCP Server (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 7169b5b2d58..eb434be0dcb 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflows activate create [options] ``` -Activate Workflow Version (personal API key required) +Activate Workflow Version (OAuth login or personal API key required) **Arguments** @@ -44,8 +44,6 @@ Activate Workflow Version (personal API key required) sim workflows operations apply [options] ``` -Apply Workflow Operations (personal API key required) - **Arguments** @@ -371,7 +369,7 @@ sim workflows delete [options] sim workflows chat unpublish [options] ``` -Take a workflow’s chat deployment offline (personal API key required) +Take a workflow’s chat deployment offline (OAuth login or personal API key required) **Arguments** @@ -399,7 +397,7 @@ Take a workflow’s chat deployment offline (personal API key required) sim workflows chat status ``` -Show a workflow’s chat deployment (personal API key required) +Show a workflow’s chat deployment (OAuth login or personal API key required) **Arguments** @@ -417,8 +415,6 @@ Show a workflow’s chat deployment (personal API key required) sim workflows chat publish [options] ``` -Publish or replace a workflow’s chat deployment (personal API key required) - **Arguments** @@ -457,7 +453,7 @@ Publish or replace a workflow’s chat deployment (personal API key required) sim workflows deploy [options] ``` -Deploy Workflow (personal API key required) +Deploy Workflow (OAuth login or personal API key required) **Arguments** @@ -602,7 +598,7 @@ sim workflows deployment status sim workflows deployment update [options] ``` -Update Workflow Public API Access (personal API key required) +Update Workflow Public API Access (OAuth login or personal API key required) **Arguments** @@ -646,8 +642,6 @@ sim workflows state get sim workflows state replace [options] ``` -Replace Workflow State (personal API key required) - **Arguments** @@ -827,7 +821,7 @@ sim workflows restore sim workflows revert create [options] ``` -Revert Workflow To Version (personal API key required) +Revert Workflow To Version (OAuth login or personal API key required) **Arguments** @@ -856,7 +850,7 @@ Revert Workflow To Version (personal API key required) sim workflows rollback [options] ``` -Rollback Workflow (personal API key required) +Rollback Workflow (OAuth login or personal API key required) **Arguments** @@ -885,7 +879,7 @@ Rollback Workflow (personal API key required) sim workflows undeploy [options] ``` -Take a workflow out of deployment (personal API key required) +Take a workflow out of deployment (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx index bd809138a9e..a5bb6e472a3 100644 --- a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx +++ b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx @@ -71,7 +71,7 @@ See [Sandboxes](/platform/self-hosting/sandboxes) for the provider credentials, ## Schedule the background jobs -Two enterprise features are started by a cron-driven HTTP endpoint rather than by the app on its own schedule. What happens next differs: retention's cleanup runs inline in the app process, while each due data drain is handed to a background job. All four endpoints authenticate with a bearer token equal to `CRON_SECRET`, and all four return `401` when it is unset: +Data drains and retention are started by cron-driven HTTP endpoints rather than by the app on their own schedules. What happens next differs: retention's cleanup runs inline in the app process, while each due data drain is handed to a background job. Every endpoint authenticates with a bearer token equal to `CRON_SECRET` and returns `401` when it is unset: ```bash openssl rand -hex 32 @@ -85,9 +85,12 @@ Persist that value as `CRON_SECRET` on the app **and** on whatever calls these e | Retention — logs | `GET /api/logs/cleanup` | Daily | **No** — schedule it yourself | | Retention — soft deletes | `GET /api/cron/cleanup-soft-deletes` | Daily | **No** — schedule it yourself | | Retention — Chat tasks | `GET /api/cron/cleanup-tasks` | Daily | **No** — schedule it yourself | +| OAuth token cleanup | `GET /api/cron/cleanup-oauth-tokens` | Hourly | Yes — Helm and Docker Compose both call it | - Both shipped deployments schedule the data-drain dispatcher but **not** the three retention cleanup endpoints — neither the Helm chart nor Docker Compose's `cron` service. Setting `DATA_RETENTION_ENABLED=true` alone deletes nothing — the windows are evaluated only when one of those endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler. + Both shipped deployments schedule the data-drain dispatcher and OAuth token cleanup, but **not** the three configurable data-retention endpoints. Setting `DATA_RETENTION_ENABLED=true` alone deletes no retained product data — those windows are evaluated only when one of the three endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler. + + OAuth token cleanup continues after `OAUTH_PROVIDER_ENABLED=false` so rows created while the provider was enabled do not become permanent. ```bash diff --git a/apps/docs/content/docs/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/platform/self-hosting/authentication.mdx index 7af562b6bc2..f877776eaac 100644 --- a/apps/docs/content/docs/platform/self-hosting/authentication.mdx +++ b/apps/docs/content/docs/platform/self-hosting/authentication.mdx @@ -79,6 +79,56 @@ Providers are then registered in the app under **Settings → Organization → S See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for the organization patterns. +## Sign in with Sim + +Your deployment can act as an OAuth 2.0 authorization server using authorization +code with PKCE and current OAuth security guidance. The Sim CLI uses it when +enabled; see [CLI authentication](/cli/authentication). + +Use a two-phase rollout: apply the database migration while this flag is unset, +deploy and drain every older app instance, then enable it in a separate config +rollout: + +```bash +OAUTH_PROVIDER_ENABLED=true +``` + +With it unset or false, the discovery document at `/.well-known/oauth-authorization-server` +returns 404 and the CLI falls back to the pairing-code handoff on its own. +`DISABLE_AUTH=true` also forces the provider off because the authorization flow +requires a real Better Auth user session. + +Access tokens are opaque and last an hour; refresh tokens rotate on every use +and expire after thirty days. Nothing is cached, so revoking a grant under +**Settings → Authorized apps** stops the app on its very next request. + +### Registering an app + +Dynamic client registration is switched off, so clients are created by an +operator. The Sim CLI is seeded by the migration; register anything else with: + +```bash +DATABASE_URL=… \ +BETTER_AUTH_SECRET=… \ +OAUTH_CLIENT_ID=my-app \ +OAUTH_CLIENT_NAME="My App" \ +OAUTH_REDIRECT_URIS=https://my-app.example/callback \ +OAUTH_SCOPES=api:read \ +bun run apps/sim/scripts/create-oauth-client.ts +``` + +Add `OAUTH_CLIENT_PUBLIC=true` for a native or CLI app that cannot keep a +secret; it then authenticates with PKCE alone. The provider exposes +`offline_access`, `api:read`, and `api:write` for API authorization; it does not +expose OpenID Connect identity scopes or issue ID tokens. A confidential +client's secret is printed once and cannot be read back. Confidential clients +use `client_secret_basic`; the registration command prints the token +authentication method alongside the client ID. + +Redirect URIs must be `https`, or `http` on a loopback address, and are matched +exactly — except a loopback URI, where any port matches, because a native app +cannot know its port in advance. + ## Controlling who can sign up | Variable | Effect | diff --git a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx index e5aa393b91c..c5b962f6fab 100644 --- a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx +++ b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx @@ -51,6 +51,7 @@ Point cron at an **internal** address where possible (the in-cluster Service, or | Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member access sync for permission-aware connectors | | Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers | | Table row TTL cleanup | `/api/cron/cleanup-table-row-ttl` | `*/15 * * * *` | Deletes table rows whose TTL column has expired | +| OAuth token cleanup | `/api/cron/cleanup-oauth-tokens` | `0 * * * *` | Deletes access and refresh tokens after the retention tail | | Data drains | `/api/cron/run-data-drains` | `0 * * * *` | Enterprise data drains | | Renew subscriptions | `/api/cron/renew-subscriptions` | `0 */12 * * *` | Renews Microsoft Teams chat subscriptions (Graph caps them at ~3 days) | | Billing cycle close | `/api/cron/billing-cycle-close` | `0 */6 * * *` | Billing only — final overage collection and per-period tracker reset | diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 8dbcf1ba31a..3530eac0a93 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -123,6 +123,12 @@ import { Callout } from 'fumadocs-ui/components/callout' Google, GitHub, and Microsoft sign-in, their callback URLs, and the `DISABLE_*_AUTH` switches are documented in [Authentication](/platform/self-hosting/authentication#social-login). +## Sign in with Sim + +| Variable | Description | +| --- | --- | +| `OAUTH_PROVIDER_ENABLED` | Set to `true` in a second rollout after the migration is applied and every older app instance is drained. Unset/false uses the CLI pairing-code handoff. `DISABLE_AUTH=true` always forces it off. See [Authentication](/platform/self-hosting/authentication#sign-in-with-sim) | + ## Integration Credentials diff --git a/apps/docs/content/docs/platform/self-hosting/index.mdx b/apps/docs/content/docs/platform/self-hosting/index.mdx index 7cbfc77249b..4b6db96ecd2 100644 --- a/apps/docs/content/docs/platform/self-hosting/index.mdx +++ b/apps/docs/content/docs/platform/self-hosting/index.mdx @@ -68,7 +68,7 @@ Docker Compose and Kubernetes run the same application; what differs is the oper | Capability | Docker Compose | Kubernetes (Helm) | |---|---|---| | App, realtime, migrations, Postgres, Redis | Yes | Yes | -| Scheduled workflows and polling triggers | Yes — `cron` service | Yes — 22 CronJobs | +| Scheduled workflows and polling triggers | Yes — `cron` service | Yes — 23 CronJobs | | Horizontal scaling / HA | No (single node) | Yes (`replicaCount`, HPA, PDB) | | Managed secrets (Vault, ESO, cloud KMS) | Manual `.env` | Yes | | Network policy, Pod Security Standards | Host-level only | Yes | diff --git a/apps/docs/content/docs/platform/self-hosting/kubernetes.mdx b/apps/docs/content/docs/platform/self-hosting/kubernetes.mdx index 29772c1cacc..81b509d8764 100644 --- a/apps/docs/content/docs/platform/self-hosting/kubernetes.mdx +++ b/apps/docs/content/docs/platform/self-hosting/kubernetes.mdx @@ -134,7 +134,7 @@ See `helm/sim/values.yaml` for all options, and the chart's [README](https://git ## Background jobs -The chart deploys 22 CronJobs by default, driving scheduled workflows, polling triggers, connector syncs, data drains, and outbox processing. They require `CRON_SECRET`; set `cronjobs.enabled=false` to deploy none. +The chart deploys 23 CronJobs by default, driving scheduled workflows, polling triggers, connector syncs, OAuth token cleanup, data drains, and outbox processing. They require `CRON_SECRET`; set `cronjobs.enabled=false` to deploy none. ```bash kubectl get cronjobs -n simstudio @@ -191,4 +191,3 @@ helm uninstall sim --namespace simstudio - diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 024a60372e0..c9c9dd1262c 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -131,9 +134,9 @@ "name": "workspaceId", "in": "query", "required": false, - "description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.", + "description": "Narrow the ledger to one workspace. An OAuth token or personal API key reports only its user's events; a workspace API key reports every member's events in its bound workspace. The response `scope` identifies which view was returned. A workspace key asking for another workspace receives the same `404 Workspace not found` as an unknown id.", "schema": { - "description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.", + "description": "Narrow the ledger to one workspace. An OAuth token or personal API key reports only its user's events; a workspace API key reports every member's events in its bound workspace. The response `scope` identifies which view was returned. A workspace key asking for another workspace receives the same `404 Workspace not found` as an unknown id.", "type": "string", "minLength": 1, "maxLength": 128 @@ -254,6 +257,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation." } }, "headers": { @@ -325,7 +334,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -334,7 +343,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -438,6 +447,48 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -453,7 +504,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -728,7 +787,7 @@ "scope": { "type": "string", "enum": ["user", "workspace"], - "description": "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to." + "description": "Whose usage this page reports. `user` contains only the OAuth or personal-key user's events, optionally narrowed by `workspaceId`; it omits other members. `workspace` contains every member's events for the workspace API key's bound workspace." } }, "required": ["data", "nextCursor", "scope"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 2ae1f1de435..38439ca50e9 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -33,6 +33,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -728,7 +731,7 @@ "get": { "operationId": "readFileText", "summary": "Read File Text", - "description": "Return a file's text content, parsed out of the stored bytes. This reads the file; it writes nothing — `POST /api/v2/files/{fileId}/unzip` is the endpoint that unzips an archive into the workspace. Answers `400` for a type no parser supports, naming the raw-bytes download as the escape hatch, and `413` for a file above the extraction ceiling. A generated document is extracted from its compiled artifact rather than its generation source, so one still compiling answers `409` and is worth retrying. **`degraded: true` means text extraction did not fully succeed and the returned text may be incomplete or synthesized from the file's raw bytes. Do not treat it as authoritative content.** The legacy `.doc` and `.ppt` parsers deliberately return best-effort content rather than failing, so this flag — not an error status — is how a partial extraction is reported. `truncated` separately reports that a parser limit stopped extraction early.", + "description": "Extract text from stored file bytes without modifying the file; use `POST /api/v2/files/{fileId}/unzip` to unpack archives. Unsupported types return `400` and point to raw-byte download; generated documents still compiling return `409`, and files above the extraction ceiling return `413`. `degraded: true` means extraction was incomplete or synthesized from raw bytes and is not authoritative; legacy `.doc` and `.ppt` extraction may return this best-effort result. `truncated` means a parser limit stopped extraction.", "tags": ["Files"], "parameters": [ { @@ -849,7 +852,7 @@ "get": { "operationId": "bulkDownloadFiles", "summary": "Bulk Download Files", - "description": "Stream a selection of workspace files as one zip. Select files by id and folders by path, each as one comma-separated parameter; a folder expands to all its descendants, and a path matching no folder is rejected rather than ignored. Each parameter accepts at most 100 entries — the same ceiling the resolved selection is held to — and the resolved file count and total bytes are checked again, so an over-broad selection answers `400` rather than streaming indefinitely. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "description": "Stream files as a zip. Provide comma-separated file IDs and folder paths; folders expand recursively, and unmatched paths are rejected. Each parameter and the resolved selection allow at most 100 entries, with bytes bounded. Oversized selections return `400`; downloads record an audit event. `HEAD` skips the effect but uses `GET` authorization, returning the corresponding `400`, `401`, `403`, or `404`, or an empty `200` with no payload headers; it confirms access only. `HEAD` omits `Content-Length`; use file metadata to size downloads.", "tags": ["Files"], "parameters": [ { @@ -945,7 +948,7 @@ "post": { "operationId": "unzipFile", "summary": "Unzip File", - "description": "Unzip a `.zip` archive into a new folder beside it and answer counts plus the destination path. This writes new workspace files; it does not read anything out of the archive into the response — `GET /api/v2/files/{fileId}/text` is the endpoint that returns a file's text. The unpacked files are deliberately not returned — a large archive would materialize thousands of objects into one response — so page `GET /api/v2/files?folderPath=...` for the contents. Unzipping is slow: an archive near the size ceiling can run for minutes. Only one unzip of a given archive runs at a time; a concurrent attempt answers `409`. Archives past the size ceiling, and runs that outrun their time budget, answer `413`.", + "description": "Unzip a `.zip` archive into a new sibling folder, creating workspace files and returning only counts and the destination path. Use `GET /api/v2/files/{fileId}/text` to read text; page `GET /api/v2/files?folderPath=...` to inspect unpacked files. Large archives can take minutes. Only one unzip per archive may run; concurrent attempts return `409`. Archives above the size ceiling or operations exceeding their time budget return `413`.", "tags": ["Files"], "parameters": [ { @@ -1032,7 +1035,7 @@ "get": { "operationId": "downloadFile", "summary": "Download File", - "description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers `409` while that artifact is still compiling and `413` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` skips the effect but uses `GET` authorization, returning the corresponding `400`, `401`, `403`, or `404`, or an empty `200` with no payload headers; it confirms access only. `HEAD` omits `Content-Length`; use file metadata to size downloads.", "tags": ["Files"], "parameters": [ { @@ -1467,7 +1470,7 @@ "get": { "operationId": "listAuditLogs", "summary": "List Audit Logs", - "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Audit Logs"], "parameters": [ { @@ -1641,7 +1644,7 @@ "get": { "operationId": "getAuditLog", "summary": "Get Audit Log", - "description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Audit Logs"], "parameters": [ { @@ -1866,7 +1869,7 @@ "patch": { "operationId": "upsertFileShare", "summary": "Enable or Disable File Share", - "description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. Enabling any mode other than `public` on a file that has never been shared must carry its credential in the same request. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. Enabling any mode other than `public` on a file that has never been shared must carry its credential in the same request. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Files"], "parameters": [ { @@ -1950,7 +1953,7 @@ "patch": { "operationId": "editFileContent", "summary": "Edit File Content", - "description": "Change part of a text file in place, leaving the rest untouched. `PUT` on this path replaces the whole file; this is the partial counterpart. `search_replace` matches exact text and requires one match unless `replaceAll` is true. `replace_between`, `insert_after`, and `delete_between` match complete lines after trimming surrounding whitespace, so edits remain stable when unrelated changes move the target to another line. Anchored replacement preserves both boundary lines; insertion preserves its anchor; deletion removes the start anchor and preserves the end anchor. Use `occurrence` when an anchor line repeats. Only files whose stored bytes are UTF-8 text can be edited: a PDF or DOCX answers `400`. A concurrent write answers `409`, and retrying means re-reading first.", + "description": "Modify part of a text file; `PUT` on this path replaces the whole file. `search_replace` requires one exact match unless `replaceAll` is true. The anchored modes match trimmed complete lines: replacement preserves both boundaries, insertion preserves its anchor, and deletion removes the start but preserves the end. Use `occurrence` for repeated anchors. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.", "tags": ["Files"], "parameters": [ { @@ -2122,7 +2125,7 @@ "get": { "operationId": "searchFileContent", "summary": "Search File Content", - "description": "Search the indexed text of active workspace files and return each matching line with its file id and line number. `folderPaths` confines the search to one or more folder trees, which also narrows the reported coverage, so `complete` and `indexStatus` describe the folders searched rather than the whole workspace. Coverage matters: the index is built asynchronously, so when `complete` is `false` a term that was not found is **unknown rather than absent**, and acting on the absence risks creating a duplicate of something already stored. `truncated` separately reports that more matches exist beyond `maxResults`.", + "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and the coverage reported by `complete` and `indexStatus`. Because indexing is asynchronous, a missing term is unknown rather than absent when `complete` is false. `truncated` means additional matches exist beyond `maxResults`.", "tags": ["Files"], "parameters": [ { @@ -2809,6 +2812,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation." } }, "headers": { @@ -2905,7 +2914,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -2914,7 +2923,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -3082,6 +3091,48 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -3097,7 +3148,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -3414,7 +3473,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." }, "headers": { "type": "object", @@ -3568,7 +3627,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." }, "headers": { "type": "object", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 999ba3d8732..2776ed95db9 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -493,7 +496,7 @@ "get": { "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. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List external sources connected to a knowledge base with opaque cursor pagination. Stored API keys and encrypted secret material are never returned. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -616,7 +619,7 @@ "post": { "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. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Validate and connect an external source, then queue its initial synchronization. The apiKey field is write-only and is never returned. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -701,7 +704,7 @@ "get": { "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. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Retrieve one connector and its ten most recent synchronization attempts. Stored API keys and encrypted secret material are never returned. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -787,7 +790,7 @@ "patch": { "operationId": "updateKnowledgeConnector", "summary": "Update Knowledge Connector", - "description": "Update connector source configuration, schedule, or active state. Replacing source configuration on a runnable connector queues an immediate synchronization; paused connectors retain the change without synchronizing until resumed. Source configuration cannot be replaced while synchronization is already in progress. Authentication material cannot be changed through this operation. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update connector source configuration, schedule, or active state. Replacing source configuration on a runnable connector queues an immediate synchronization; paused connectors retain the change without synchronizing until resumed. Source configuration cannot be replaced while synchronization is already in progress. Authentication material cannot be changed through this operation. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -881,7 +884,7 @@ "delete": { "operationId": "deleteKnowledgeConnector", "summary": "Delete Knowledge Connector", - "description": "Delete a connector and optionally its synchronized documents. Documents are retained by default. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a connector and optionally its synchronized documents. Documents are retained by default. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -979,7 +982,7 @@ "post": { "operationId": "syncKnowledgeConnector", "summary": "Sync Knowledge Connector", - "description": "Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1075,7 +1078,7 @@ "get": { "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. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1195,7 +1198,7 @@ "patch": { "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. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1434,7 +1437,7 @@ "post": { "operationId": "createKnowledgeTag", "summary": "Create Tag", - "description": "Define one tag on a knowledge base; use `PUT` on this path to declare several at once. Define a tag here, write its `tagSlot` on a document with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`, then filter by its `displayName` on the document list or on search. Omit `tagSlot` to take the next free slot for the field type; a field type with no free slot left is a `400` naming it, since the remedy is a different type or a deleted definition rather than a retry. A `tagSlot` already taken, or a `displayName` already defined on this knowledge base, is a `409` naming which of the two to change. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Define one tag; use `PUT` on this path for several. Write its `tagSlot` on documents, then filter by `displayName`. Omitting `tagSlot` selects the next free slot; exhaustion returns `400`. An occupied slot or duplicate display name returns `409` naming the conflict. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1517,7 +1520,7 @@ "put": { "operationId": "bulkSaveKnowledgeTagDefinitions", "summary": "Bulk Save Tag Definitions", - "description": "Declare, in one request, several of the knowledge base's tag definitions. `POST` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in `originalDisplayName`; that is the only form that edits one in place. Without it the entry is a create, and a requested `tagSlot` another name already holds is refused in `errors` — it is neither overwritten nor relocated to a different slot, so an explicitly requested slot always means that slot or an error. A create whose `displayName` already exists is refused in `errors`. Per-definition failures are reported in `errors` and still answer `200`. This writes the vocabulary, not one document's tag values — set those with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Declare multiple tag definitions while leaving unspecified slots unchanged. Updating requires the current name in `originalDisplayName`; otherwise the entry creates a tag. Occupied explicit slots and duplicate display names appear in per-definition `errors`, never overwrite or relocate data, and still return `200`. This writes the vocabulary, not document tag values; set those through the document update endpoint. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1597,7 +1600,7 @@ "delete": { "operationId": "deleteKnowledgeTagDefinitions", "summary": "Delete Tag Definitions", - "description": "Remove tag definitions from the knowledge base. `unused` defaults to `true`, which removes only the definitions no document still carries a value for — the recoverable half, since a definition with nothing behind it can simply be redefined. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. Delete one definition at a time with `DELETE /api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Remove tag definitions. `unused` defaults to `true`, deleting only definitions with no document values, which can be recreated safely. `unused=false` deletes every definition and irreversibly clears its slot from all documents and chunks. Use `DELETE /api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}` to delete one definition. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1851,7 +1854,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeDocuments", "summary": "Bulk Enable or Disable Documents", - "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2521,7 +2524,7 @@ "patch": { "operationId": "updateKnowledgeDocument", "summary": "Update Document", - "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3144,7 +3147,7 @@ "post": { "operationId": "addWorkspaceFilesToKnowledgeBase", "summary": "Index Workspace Files", - "description": "Index files the workspace already stores, without re-uploading their bytes. Each reference is authorized against the file it names, so a reference the caller cannot read, one over the 100 MB document limit, or one whose type is not supported is reported in `failed` while the rest are queued — a partial outcome is a `200`, not a multi-status. A queued document starts in the `pending` processing state; the entries returned here carry only its identity, so read `GET /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` for its current state. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Index stored workspace files without re-uploading bytes. Each reference is authorized independently; unreadable, unsupported, or over-100 MB files appear in `failed` while valid files are queued. This partial outcome returns `200`, not multi-status. Queued documents begin as `pending`; the response carries identities only, so read each document endpoint for current processing state. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3229,7 +3232,7 @@ "get": { "operationId": "listKnowledgeChunks", "summary": "List Chunks", - "description": "List the passages a document was split into, with content search, enabled filtering, sorting, and opaque cursor pagination. Tag values are projected by slot; resolve slots to display names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List the passages a document was split into, with content search, enabled filtering, sorting, and opaque cursor pagination. Tag values are projected by slot; resolve slots to display names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3390,7 +3393,7 @@ "post": { "operationId": "createKnowledgeChunk", "summary": "Create Chunk", - "description": "Append a chunk to a document. The text is embedded before the response returns, so the chunk is searchable immediately, and it inherits the document's tag values and the next `chunkIndex`. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Append a chunk, embedding it before the response so it is immediately searchable. It inherits the document's tags and next `chunkIndex`. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3481,7 +3484,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeChunks", "summary": "Bulk Update Chunks", - "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in `errors` rather than failing the request. `processed` counts the chunks the operation matched, not the chunks it changed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable, disable, or delete multiple chunks in one best-effort request. Unknown chunk IDs appear in `errors` without failing the request; `processed` counts matched chunks, not changes. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3574,7 +3577,7 @@ "get": { "operationId": "getKnowledgeChunk", "summary": "Get Chunk", - "description": "Retrieve one chunk of a document, including the exact text that was embedded. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Retrieve one chunk of a document, including the exact text that was embedded. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3674,7 +3677,7 @@ "patch": { "operationId": "updateKnowledgeChunk", "summary": "Update Chunk", - "description": "Correct a chunk's text or take it out of search. Changing `content` re-embeds the chunk and re-derives the document's token and character counts, so the correction reaches search immediately; disabling keeps the chunk indexed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Correct chunk text or disable it from search. Changing `content` re-embeds immediately and recalculates document token and character counts; disabling retains the index. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3779,7 +3782,7 @@ "delete": { "operationId": "deleteKnowledgeChunk", "summary": "Delete Chunk", - "description": "Permanently remove one chunk and subtract it from the document's counts. Deleting does not renumber the remaining chunks, so `chunkIndex` values stay stable but become non-contiguous. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Permanently remove one chunk and subtract it from document counts. Remaining `chunkIndex` values stay stable and may become non-contiguous. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3881,7 +3884,7 @@ "patch": { "operationId": "updateKnowledgeTag", "summary": "Update Tag", - "description": "Rename a tag, or change the value type stored in its slot. Renaming changes the name filters and document reads use; the slot, and every value in it, is untouched. A tag's slot is fixed for its lifetime and each slot holds one kind of value, so `fieldType` can only change to another type valid for the slot the tag already occupies — anything else is a `400`, and the way to get a tag of that type is to create one. A name another tag on this knowledge base already holds is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Rename a tag or change its slot-compatible `fieldType`. Renaming changes read and filter names without moving the slot or its values. Slots are fixed for a tag's lifetime; an incompatible type returns `400` and requires creating a new tag. A duplicate display name returns `409`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3975,7 +3978,7 @@ "delete": { "operationId": "deleteKnowledgeTag", "summary": "Delete Tag", - "description": "Remove a tag definition and clear its slot across every document and chunk in the knowledge base. Without a definition the slot has no meaning, so leaving the values would strand them under a raw slot name — this is not recoverable. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Remove a tag definition and clear its slot across every document and chunk in the knowledge base. Without a definition the slot has no meaning, so leaving the values would strand them under a raw slot name — this is not recoverable. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -4066,7 +4069,7 @@ "get": { "operationId": "getNextKnowledgeTagSlot", "summary": "Get Next Tag Slot", - "description": "Report which slot a create would take for a field type, and how many are left. Advisory rather than a claim: nothing is reserved, and `POST /api/v2/knowledge/{knowledgeBaseId}/tags` assigns the same slot when `tagSlot` is omitted. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Report which slot a create would take for a field type, and how many are left. Advisory rather than a claim: nothing is reserved, and `POST /api/v2/knowledge/{knowledgeBaseId}/tags` assigns the same slot when `tagSlot` is omitted. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -4155,7 +4158,7 @@ "get": { "operationId": "listKnowledgeTagUsage", "summary": "List Tag Usage", - "description": "Report how many documents and chunks carry a value for each defined tag, so a caller can tell a tag that is actually populated from one that was only declared. The bounded set is returned in one page; `nextCursor` is always null. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Report how many documents and chunks carry a value for each defined tag, so a caller can tell a tag that is actually populated from one that was only declared. The bounded set is returned in one page; `nextCursor` is always null. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -4236,6 +4239,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation." } }, "headers": { @@ -4307,7 +4316,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -4316,7 +4325,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -4484,6 +4493,48 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -4499,7 +4550,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -6135,7 +6194,7 @@ "maximum": 100 }, "tagFilters": { - "description": "Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.", + "description": "Up to 10 filters combined with AND; repeating a tag narrows results. To express OR, run separate searches. Every tag must exist with the same slot and field type in each selected knowledge base or the request is rejected. List valid names with the knowledge-base tag-list operation.", "maxItems": 10, "type": "array", "items": { @@ -6684,7 +6743,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." }, "headers": { "type": "object", @@ -6889,7 +6948,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." }, "headers": { "type": "object", diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 2b65611a2fb..33eba6fe8fc 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -36,7 +39,7 @@ "get": { "operationId": "listLogs", "summary": "List Logs", - "description": "List workflow execution logs for a workspace with filters, selectable detail, sorting by start time, duration, cost, or status, and opaque cursor pagination. Chat and Sim-agent job runs join the sequence with `includeJobRuns=true`, which is accepted only under `sortBy=startedAt` — their cost is stored as a document and their status is not comparable, so they cannot participate in the other orderings. Each item's `files` lists only the files the run itself produced, addressed by `downloadPath`; input attachments a caller supplied are read through the files API instead. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List logs with filters, selectable detail, sorting, and cursor pagination. `includeJobRuns=true` includes chat and Sim-agent jobs only with `sortBy=startedAt`, because other orderings are unsupported. `files` contains only run-produced files; use the files API for input attachments. Expired runs are hard-deleted and simply absent. Retention is 30 days from run start on Free, unbounded on Pro and Team, and configured per organization on Enterprise with an optional workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -65,10 +68,10 @@ "name": "triggers", "in": "query", "required": false, - "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries.", + "description": "Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries.", "schema": { "type": "string", - "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries." + "description": "Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries." } }, { @@ -244,9 +247,9 @@ "name": "includeJobRuns", "in": "query", "required": false, - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: \"job\"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`.", "schema": { - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: \"job\"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`.", "type": "boolean" } }, @@ -351,7 +354,7 @@ "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none. A workspace folder tree over 10,000 folders is a `413`. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", + "description": "Retrieve a run's workflow snapshot, trace spans, final output, and cost. Trace spans have separate retention, so an empty `traceSpans` array does not prove none were recorded. Expired runs are hard-deleted and simply absent. Retention is 30 days from run start on Free, unbounded on Pro and Team, and configured per organization on Enterprise with an optional workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -421,7 +424,7 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Return workspace/workflow counts, success, errors, and latency. Defaults span runs, or 24 hours if empty; supplied bounds stay exact. Buckets are one-minute minimum and may pass the end. Folders include descendants; `workflowsTruncated` marks capped series, totals include all. Expired runs are hard-deleted and simply absent. Retention is 30 days from run start on Free, unbounded on Pro and Team, and configured per organization on Enterprise with an optional workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -572,6 +575,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation." } }, "headers": { @@ -643,7 +652,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -652,7 +661,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -772,6 +781,48 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -787,7 +838,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -1795,7 +1854,7 @@ }, "required": ["start", "end"], "additionalProperties": false, - "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." + "description": "Actual bucket window. Supplied bounds are exact. Without `startDate`, the left edge is the oldest match, or 24 hours before the right edge when no run matches. Without `endDate`, the right edge is at least now. `startDate` alone spans through now." }, "segmentMs": { "type": "number", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index d6556294305..0ba84c82c07 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -23,7 +23,7 @@ "tags": [ { "name": "Meta", - "description": "Discover what the calling API key can reach." + "description": "Discover what the calling API credential can reach." }, { "name": "Workspaces", @@ -61,6 +61,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -68,7 +71,7 @@ "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.", + "description": "List active workspaces available to the calling credential with opaque cursor pagination. A personal API key or OAuth token sees accessible workspaces that permit user-held API credentials; a workspace API key sees only its bound workspace.", "tags": ["Workspaces"], "parameters": [ { @@ -122,7 +125,7 @@ ], "responses": { "200": { - "description": "Public metadata for workspaces available to the API key.", + "description": "Public metadata for workspaces available to the credential.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -754,7 +757,7 @@ "get": { "operationId": "listMcpServerTools", "summary": "List MCP Server Tools", - "description": "Connect to a registered MCP server and return the tools it exposes. This read has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh`. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. Discovery is bounded at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page; `nextCursor` is always null. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Return up to 1,000 tools and 5 MB with `nextCursor: null`, opening a connection and updating connection metadata. `HEAD` skips the effect but uses `GET` authorization, returning the corresponding `400`, `401`, `403`, or `404`, or an empty `200` with no payload headers; it confirms access only. Unavailable servers return `503`; invalid OAuth returns `409` with `error.details.code: MCP_SERVER_REAUTHORIZATION_REQUIRED` and requires human reauthorization. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -968,7 +971,7 @@ "post": { "operationId": "createSkill", "summary": "Create Skill", - "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Skills"], "requestBody": { "required": true, @@ -1115,7 +1118,7 @@ "patch": { "operationId": "updateSkill", "summary": "Update Skill", - "description": "Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Skills"], "parameters": [ { @@ -1198,7 +1201,7 @@ "delete": { "operationId": "deleteSkill", "summary": "Delete Skill", - "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Skills"], "parameters": [ { @@ -1398,7 +1401,7 @@ "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.", + "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 or an appropriately scoped OAuth token.", "tags": ["Skills"], "parameters": [ { @@ -1499,7 +1502,7 @@ "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.", + "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 or an appropriately scoped OAuth token.", "tags": ["Skills"], "parameters": [ { @@ -2143,7 +2146,7 @@ "post": { "operationId": "createSandbox", "summary": "Create Sandbox", - "description": "Create a sandbox. The name must be unique within the workspace. Where the deployment prebuilds dependency images, the build is scheduled and reported through `buildStatus`; a deployment that installs at run time, or a sandbox with nothing to install, has no build and reports `buildStatus: null`. A dependency or system-package entry the builder cannot accept is a `400` whose `error.details` names the field and the offending entries. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create a uniquely named sandbox. Prebuild deployments schedule an image build reported by `buildStatus`; runtime-install deployments or empty specs report `buildStatus: null`. Invalid dependency or system-package entries return `400` with field details. Requires a workspace admin on Max or Enterprise; lower plans return `403` with `error.details.code: WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates share a write budget; bursts return `429` with `Retry-After`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Sandboxes"], "requestBody": { "required": true, @@ -2290,7 +2293,7 @@ "patch": { "operationId": "updateSandbox", "summary": "Update Sandbox", - "description": "Update the supplied sandbox fields. Omitted fields retain their stored values; a supplied list replaces the whole list; names must remain unique within the workspace. Where the deployment prebuilds dependency images, a changed spec is rebuilt and re-sending an unchanged spec after a failed build retries it; a deployment that installs at run time, or a spec with nothing to install, has no build and reports `buildStatus: null`. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update supplied fields; omissions preserve values, lists replace whole lists, and names remain unique. Prebuild deployments rebuild changed specs, while resending an unchanged failed spec retries it; runtime-install or empty specs report `buildStatus: null`. Requires a workspace admin on Max or Enterprise; lower plans return `403` with `error.details.code: WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates share a write budget; bursts return `429` with `Retry-After`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Sandboxes"], "parameters": [ { @@ -2373,7 +2376,7 @@ "delete": { "operationId": "deleteSandbox", "summary": "Delete Sandbox", - "description": "Delete a sandbox. Function blocks that still select it fail closed at run time until they are re-pointed. Where the deployment prebuilds dependency images, the sandbox's image is released once nothing else shares it; a runtime-install deployment, or a spec with nothing to install, had no image and nothing is released. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a sandbox. Function blocks still selecting it fail closed until reconfigured. A prebuilt image is released when no sandbox shares it; runtime-install and empty specs have no image to release. Requires a workspace admin on Max or Enterprise; lower plans return `403` with `error.details.code: WORKSPACE_PLAN_CAPABILITY_REQUIRED`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Sandboxes"], "parameters": [ { @@ -2596,7 +2599,7 @@ "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, 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.", + "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 or an appropriately scoped OAuth token.", "tags": ["Credentials"], "requestBody": { "required": true, @@ -2767,7 +2770,7 @@ "post": { "operationId": "createCredentialConnection", "summary": "Create Credential Connection", - "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL, sign in as the authenticated user, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Credentials"], "requestBody": { "required": true, @@ -2839,7 +2842,7 @@ "delete": { "operationId": "deleteCredential", "summary": "Disconnect Credential", - "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Credentials"], "parameters": [ { @@ -2915,7 +2918,7 @@ "patch": { "operationId": "updateCredential", "summary": "Update Credential", - "description": "Rotate a service-account credential's secret material, or rename it. Send only the fields to change: an omitted field is left unchanged, and `description: null` clears the stored description. Secret fields are write-only and are never returned, and only a service-account credential has any: sending one for a credential of another type answers `400` rather than dropping it. The provider re-verifies replacement secret material before it replaces the stored secret, so a rejected secret leaves the stored one untouched and answers `400` with the provider's code in `error.details.providerErrorCode`; a provider that cannot be reached answers `503`. The credential ID is preserved, so every workflow, deployment, paused run, knowledge connector, and webhook that references it keeps working — which disconnecting and re-creating does not. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Rename a service-account credential or rotate its write-only secret fields. Omissions preserve values; `description: null` clears the description. Secret fields sent for another credential type return `400`. The provider verifies replacements before storage: rejection leaves the old secret intact and returns `400` with `providerErrorCode`; provider outages return `503`. The preserved credential ID keeps all references working. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Credentials"], "parameters": [ { @@ -3013,7 +3016,7 @@ "get": { "operationId": "listSecrets", "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Secrets"], "parameters": [ { @@ -3150,7 +3153,7 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or personal secret. Values are encrypted at rest, write-only, and never returned. For an existing workspace secret, omit `value` to update only `description` or `unredacted`; the value remains untouched. This metadata-only form cannot create a secret and returns `404` when absent. Personal secrets always require `value`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Secrets"], "parameters": [ { @@ -3253,7 +3256,7 @@ "delete": { "operationId": "deleteSecret", "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Secrets"], "parameters": [ { @@ -3343,11 +3346,11 @@ "get": { "operationId": "getApiMeta", "summary": "Get API Capabilities", - "description": "Report whether v2 is available, whether the calling API key is personal or workspace-scoped, and when it expires. Requires a valid key.", + "description": "Report whether v2 is available, what kind of API credential is calling, and when it expires. Requires a valid API key or OAuth access token.", "tags": ["Meta"], "responses": { "200": { - "description": "Availability and lifecycle facts about the calling key.", + "description": "Availability and lifecycle facts about the calling credential.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3389,7 +3392,7 @@ "get": { "operationId": "listWorkflowMcpServers", "summary": "List Workflow MCP Servers", - "description": "List the MCP servers a workspace *publishes*. These serve deployed workflows as tools to outside MCP clients, which is the opposite direction from `GET /api/v2/mcp-servers` — that lists external servers Sim calls. Each entry carries the endpoint clients connect to and the tool names it exposes; those names are gathered under a 2,000-tool budget shared across the page, so on a page of unusually large servers the trailing entries can list fewer names than they publish. Read one server's full inventory with `GET /api/v2/workflow-mcp-servers/{serverId}/tools`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List servers that publish deployed workflows to outside MCP clients; `GET /api/v2/mcp-servers` instead lists external servers Sim calls. Entries include client endpoints and tool names. A page shares a 2,000-name budget, so trailing servers may show partial inventories; read a server's tools endpoint for its full set. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -3501,7 +3504,7 @@ "post": { "operationId": "createWorkflowMcpServer", "summary": "Create Workflow MCP Server", - "description": "Publish a new MCP server for a workspace, optionally seeding it with workflows to expose as tools. Every workflow named in `workflowIds` must already be deployed. Setting `isPublic` lets any MCP client holding the server URL execute the workflows it publishes without a Sim API key. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Publish a new MCP server for a workspace, optionally seeding it with workflows to expose as tools. Every workflow named in `workflowIds` must already be deployed. Setting `isPublic` lets any MCP client holding the server URL execute the workflows it publishes without a Sim API key. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -3573,7 +3576,7 @@ "get": { "operationId": "getWorkflowMcpServer", "summary": "Get Workflow MCP Server", - "description": "Read one published MCP server. The list is the only other place this state is published, so a caller holding a server id would otherwise have to page the collection and filter client-side. The tools it publishes are on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Read one published MCP server. The list is the only other place this state is published, so a caller holding a server id would otherwise have to page the collection and filter client-side. The tools it publishes are on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -3636,7 +3639,7 @@ "patch": { "operationId": "updateWorkflowMcpServer", "summary": "Update Workflow MCP Server", - "description": "Rename, re-describe, or change the public visibility of a published MCP server. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the description. Publishing and unpublishing the workflows it serves are separate operations on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Rename, re-describe, or change the public visibility of a published MCP server. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the description. Publishing and unpublishing the workflows it serves are separate operations on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -3719,7 +3722,7 @@ "delete": { "operationId": "deleteWorkflowMcpServer", "summary": "Delete Workflow MCP Server", - "description": "Unpublish an MCP server. Every tool it served stops answering and connected clients lose the endpoint. The workflows themselves are untouched — their own deployments stay live and executable through the workflow API. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Unpublish an MCP server. Every tool it served stops answering and connected clients lose the endpoint. The workflows themselves are untouched — their own deployments stay live and executable through the workflow API. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -3787,7 +3790,7 @@ "get": { "operationId": "listWorkflowMcpTools", "summary": "List Workflow MCP Tools", - "description": "Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the `workflowId` that `DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}` addresses. Returned in one page rather than paged — so `nextCursor` is always null — and capped at 2,000 tools, which is far above any real server's inventory. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the `workflowId` that `DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}` addresses. Returned in one page rather than paged — so `nextCursor` is always null — and capped at 2,000 tools, which is far above any real server's inventory. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -3850,7 +3853,7 @@ "post": { "operationId": "deployWorkflowMcpTool", "summary": "Publish Workflow As MCP Tool", - "description": "Publish a deployed workflow as a tool on an MCP server. The tool's input schema is generated from the deployed workflow's input format, so the workflow must already be deployed. Idempotent per workflow: a server carries at most one tool per workflow, so a repeat call replaces the existing tool and answers `200` with `updated: true` rather than conflicting. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Publish a deployed workflow as a tool on an MCP server. The tool's input schema is generated from the deployed workflow's input format, so the workflow must already be deployed. Idempotent per workflow: a server carries at most one tool per workflow, so a repeat call replaces the existing tool and answers `200` with `updated: true` rather than conflicting. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -3935,7 +3938,7 @@ "delete": { "operationId": "undeployWorkflowMcpTool", "summary": "Unpublish Workflow MCP Tool", - "description": "Remove a workflow from an MCP server. Addressed by workflow rather than by tool identifier, because a server carries at most one live tool per workflow. The workflow's own deployment is untouched. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Remove a workflow from an MCP server. Addressed by workflow rather than by tool identifier, because a server carries at most one live tool per workflow. The workflow's own deployment is untouched. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["MCP Servers"], "parameters": [ { @@ -4478,7 +4481,7 @@ "post": { "operationId": "executeTool", "summary": "Run Tool", - "description": "Run one built-in tool and return what it produced. Supply `input` using the parameter ids `GET /api/v2/tools/{toolId}` publishes; Sim resolves the credential named by `credentialId`, injects a hosted API key for the tools it supplies one for, and substitutes environment-variable references, so the request carries arguments rather than secrets. A parameter the tool marks `user-only` also accepts `{{VAR_NAME}}` as its whole value, resolved server-side against the workspace environment; every other value is sent verbatim, so a literal secret passes through untouched. A tool that runs and refuses is a `200` carrying `status: \"failed\"` and the reason — the error envelope is reserved for failures of this API, not of the third party. A tool the workspace's visible blocks do not expose answers `404` identically to one that does not exist; one whose integration the workspace does not permit answers `403` with `error.details.code` `INTEGRATION_NOT_ALLOWED`. Hosted-key spend this call incurs is billed to the workspace. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: \"failed\"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Catalog"], "parameters": [ { @@ -4561,7 +4564,7 @@ "get": { "operationId": "listConnectorTypes", "summary": "List Connector Types", - "description": "List every knowledge-base connector type and the source configuration each accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with `multi: true` stores a `string[]` rather than a `string`, and a `canonicalParamId` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by `canonicalParamId` rather than by the field's own `id`. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List connector types and accepted source configuration. A field with `multi: true` stores `string[]`. `canonicalParamId` links picker and manual fields that write the same key; send exactly one, keyed by `canonicalParamId` rather than its own `id`. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Catalog"], "parameters": [ { @@ -4643,6 +4646,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation." } }, "headers": { @@ -4714,7 +4723,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -4723,7 +4732,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -4875,6 +4884,48 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -4890,7 +4941,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -4988,7 +5047,7 @@ "required": ["data", "nextCursor"], "additionalProperties": false, "title": "List workspaces response", - "description": "Public metadata for workspaces available to the API key.", + "description": "Public metadata for workspaces available to the credential.", "examples": [ { "data": [ @@ -8290,8 +8349,8 @@ }, "keyType": { "type": "string", - "enum": ["personal", "workspace"], - "description": "Whether the calling key carries the full authority of its owner across their workspaces, or is scoped to one workspace." + "enum": ["personal", "workspace", "oauth_access_token"], + "description": "Whether the calling credential is a personal API key carrying the full authority of its owner across their workspaces, a key scoped to one workspace, or an OAuth access token acting for its user within the scopes it was granted." }, "expiresAt": { "anyOf": [ @@ -8304,13 +8363,13 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the calling key expires, or null when it never does." + "description": "ISO 8601 timestamp when the calling credential expires, or null when it does not." } }, "required": ["v2Enabled", "keyType", "expiresAt"], "additionalProperties": false, "title": "API capabilities", - "description": "API availability and lifecycle facts about the calling API key." + "description": "API availability and lifecycle facts about the calling credential." }, "GetApiMetaResponse": { "type": "object", @@ -8323,7 +8382,7 @@ "required": ["data"], "additionalProperties": false, "title": "API capabilities response", - "description": "API availability, key type, and expiry for the calling key.", + "description": "API availability, credential type, and expiry for the caller.", "examples": [ { "data": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index bb44fceaef5..42b4fea7be9 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -409,7 +412,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. When at least one field landed before the failure the error body carries `details.applied` naming those fields — retry with only the ones missing from it. Its absence means nothing was applied.\n\nA workspace folder tree over 10,000 folders is a `413`.", + "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags are read-only.\n\nNOT atomic: fields are written independently, so a 4xx may follow a partial update. When fields were applied, `details.applied` names them; retry only the missing fields. If it is absent, nothing changed.\n\nA workspace folder tree over 10,000 folders is a `413`.", "tags": ["Tables"], "parameters": [ { @@ -1469,7 +1472,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`. Set `includeRunState: true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set. Row totals live on the companion `POST /api/v2/tables/{tableId}/query/count`, which is a separate snapshot — a caller needing a consistent pair should take the count first and treat it as a floor.", + "description": "Query rows using an optional typed condition or `all`/`any` group, ordered sorting, and opaque cursor pagination. Omit the predicate to match all rows. Pages default to a 5 MB cap and may return fewer rows than requested; continue until `nextCursor` is null. Oversized predicates return `413`. `includeRunState` adds per-group outcomes and lowers the row cap. Counts come from a separate snapshot at `POST /query/count`; take the count first and treat it as a floor.", "tags": ["Tables"], "parameters": [ { @@ -2736,7 +2739,7 @@ "post": { "operationId": "searchTableRows", "summary": "Search Rows", - "description": "Text-search every cell case-insensitively for the substring `q`, optionally within a predicate-filtered and sorted view. This is TEXT search, not the structured predicate read: `POST /api/v2/tables/{tableId}/query` is that one, and on this surface `query` always means a structured predicate while `search` always means text.\n\nIt returns cell COORDINATES — `{ ordinal, rowId, column }` — and never row data. `ordinal` is the row's zero-based index in the same filtered, sorted view `POST /query` pages, so read the rows themselves through that. The result is uncursored and capped: at most 1000 matches come back and `truncated` is `true` when more matched than were returned. There is no cursor to page with — narrow `q` or the predicate instead.", + "description": "Search every cell case-insensitively for substring `q`, optionally within a predicate-filtered, sorted view. This is text search; `POST /query` performs structured predicate reads. Results are cell coordinates `{ ordinal, rowId, column }`, never row data; `ordinal` indexes the same view paged by `POST /query`. Results are uncursored and capped at 1000; `truncated` signals more matches. Narrow `q` or the predicate instead of paging.", "tags": ["Tables"], "parameters": [ { @@ -4070,7 +4073,7 @@ "post": { "operationId": "restoreTablesFolder", "summary": "Restore Folder", - "description": "Un-archive a table folder a recursive `DELETE` archived, along with every subfolder and table archived with it. Address it by the path it held when it was deleted. The restore may legally land it elsewhere: a folder whose parent is still archived is re-rooted to `/`, and a name an active sibling has taken meanwhile is deduplicated — so read the returned folder's `path` rather than assuming the requested one. A path that is not archived answers `404`. `DELETE /api/v2/tables/folders` returns the path it archived, which is the value to keep and send here; unlike the files surface, `GET /api/v2/tables/folders` does not yet list archived folders, so a caller that discards that path cannot recover it over the API.", + "description": "Restore a recursively archived table folder with its subfolders and tables, addressed by its former path. If its parent remains archived, it is re-rooted to `/`; active-name conflicts are deduplicated, so use the returned `path`. Non-archived paths return `404`. Preserve the path returned by `DELETE /api/v2/tables/folders`: unlike the files API, the table-folder list cannot discover archived paths.", "tags": ["Tables"], "requestBody": { "required": true, @@ -4486,7 +4489,7 @@ "post": { "operationId": "moveTables", "summary": "Move Tables and Folders", - "description": "Move up to 100 tables and table folders into one destination folder in a single authorized request. Folders are named by canonical path, and `null` or `/` moves to the workspace root. Best-effort per item: a table filed inside a selected folder is reported in `skipped` because the folder already carries it, an entry that resolves to nothing lands in `notFound`, and an item refused by a lock or a folder cycle lands in `failed` with a reason. An invalid destination fails the whole request before anything moves.", + "description": "Move up to 100 tables and canonical-path folders to one destination; `null` or `/` means the workspace root. Processing is best-effort per item: tables already carried by selected folders are `skipped`, missing items are `notFound`, and lock or cycle refusals are `failed` with reasons. An invalid destination rejects the entire request before any move.", "tags": ["Tables"], "requestBody": { "required": true, @@ -4631,6 +4634,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation." } }, "headers": { @@ -4702,7 +4711,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -4711,7 +4720,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -4882,6 +4891,48 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -4897,7 +4948,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -6039,7 +6098,7 @@ }, "TablePredicate": { "title": "Table predicate", - "description": "Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "description": "Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.", "type": "object", "oneOf": [ { @@ -6453,7 +6512,7 @@ }, "TablePredicateInput": { "title": "Table predicate input", - "description": "A single `{ field, op, value }` condition or a recursive `all`/`any` group; either form is normalized to a grouped predicate after validation. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "description": "One condition or a recursive `all`/`any` group, normalized to a grouped predicate. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.", "oneOf": [ { "type": "object", @@ -8451,7 +8510,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." }, "headers": { "type": "object", @@ -9064,7 +9123,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." }, "headers": { "type": "object", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index fa265533ea7..c1f23f03188 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -33,6 +33,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -274,7 +277,7 @@ "get": { "operationId": "getWorkflowState", "summary": "Get Workflow State", - "description": "Get the editable draft graph of a workflow: blocks, edges, the loop and parallel containers derived from them, and variables. This is the pollable read — it records no audit event, and `HEAD` mirrors `GET`. The payload is **unsanitized**: it carries workspace-scoped `credentialId`, `knowledgeBaseId`, and `tableId` values verbatim, so it is not portable to another workspace. Use `GET /workflows/{workflowId}/export` for a portable, sanitized copy — and note that export is not a read-modify-write source, because sanitizing it drops every credential binding. Unknown members are stripped, so what this returns is exactly the set of keys `PUT /workflows/{workflowId}/state` accepts.", + "description": "Get the editable draft graph: blocks, edges, derived loop and parallel containers, and variables. This pollable read records no audit event, and `HEAD` mirrors `GET`. The unsanitized payload includes workspace-scoped credential, knowledge-base, and table ids, so it is not portable. Use `export` for a sanitized copy, but not for read-modify-write because credential bindings are removed. Returned keys exactly match what `PUT /workflows/{workflowId}/state` accepts.", "tags": ["Workflows"], "parameters": [ { @@ -338,7 +341,7 @@ "put": { "operationId": "replaceWorkflowState", "summary": "Replace Workflow State", - "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with `409` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", + "description": "Atomically replace the editable draft graph. Concurrent writes are row-locked and last-write-wins; no partial state is stored. `loops` and `parallels` are recomputed from `blocks`; omitted `variables` remain unchanged. Foreign ids return `409`. This leaves deployment unchanged and marks the draft for redeployment; lint is advisory. `dryRun=true` runs the same validation, lint, and conflict checks without persistence, audit, or notification; `needsRedeployment` reflects pre-write state. Workspace keys are rejected; use personal keys or OAuth.", "tags": ["Workflows"], "parameters": [ { @@ -437,7 +440,7 @@ "post": { "operationId": "applyWorkflowOperations", "summary": "Apply Workflow Operations", - "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", + "description": "Apply graph edits and optional block enablement in one atomic write. Failed operations appear in `skipped`; `deferred` edges resolve when targets exist and must not be retried. With `atomic`, any skip or dropped input returns `409` with `OPERATIONS_NOT_APPLIED` and persists nothing. Non-UUID labels are minted and same-batch references remapped in `mintedBlockIds`. Lint is advisory. `dryRun=true` runs the same checks without persistence, audit, or notification. This changes only the draft. Workspace keys are rejected; use personal keys or OAuth.", "tags": ["Workflows"], "parameters": [ { @@ -1342,7 +1345,7 @@ "post": { "operationId": "activateWorkflowVersion", "summary": "Activate Workflow Version", - "description": "Promote an existing deployment version to live. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state. Unlike `rollback`, the target is named by the path and the workflow need not already be deployed. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Promote an existing deployment version to live. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state. Unlike `rollback`, the target is named by the path and the workflow need not already be deployed. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -1443,7 +1446,7 @@ "post": { "operationId": "revertWorkflowVersion", "summary": "Revert Workflow To Version", - "description": "Overwrite the editable draft with the graph pinned by a deployment version, discarding every unsaved edit. This is the most destructive operation in the deployment family and it does **not** change what is live — to move production, use `activate` or `rollback`, both of which leave the draft alone. Pass `active` as the version to discard draft edits and return to the live graph. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Overwrite the editable draft with a deployment version, irreversibly discarding unsaved edits. This does not change the live version; use `activate` or `rollback` for production, both of which leave the draft unchanged. Pass `active` to reset the draft to the live graph. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -1552,7 +1555,7 @@ "get": { "operationId": "getWorkflowDeployment", "summary": "Get Workflow Deployment", - "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment` and `isPublicApi`.\n\n`isPublicApi` is the security-relevant one: while it is `true` the deployed workflow executes without an API key, so anyone holding the execution URL can run it — and consume the workspace’s billed usage — anonymously. It is set through `PATCH /workflows/{workflowId}/deployment`, and this read is the only way to audit whether it is on.\n\nNot to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable.", + "description": "Read the live version, latest deployment attempt and readiness, draft drift (`needsRedeployment`), and `isPublicApi`. When `isPublicApi` is true, anyone with the execution URL can run and consume billed usage without an API key; change it with `PATCH /workflows/{workflowId}/deployment`. `/workflows/{workflowId}/deployment` controls overall API executability; `/deployments/chat` controls only the hosted-chat surface. A workflow can remain deployed without a chat.", "tags": ["Workflows"], "parameters": [ { @@ -1616,7 +1619,7 @@ "patch": { "operationId": "updateWorkflowPublicApi", "summary": "Update Workflow Public API Access", - "description": "Enable or disable unauthenticated public execution of the deployed workflow. While enabled, anyone holding the execution URL can run the workflow without an API key. An organization that forbids public sharing refuses this with `403` and `PUBLIC_SHARING_NOT_ALLOWED`. Not to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable or disable unauthenticated public execution of the deployed workflow. While enabled, anyone holding the execution URL can run the workflow without an API key. An organization that forbids public sharing refuses this with `403` and `PUBLIC_SHARING_NOT_ALLOWED`. `/workflows/{workflowId}/deployment` controls overall API executability; `/deployments/chat` controls only the hosted-chat surface. A workflow can remain deployed without a chat. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -1702,7 +1705,7 @@ "post": { "operationId": "deployWorkflow", "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -1789,7 +1792,7 @@ "delete": { "operationId": "undeployWorkflow", "summary": "Undeploy Workflow", - "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -1858,7 +1861,7 @@ "post": { "operationId": "rollbackWorkflow", "summary": "Rollback Workflow", - "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. Use this to step back from the currently live version; to make a specific version live by naming it in the path — including when the workflow is not currently deployed — use `POST /workflows/{workflowId}/versions/{version}/activate`. Neither touches the draft. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. Use this to step back from the currently live version; to make a specific version live by naming it in the path — including when the workflow is not currently deployed — use `POST /workflows/{workflowId}/versions/{version}/activate`. Neither touches the draft. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -1947,7 +1950,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Export a portable, secret-sanitized workflow; workspace-scoped bindings must be selected again after import. Exporting records an audit event. `HEAD` skips the effect but uses `GET` authorization, returning the corresponding `400`, `401`, `403`, or `404`, or an empty `200` with no payload headers; it confirms access only. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -2091,7 +2094,7 @@ "get": { "operationId": "listChatDeployments", "summary": "List Chat Deployments", - "description": "List the workflows a workspace has published as hosted chats. Each entry carries the public `url` a visitor uses — there is no chat subdomain, the identifier is a path segment.\n\nThis is the only chat path not addressed under a workflow, and deliberately so: every chat is a singleton of the workflow it publishes, but \"what does this workspace serve\" is a question no per-workflow path can answer. Filter by `workflowId` to resolve one workflow's chat without holding its id.\n\nEntries are deliberately narrower than the singleton read: `allowedEmails`, `hasPassword`, and `customizations` are available only from `GET /api/v2/workflows/{workflowId}/deployments/chat`, which requires workspace `admin`. That is what keeps this list callable at workspace `read` and by a workspace API key. A stored password is never returned by either.", + "description": "List hosted chats in a workspace with opaque cursor pagination. Filter by `workflowId` to resolve one workflow’s singleton chat. Each item includes its public URL, whose identifier is a path segment, but omits `allowedEmails`, `hasPassword`, and `customizations`; read those through the admin-only singleton endpoint. This list requires workspace read access and accepts workspace API keys. Stored passwords are never returned.", "tags": ["Workflows"], "parameters": [ { @@ -2226,7 +2229,7 @@ "get": { "operationId": "getWorkflowChatDeployment", "summary": "Get Workflow Chat Deployment", - "description": "Read the hosted chat a workflow is published as. Answers `404` when the workflow publishes no chat. Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write. The stored password is never returned — `hasPassword` reports only whether one is set. This carries the visitor gate — `authType`, `hasPassword`, and the `allowedEmails` allow-list — so it requires workspace `admin`, unlike the workspace-wide list. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Read a workflow’s singleton hosted chat, or return `404` when none exists. `/workflows/{workflowId}/deployment` controls API execution; this singleton path controls hosted chat. `PUT` creates or replaces it without a chat-id path. The password is never returned; `hasPassword` reports its presence. Visitor-gate fields (`authType`, `hasPassword`, and `allowedEmails`) require workspace admin access. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -2290,7 +2293,7 @@ "put": { "operationId": "replaceWorkflowChatDeployment", "summary": "Create or Replace Workflow Chat Deployment", - "description": "Publish a workflow as a hosted chat, or replace the chat it already publishes. Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write.\n\n**Replace, not merge.** The chat ends up as exactly what the body describes: an omitted optional field takes its platform default rather than whatever the previous chat carried, so sending the same body twice leaves the same result. `password` is therefore required whenever `authType` is `\"password\"` and rejected otherwise — it is write-only and never readable back, so carrying one over implicitly is the one place a replace would quietly stop meaning replace. `allowedEmails` follows the same rule: required and non-empty for `\"email\"` and `\"sso\"`, rejected for the modes that admit no allow-list. `customizations` is the one documented exception: it merges per field, so an omitted `imageUrl` keeps the stored one rather than clearing it, and customization keys this surface does not declare do not survive the write. That behaviour is shared with the in-app editor and the Copilot deploy tool, which both send partial objects.\n\nThis also deploys the workflow, because a chat serves the live version: a draft that has drifted is republished as part of the call. Two conditions answer `409` — an `identifier` another live chat already holds, and a workflow deployment attempt still preparing, which the caller can retry once it becomes active. `authType: \"public\"` leaves the chat open to anyone holding the URL. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace hosted chat. Omitted fields reset to defaults except per-field `customizations`. `password` is write-only and required for password auth; `allowedEmails` is required and non-empty for email or SSO. This also deploys the draft. A duplicate identifier or pending deployment returns `409`; public auth exposes the URL. `/workflows/{workflowId}/deployment` controls API execution; this singleton path controls hosted chat. `PUT` creates or replaces it without a chat-id path. Workspace keys are rejected; use personal keys or OAuth.", "tags": ["Workflows"], "parameters": [ { @@ -2377,7 +2380,7 @@ "delete": { "operationId": "deleteWorkflowChatDeployment", "summary": "Delete Workflow Chat Deployment", - "description": "Stop serving a workflow's hosted chat. Its URL stops answering and the identifier becomes free again. The workflow's own deployment is untouched and stays executable through the workflow API — to undeploy that, use `DELETE /workflows/{workflowId}/deploy`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Stop serving a workflow's hosted chat. Its URL stops answering and the identifier becomes free again. The workflow's own deployment is untouched and stays executable through the workflow API — to undeploy that, use `DELETE /workflows/{workflowId}/deploy`. A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.", "tags": ["Workflows"], "parameters": [ { @@ -2443,12 +2446,15 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute Workflow", - "description": "Execute the active deployment by default, or select manual execution of the current saved workflow state with `run.source: \"manual\"`. Manual runs require a personal API key with current write access and support synchronous or Server-Sent Event execution only; workspace keys, anonymous public access, and async manual runs are rejected. A manual run can enter through one runnable trigger (including external integration/webhook triggers) or resume at a named block from the exact same-workflow run identified by `sourceRunId`; the server loads that run's persisted snapshot, which is never accepted from the request. Omit a trigger block id only when the saved workflow has exactly one runnable trigger. Public deployed workflows permit anonymous synchronous and streaming execution, while asynchronous deployed execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "description": "Execute the deployment, or use `run.source: \"manual\"` for draft state. Manual runs require a personal key or OAuth write access; workspace keys, anonymous callers, and async mode are rejected. Start at a runnable trigger, or resume from `sourceRunId` using the same-workflow snapshot. Public deployments allow anonymous sync or streaming; async requires credentials. Sync timeouts return `200` with failed status and `TIMEOUT`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "tags": ["Workflows"], "security": [ { "apiKey": [] }, + { + "oauthBearer": [] + }, {} ], "parameters": [ @@ -2597,7 +2603,7 @@ "get": { "operationId": "listWorkflowRunsV2", "summary": "List Workflow Runs", - "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", + "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Expired runs are hard-deleted and simply absent. Retention is 30 days from run start on Free, unbounded on Pro and Team, and configured per organization on Enterprise with an optional workspace override.", "tags": ["Workflow Runs"], "parameters": [ { @@ -2745,7 +2751,7 @@ "get": { "operationId": "getWorkflowRunV2", "summary": "Get Workflow Run", - "description": "Get current workflow run state, optionally including final and block outputs. With `includeOutput`, `files` lists the files the run produced, each with a `downloadPath`; add `includeFileBase64` to inline their bytes, which answers `413` naming the download path when a single file, or the run's inlined total, exceeds the 16 MiB ceiling. Because inlining reads object storage, this `GET` is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return.", + "description": "Get current run state with optional final and block outputs. With `includeOutput`, `files` includes download paths; `includeFileBase64` reads object storage to inline bytes and returns `413` with the download path when one file or the total exceeds 16 MiB. `HEAD` skips the effect but uses `GET` authorization, returning the corresponding `400`, `401`, `403`, or `404`, or an empty `200` with no payload headers; it confirms access only.", "tags": ["Workflow Runs"], "parameters": [ { @@ -2872,7 +2878,7 @@ "get": { "operationId": "downloadWorkflowRunFileV2", "summary": "Download Workflow Run File", - "description": "Download one file a run produced. The run resource reports the files a run emitted; address one of them by its `id` here. Run output carries `/api/files/serve/...` URLs that reject API keys, so this is the byte path out of a run for an API-key caller. Execution objects are not retained indefinitely, so a `404` for a file an older run produced is expected rather than a fault. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "description": "Download one run-produced file by id. Downloads record an audit event. Expired runs are hard-deleted and simply absent. Retention is 30 days from run start on Free, unbounded on Pro and Team, and configured per organization on Enterprise with an optional workspace override. `HEAD` skips the effect but uses `GET` authorization, returning the corresponding `400`, `401`, `403`, or `404`, or an empty `200` with no payload headers; it confirms access only. `HEAD` omits `Content-Length`; use file metadata to size downloads.", "tags": ["Workflow Runs"], "parameters": [ { @@ -3560,6 +3566,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation." } }, "headers": { @@ -3656,7 +3668,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -3665,7 +3677,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -3893,6 +3905,48 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -3908,7 +3962,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -5870,7 +5932,7 @@ "type": "string", "description": "The id the block was actually given." }, - "description": "The id each newly created block was actually given, keyed by the `block_id` you asked for, and present only for the ones that differ. A `block_id` on an `add` or `insert_into_subflow` that is not already a UUID is replaced with a minted one, so this is how you learn what to reference afterwards. Within a single batch you can keep using your own ids — references between operations are remapped for you — but a later request must use the minted id, so send your own UUIDs when you want an id you chose to survive." + "description": "Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged." }, "lint": { "$ref": "#/components/schemas/WorkflowLintReport" @@ -6004,7 +6066,7 @@ "additionalProperties": { "description": "One block-specific input or connection descriptor." }, - "description": "Block type and name, plus any block-specific configuration. Beyond `type` and `name` the accepted keys are `inputs`, `connections`, `retry`, `triggerMode`, and `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle." + "description": "Block `type`, `name`, and optional `inputs`, `connections`, `retry`, `triggerMode`, or `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." } }, "required": ["operation_type", "block_id", "params"], @@ -6069,7 +6131,7 @@ } } ], - "description": "Fields to change on the target block. Send only what changes. Accepted keys: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle. Re-sending `connections` replaces that block's outgoing edges, so use `removeEdges` — `[{ targetBlockId, sourceHandle? }]`, `sourceHandle` defaulting to `source` — to drop one edge without restating the rest." + "description": "Patch only supplied fields: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, and `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`. Re-sending `connections` replaces outgoing edges; use `removeEdges` to delete selected edges." } }, "required": ["operation_type", "block_id", "params"], @@ -6154,7 +6216,7 @@ "additionalProperties": { "description": "One block-specific input or connection descriptor." }, - "description": "Container, block type and name, plus any block-specific configuration. Takes the same keys as an `add`: `inputs`, `connections`, `retry`, `triggerMode`, `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle." + "description": "Container, block `type`, `name`, and the same optional fields as `add`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." } }, "required": ["operation_type", "block_id", "params"], @@ -9268,7 +9330,7 @@ } }, "run": { - "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires a personal API key with write access and supports synchronous or streamed runs only.", + "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.", "oneOf": [ { "type": "object", @@ -9346,7 +9408,7 @@ }, "async": { "default": false, - "description": "Queue the run and return a 202 receipt when true. Requires an API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", + "description": "Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", "type": "boolean" }, "executionTimeoutSeconds": { diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 8d46564462b..cff17292594 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -11,6 +11,7 @@ BETTER_AUTH_URL=http://localhost:3000 # Authentication Bypass (Optional - for self-hosted deployments behind private networks) # DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests. +# OAUTH_PROVIDER_ENABLED=true # Enable Sim's OAuth authorization server after every app instance runs the matching migration/code. DISABLE_AUTH=true forces it off. # Private-network egress allowlist (Optional - self-hosted only; ignored on Sim Cloud) # EGRESS_ALLOWED_HOSTS=host.docker.internal,*.svc.cluster.local # Uncomment to let outbound requests reach these hosts on a private network. Widens the SSRF boundary; only use on a trusted private network. diff --git a/apps/sim/app/(auth)/oauth/consent/consent-view.tsx b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx new file mode 100644 index 00000000000..1f3e10d18e8 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx @@ -0,0 +1,189 @@ +'use client' + +import { Chip } from '@sim/emcn' +import { Check } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { signOut } from '@/lib/auth/auth-client' +import { + OAUTH_SCOPE_DESCRIPTIONS, + SIM_CLI_CLIENT_ID, + visibleOAuthScopes, +} from '@/lib/auth/oauth-provider' +import { + AuthFormMessage, + AuthHeader, + AuthSubmitButton, + AuthTextLink, +} from '@/app/(auth)/components' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' +import { OAuthConsentLoading } from '@/app/(auth)/oauth/consent/loading' +import { useOAuthConsent, useOAuthPublicClient } from '@/hooks/queries/oauth-provider' + +export type OAuthConsentRefusal = 'expired' | 'missing' | 'tampered' | 'unsigned' + +const REFUSAL_MESSAGES: Record = { + expired: 'This authorization request has expired. Start sign-in again from the app.', + missing: 'The authorization request is missing its client identifier.', + tampered: 'This authorization request was altered on its way here.', + unsigned: 'This authorization request did not come from Sim.', +} + +interface OAuthConsentViewProps { + /** Set when the page already knows the request is not a real one. */ + refusal: OAuthConsentRefusal | null + clientId: string | null + /** Isolates cached client metadata to every parameter in this signed request. */ + authorizationRequestKey: string | null + scope: string | null + /** Where the code will be sent, shown so a lookalike app is visible as one. */ + redirectUri: string | null + /** The signed-in account the grant will belong to. */ + email: string +} + +/** + * The host the authorization code will be delivered to, or `null` when the + * request names none this page can read. + * + * A registered `client_name` is whatever the client called itself, so for a + * public client the destination is the one part of the request an impostor + * cannot borrow. A loopback address is named plainly rather than shown as an + * IP, because "this computer" is what it means to the person reading it. + */ +function describeDestination(redirectUri: string | null): string | null { + if (!redirectUri) return null + try { + const { hostname } = new URL(redirectUri) + return hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' + ? 'this computer' + : hostname + } catch { + return null + } +} + +/** + * The consent card: which app is asking, what it will be able to do, and as + * whom. Every decision here is a real grant, so the copy names the app and the + * account rather than a generic "an application" — the page is the only place + * a relayed or phished authorization can be caught, which is also why the + * first-party CLI never skips it. + */ +export function OAuthConsentView({ + refusal, + clientId, + authorizationRequestKey, + scope, + redirectUri, + email, +}: OAuthConsentViewProps) { + const client = useOAuthPublicClient(clientId ?? undefined, authorizationRequestKey ?? undefined) + const consent = useOAuthConsent() + + /** Refuses clients Sim cannot name because URL metadata alone is untrusted. */ + const reason: OAuthConsentRefusal | null = refusal ?? (clientId ? null : 'missing') + if (reason || client.isError) { + return ( +
+ + + {reason + ? REFUSAL_MESSAGES[reason] + : getErrorMessage(client.error, 'Sim could not identify the app asking for access.')} + +
+ ) + } + + if (client.isPending) return + + const isCli = clientId === SIM_CLI_CLIENT_ID + /** Uses only the server-registered name; the client ID comes from the URL. */ + const appName = client.data?.name?.trim() + if (!appName) { + return ( +
+ + + Sim could not identify the app asking for access. + +
+ ) + } + const scopes = visibleOAuthScopes((scope ?? '').split(' ').filter(Boolean)) + const destination = describeDestination(redirectUri) + + const decide = (accept: boolean) => { + consent.mutate(accept, { + onSuccess: (url) => { + window.location.assign(url) + }, + }) + } + + const switchAccount = async () => { + await signOut() + window.location.assign(`/oauth/sign-in${window.location.search}`) + } + + return ( +
+ +
+ {scopes.length > 0 && ( +
    + {scopes.map((item) => ( +
  • + + {OAUTH_SCOPE_DESCRIPTIONS[item]} +
  • + ))} +
+ )} +

+ {destination ? `Sends you back to ${destination}. ` : ''}Continuing as {email}.{' '} + + Not you? + +

+ decide(true)} + > + Allow + + decide(false)} + > + Deny + + {consent.isError && ( + + {getErrorMessage(consent.error, 'Something went wrong. Please try again.')} + + )} +
+
+ ) +} diff --git a/apps/sim/app/(auth)/oauth/consent/loading.tsx b/apps/sim/app/(auth)/oauth/consent/loading.tsx new file mode 100644 index 00000000000..daaaace11f2 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/loading.tsx @@ -0,0 +1,39 @@ +import { Skeleton } from '@sim/emcn' +import { AuthShell } from '@/app/(auth)/components' + +/** + * Consent-card skeleton, shared by the route fallback and the card itself + * while it resolves the client's registered name. + * + * Bars mirror the card so nothing shifts when it arrives: a one-line heading, + * a description that wraps to two in the 400px column, the scope panel at its + * tallest real height, then the two `h-9` actions with the `space-y-4` gap the + * card renders. + * + * Sized for the Sim CLI, which is the client nearly every visitor arrives + * from: it asks for `offline_access api:read api:write`, and + * `visibleOAuthScopes` folds `api:read` into `api:write`, so two rows render. + * `py-3` (24) + two 20px rows + one 8px gap + 2px of border. A third-party + * client asking for more shifts the card down a little when it resolves. + */ +export function OAuthConsentLoading() { + return ( +
+ + + + + + + +
+ ) +} + +export default function OAuthConsentRouteLoading() { + return ( + + + + ) +} diff --git a/apps/sim/app/(auth)/oauth/consent/page.tsx b/apps/sim/app/(auth)/oauth/consent/page.tsx new file mode 100644 index 00000000000..f9bf5497a61 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import type { SearchParams } from 'nuqs/server' +import { getSession } from '@/lib/auth' +import { isOAuthProviderEnabled } from '@/lib/core/config/env-flags' +import { AuthShell } from '@/app/(auth)/components' +import { OAuthConsentView } from '@/app/(auth)/oauth/consent/consent-view' +import { oauthConsentSearchParamsCache } from '@/app/(auth)/oauth/consent/search-params' + +export const metadata: Metadata = { + title: 'Authorize app', + robots: { index: false, follow: false }, +} + +export const dynamic = 'force-dynamic' + +/** + * The OAuth provider's `consentPage`. The plugin sends a signed-in user here with + * the signed authorization query; the view shows who is asking and for what, and + * the consent call carries that same query back so the plugin can mint a code + * for exactly the request the user saw. + * + * A signed-out visitor (a stale tab, a shared link) goes through the same + * bounce the plugin uses for its `loginPage`, which re-enters authorize after + * sign-in and lands back here with a fresh signature. + */ +export default async function OAuthConsentPage({ + searchParams, +}: { + searchParams: Promise +}) { + if (!isOAuthProviderEnabled) redirect('/') + + const [session, raw] = await Promise.all([getSession(), searchParams]) + + if (!session?.user) { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(raw)) { + if (typeof value === 'string') query.set(key, value) + } + redirect(`/oauth/sign-in?${query.toString()}`) + } + + /** + * Repeated fields can make displayed consent diverge from the signed request; + * unsigned requests never passed through the authorization endpoint. + */ + const tampered = Object.entries(raw).some( + ([key, value]) => key !== 'ba_param' && Array.isArray(value) + ) + const unsigned = typeof raw.sig !== 'string' + const expiresAtSeconds = typeof raw.exp === 'string' ? Number(raw.exp) : Number.NaN + const expired = !Number.isFinite(expiresAtSeconds) || expiresAtSeconds * 1000 < Date.now() + const refusal = tampered ? 'tampered' : unsigned ? 'unsigned' : expired ? 'expired' : null + const params = refusal ? null : oauthConsentSearchParamsCache.parse(raw) + const authorizationRequestKey = refusal ? null : JSON.stringify(raw) + + return ( + + + + ) +} diff --git a/apps/sim/app/(auth)/oauth/consent/search-params.ts b/apps/sim/app/(auth)/oauth/consent/search-params.ts new file mode 100644 index 00000000000..b250d5e6a69 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/search-params.ts @@ -0,0 +1,21 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server' + +/** + * The authorize parameters the consent card renders. The rest of the signed + * query (`state`, `sig`, `exp`, …) stays untouched in `window.location.search`, + * which is what the auth client forwards verbatim on the consent call. + * + * Read-only for the life of the page, so there is no `urlKeys` companion and + * no client-side `useQueryStates` — the server component reads them and passes + * them down as props. + * + * Deliberately nullable rather than `.withDefault('')`: a missing `client_id` + * is a malformed request the card must refuse, not a value to fall back on. + */ +const oauthConsentParsers = { + client_id: parseAsString, + scope: parseAsString, + redirect_uri: parseAsString, +} as const + +export const oauthConsentSearchParamsCache = createSearchParamsCache(oauthConsentParsers) diff --git a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts new file mode 100644 index 00000000000..b72250bb1c5 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const flags = vi.hoisted(() => ({ enabled: true, registrationDisabled: false })) + +vi.mock('@/lib/core/config/env-flags', () => ({ + get isOAuthProviderEnabled() { + return flags.enabled + }, + get isRegistrationDisabled() { + return flags.registrationDisabled + }, +})) + +import { GET } from '@/app/(auth)/oauth/sign-in/route' + +function request(query: string): NextRequest { + return new NextRequest(`https://sim.test/oauth/sign-in?${query}`) +} + +function redirectParts(response: Response): { destination: URL; callback: URL } { + const destination = new URL(response.headers.get('location') as string) + const callbackUrl = destination.searchParams.get('callbackUrl') + if (!callbackUrl) throw new Error('redirect did not carry a callbackUrl') + return { destination, callback: new URL(callbackUrl, destination.origin) } +} + +describe('OAuth login bridge', () => { + beforeEach(() => { + flags.enabled = true + flags.registrationDisabled = false + }) + + it('consumes prompt=login and preserves a later consent prompt', async () => { + const response = await GET( + request( + 'client_id=sim-cli&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&prompt=login%20consent&sig=signed&ba_iat=1' + ) + ) + const { destination, callback } = redirectParts(response) + + expect(response.status).toBe(302) + expect(destination.pathname).toBe('/login') + expect(callback.pathname).toBe('/api/auth/oauth2/authorize') + expect(callback.searchParams.get('prompt')).toBe('consent') + expect(callback.searchParams.get('client_id')).toBe('sim-cli') + expect(callback.searchParams.has('sig')).toBe(false) + expect(callback.searchParams.has('ba_iat')).toBe(false) + }) + + it('consumes prompt=create after directing the user through signup', async () => { + const response = await GET(request('client_id=sim-cli&prompt=create')) + const { destination, callback } = redirectParts(response) + + expect(destination.pathname).toBe('/signup') + expect(callback.searchParams.has('prompt')).toBe(false) + }) + + it('uses login when registration is disabled and hides a disabled provider', async () => { + flags.registrationDisabled = true + const enabled = await GET(request('client_id=sim-cli')) + expect(redirectParts(enabled).destination.pathname).toBe('/login') + + flags.enabled = false + const disabled = await GET(request('client_id=sim-cli')) + expect(disabled.status).toBe(302) + expect(new URL(disabled.headers.get('location') as string).pathname).toBe('/') + }) +}) diff --git a/apps/sim/app/(auth)/oauth/sign-in/route.ts b/apps/sim/app/(auth)/oauth/sign-in/route.ts new file mode 100644 index 00000000000..35430c1524d --- /dev/null +++ b/apps/sim/app/(auth)/oauth/sign-in/route.ts @@ -0,0 +1,59 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { isOAuthProviderEnabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' + +/** + * Query parameters the OAuth plugin adds when it signs the authorize query for + * a `loginPage` redirect. Re-entering authorize starts a fresh request, which + * signs its own, so carrying the old ones forward would only widen the URL. + */ +const SIGNED_QUERY_PARAMS = new Set(['sig', 'exp', 'ba_iat', 'ba_param', 'ba_pl']) + +/** Removes prompts completed by the login/signup hop while preserving later consent prompts. */ +function consumeInteractivePrompt(params: URLSearchParams): boolean { + const prompts = (params.get('prompt') ?? '').split(/\s+/).filter(Boolean) + const requiresLogin = prompts.includes('login') + const remaining = prompts.filter((prompt) => prompt !== 'login' && prompt !== 'create') + if (remaining.length > 0) params.set('prompt', remaining.join(' ')) + else params.delete('prompt') + return requiresLogin +} + +/** + * The OAuth provider's `loginPage`: where a signed-out user lands when a client + * starts `/api/auth/oauth2/authorize`. + * + * The plugin forwards the whole authorize query, signed. The authorize endpoint + * is stateless, so the flow resumes by simply visiting it again once a session + * exists. This route rebuilds that URL from the original parameters and hands + * it to the normal auth pages as `callbackUrl`, which is how every other + * post-login destination in Sim travels — no second login form, no plugin + * client hooks to keep in step. + * + * Signup rather than login by default, for the same reason the CLI handoff + * chooses it: this is reached from a terminal, often on a fresh install. Under + * DISABLE_REGISTRATION nobody can create an account, so login is the only hop + * that can succeed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + /** Avoid sending a newly signed-in user to a disabled provider's JSON 404. */ + if (!isOAuthProviderEnabled) { + return NextResponse.redirect(new URL('/', request.nextUrl.origin), 302) + } + + const params = new URLSearchParams(request.nextUrl.search) + for (const name of SIGNED_QUERY_PARAMS) params.delete(name) + const requiresLogin = consumeInteractivePrompt(params) + + const authorizeUrl = `/api/auth/oauth2/authorize?${params.toString()}` + const destination = buildAuthCrossLink( + isRegistrationDisabled || requiresLogin ? '/login' : '/signup', + { + callbackUrl: authorizeUrl, + isInviteFlow: false, + } + ) + + return NextResponse.redirect(new URL(destination, request.nextUrl.origin), 302) +}) diff --git a/apps/sim/app/.well-known/oauth-authorization-server/api/auth/route.ts b/apps/sim/app/.well-known/oauth-authorization-server/api/auth/route.ts new file mode 100644 index 00000000000..2a91de32010 --- /dev/null +++ b/apps/sim/app/.well-known/oauth-authorization-server/api/auth/route.ts @@ -0,0 +1,5 @@ +import { getOAuthProviderMetadataResponse } from '@/lib/auth/oauth-provider-metadata' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** RFC 8414 metadata location derived from Sim's `/api/auth` issuer path. */ +export const GET = withRouteHandler(getOAuthProviderMetadataResponse) diff --git a/apps/sim/app/.well-known/oauth-authorization-server/route.ts b/apps/sim/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 00000000000..55f7c4928bc --- /dev/null +++ b/apps/sim/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,18 @@ +import { getOAuthProviderMetadataResponse } from '@/lib/auth/oauth-provider-metadata' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** + * RFC 8414 authorization-server metadata at the origin root. The plugin serves + * the same document under `/api/auth/.well-known/`; this route exists so a + * client that only knows Sim's origin can discover the endpoints, and so the + * Sim CLI can probe one URL to learn whether the provider is on before + * choosing a login flow. Deliberately 404 rather than an empty document when + * it is off: "no authorization server here" is the answer. + * + * Note the issuer this document names is `/api/auth`, which is where + * Better Auth mounts the provider — so a client following RFC 8414 §3.1 to the + * letter would look under `/.well-known/oauth-authorization-server/api/auth`. + * This copy is the probe; Sim serves the issuer-derived alias from the same + * response helper so every discovery path stays byte-for-byte equivalent. + */ +export const GET = withRouteHandler(getOAuthProviderMetadataResponse) diff --git a/apps/sim/app/api/auth/.well-known/oauth-authorization-server/route.ts b/apps/sim/app/api/auth/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 00000000000..94ac2445646 --- /dev/null +++ b/apps/sim/app/api/auth/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,5 @@ +import { getOAuthProviderMetadataResponse } from '@/lib/auth/oauth-provider-metadata' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** Better Auth's issuer-prefixed RFC 8414 compatibility location. */ +export const GET = withRouteHandler(getOAuthProviderMetadataResponse) diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index e173e2ffee0..442366fc375 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const handlerMocks = vi.hoisted(() => ({ @@ -297,3 +298,114 @@ describe('auth catch-all route SSO provider mutations', () => { expect(await res.json()).toEqual({ data: { url: 'https://idp.example.com' } }) }) }) + +describe('OAuth provider client endpoints', () => { + beforeEach(() => { + vi.clearAllMocks() + handlerMocks.betterAuthPOST.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ) + }) + + it.each(['.well-known/openid-configuration', 'oauth2/end-session', 'oauth2/userinfo'])( + 'does not expose the OIDC-only %s endpoint', + async (path) => { + const getResponse = await GET( + createMockRequest('GET', undefined, {}, `http://localhost:3000/api/auth/${path}`) + ) + const postResponse = await POST( + createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`) + ) + + expect(getResponse.status).toBe(404) + expect(postResponse.status).toBe(404) + expect(getResponse.headers.get('cache-control')).toBe('no-store') + expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled() + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + } + ) + + /** + * The plugin gates client creation on a session alone, so without this any + * signed-in user could register a client with arbitrary redirect URIs and + * the full scope set. Nothing must reach the plugin. + */ + it.each([ + 'oauth2/create-client', + 'oauth2/update-client', + 'oauth2/delete-client', + 'oauth2/client/rotate-secret', + 'oauth2/register', + 'oauth2/introspect', + 'oauth2/anything-a-future-version-adds', + ])('refuses POST /%s without reaching Better Auth', async (path) => { + const req = createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`) + + const res = await POST(req) + + expect(res.status).toBe(404) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + }) + + it.each([ + 'oauth2/token', + 'oauth2/consent', + 'oauth2/continue', + 'oauth2/revoke', + 'oauth2/public-client-prelogin', + 'oauth2/callback/jira', + ])('lets the protocol endpoint %s through', async (path) => { + const req = createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`) + + await POST(req) + + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1) + }) + + it.each(['oauth2/token', 'oauth2/revoke'])( + 'rejects repeated form parameters on %s', + async (path) => { + const req = new NextRequest(`http://localhost:3000/api/auth/${path}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: 'client_id=client-1&client_id=client-2', + }) + + const res = await POST(req) + + expect(res.status).toBe(400) + expect(res.headers.get('cache-control')).toBe('no-store') + await expect(res.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + } + ) + + it('rejects Basic authentication combined with a body secret', async () => { + const req = new NextRequest('http://localhost:3000/api/auth/oauth2/token', { + method: 'POST', + headers: { + authorization: 'Basic Y2xpZW50OnNlY3JldA==', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&client_secret=secret', + }) + + const res = await POST(req) + + expect(res.status).toBe(400) + expect(res.headers.get('cache-control')).toBe('no-store') + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + }) + + it('passes an ordinary form request through unchanged', async () => { + const req = new NextRequest('http://localhost:3000/api/auth/oauth2/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: 'grant_type=authorization_code&client_id=client-1', + }) + + await POST(req) + + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledWith(req) + }) +}) diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 03c3a1514ad..5c80def3a2a 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -16,6 +16,11 @@ export const dynamic = 'force-dynamic' const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler) const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active']) const OAUTH_CALLBACK_PATH_PREFIX = 'oauth2/callback/' +const UNSUPPORTED_OIDC_PATHS = new Set([ + '.well-known/openid-configuration', + 'oauth2/end-session', + 'oauth2/userinfo', +]) /** * SAML protocol endpoints the IdP posts to (`saml2/callback/:id`, @@ -25,6 +30,69 @@ const OAUTH_CALLBACK_PATH_PREFIX = 'oauth2/callback/' */ const SAML_PROTOCOL_POST_PREFIX = 'sso/saml2/' +/** + * The OAuth provider POST endpoints a client legitimately calls: the protocol + * itself, plus the consent decision the consent page submits. + */ +const OAUTH_PROVIDER_PROTOCOL_POST_PATHS = new Set([ + 'oauth2/token', + 'oauth2/consent', + 'oauth2/continue', + 'oauth2/revoke', + 'oauth2/public-client-prelogin', +]) + +const OAUTH_FORM_POST_PATHS = new Set(['oauth2/token', 'oauth2/revoke']) + +/** + * Rejects ambiguous OAuth form requests before Better Auth parses them. + * Better Auth 1.6.27 keeps the last occurrence of a repeated form field, while + * OAuth requires each parameter to appear at most once. Refusing the request + * here also prevents HTTP Basic credentials from being combined with a body + * secret and interpreted differently by an intermediary. + */ +async function rejectAmbiguousOAuthForm( + request: NextRequest, + path: string +): Promise { + if (!OAUTH_FORM_POST_PATHS.has(path)) return null + if ( + !request.headers + .get('content-type') + ?.toLowerCase() + .startsWith('application/x-www-form-urlencoded') + ) { + return null + } + + const form = new URLSearchParams(await request.clone().text()) + const seen = new Set() + for (const [name] of form) { + if (seen.has(name)) { + return NextResponse.json( + { + error: 'invalid_request', + error_description: `OAuth parameter ${name} appears more than once.`, + }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } + seen.add(name) + } + + const usesBasicAuth = request.headers.get('authorization')?.startsWith('Basic ') === true + if (usesBasicAuth && form.has('client_secret')) { + return NextResponse.json( + { + error: 'invalid_request', + error_description: 'Use exactly one client authentication method.', + }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } + return null +} + function getAuthPath(request: NextRequest): string { const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname return pathname.replace('/api/auth/', '') @@ -71,8 +139,39 @@ function isBlockedSsoMutationPath(path: string): boolean { return path.startsWith('sso/') && !path.startsWith(SAML_PROTOCOL_POST_PREFIX) } +/** + * Client registration and client/consent mutation are not Sim's OAuth surface. + * + * `allowDynamicClientRegistration: false` gates only `/oauth2/register`; the + * plugin's `/oauth2/create-client`, `/update-client`, `/delete-client`, + * `/client/rotate-secret` and `/update-consent` are separate endpoints whose + * only guard is a session, so without this any signed-in user could register a + * client with arbitrary redirect URIs and the full scope set, then phish a + * token out of somebody else — or widen their own grant past what they + * consented to. Clients are operator-created rows (see + * `apps/sim/scripts/create-oauth-client.ts`), and a consent is changed by + * granting or revoking it, never by editing the row. + * + * Deny-by-default, like the SSO block above, so a future plugin version cannot + * introduce another unshadowed mutation endpoint. `oauth2/callback/` is the + * generic-OAuth *client* callback and belongs to connector linking, not here. + */ +function isBlockedOAuthProviderMutationPath(path: string): boolean { + if (!path.startsWith('oauth2/') || path.startsWith('oauth2/callback/')) return false + return !OAUTH_PROVIDER_PROTOCOL_POST_PATHS.has(path) +} + +/** Sim exposes OAuth API authorization, not an OpenID Connect identity provider. */ +function unsupportedOidcResponse(): NextResponse { + return NextResponse.json( + { error: 'OpenID Connect is not available.' }, + { status: 404, headers: { 'Cache-Control': 'no-store' } } + ) +} + export const GET = withRouteHandler(async (request: NextRequest) => { const path = getAuthPath(request) + if (UNSUPPORTED_OIDC_PATHS.has(path)) return unsupportedOidcResponse() const credentialGroupProviderId = getCredentialGroupCallbackProviderId(request, path) if (credentialGroupProviderId) { @@ -111,6 +210,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { export const POST = withRouteHandler(async (request: NextRequest) => { const path = getAuthPath(request) + if (UNSUPPORTED_OIDC_PATHS.has(path)) return unsupportedOidcResponse() + + const ambiguousOAuthForm = await rejectAmbiguousOAuthForm(request, path) + if (ambiguousOAuthForm) return ambiguousOAuthForm if (isBlockedOrganizationMutationPath(path)) { return NextResponse.json( @@ -126,5 +229,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + if (isBlockedOAuthProviderMutationPath(path)) { + return NextResponse.json( + { error: 'OAuth client registration is not available.' }, + { status: 404 } + ) + } + return betterAuthPOST(request) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index ab527378a22..801f0767dac 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,14 +1,22 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' const mocks = vi.hoisted(() => ({ + betterAuthGET: vi.fn(), getSession: vi.fn(), linkAccount: vi.fn(), getBaseUrl: vi.fn(), @@ -18,9 +26,13 @@ const mocks = vi.hoisted(() => ({ launchConnection: vi.fn(), })) +vi.mock('better-auth/next-js', () => ({ + toNextJsHandler: () => ({ GET: mocks.betterAuthGET }), +})) + vi.mock('@/lib/auth/auth', () => ({ getSession: mocks.getSession, - auth: { api: { oAuth2LinkAccount: mocks.linkAccount } }, + auth: { handler: {}, api: { oAuth2LinkAccount: mocks.linkAccount } }, })) vi.mock('@/lib/core/utils/urls', () => ({ SITE_URL: 'https://www.sim.ai', @@ -56,6 +68,8 @@ import { GET } from '@/app/api/auth/oauth2/authorize/route' const BASE_URL = 'https://sim.test' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +afterAll(resetEnvFlagsMock) + function request(query: Record) { const url = new URL('/api/auth/oauth2/authorize', BASE_URL) for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value) @@ -72,6 +86,8 @@ function linkResponse(url = 'https://provider.example/authorize') { describe('OAuth2 authorize route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isOAuthProviderEnabled: true }) mocks.getBaseUrl.mockReturnValue(BASE_URL) mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, @@ -94,6 +110,180 @@ describe('OAuth2 authorize route', () => { }) mocks.linkAccount.mockResolvedValue(linkResponse()) mocks.getPerRequestScopes.mockReturnValue(undefined) + mocks.betterAuthGET.mockResolvedValue(new Response(null, { status: 302 })) + }) + + it('forwards a provider request without entering the connector flow', async () => { + const providerRequest = request({ + client_id: 'client-1', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + providerId: 'google-email', + draftId: 'draft-1', + }) + + const response = await GET(providerRequest) + + expect(response.status).toBe(302) + expect(mocks.betterAuthGET).toHaveBeenCalledWith(providerRequest) + expect(mocks.getSession).not.toHaveBeenCalled() + expect(mocks.launchConnection).not.toHaveBeenCalled() + expect(mocks.createConnection).not.toHaveBeenCalled() + }) + + it('returns 404 without delegation when the provider is disabled', async () => { + setEnvFlags({ isOAuthProviderEnabled: false }) + + const response = await GET(request({ client_id: 'client-1' })) + + expect(response.status).toBe(404) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it('keeps an OAuth request missing client_id out of the connector flow', async () => { + const response = await GET( + request({ response_type: 'code', redirect_uri: 'https://client.example/callback' }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.getSession).not.toHaveBeenCalled() + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it.each(['scope', 'state', 'nonce', 'prompt'])( + 'does not let an isolated %s parameter enter the connector flow', + async (parameter) => { + const response = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + [parameter]: 'value', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.getSession).not.toHaveBeenCalled() + expect(mocks.createConnection).not.toHaveBeenCalled() + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + } + ) + + it.each([ + 'response_type', + 'client_id', + 'redirect_uri', + 'scope', + 'state', + 'request_uri', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'prompt', + 'resource', + ])('rejects a repeated OAuth provider %s before Better Auth', async (parameter) => { + const url = new URL('/api/auth/oauth2/authorize', BASE_URL) + url.searchParams.set('client_id', 'sim-cli') + url.searchParams.append(parameter, 'first') + url.searchParams.append(parameter, 'second') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it.each([ + [{ code_challenge: 'a'.repeat(43) }, 'unpaired challenge'], + [{ code_challenge_method: 'S256' }, 'unpaired method'], + [{ code_challenge: 'a'.repeat(42), code_challenge_method: 'S256' }, 'malformed challenge'], + [{ code_challenge: 'a'.repeat(43), code_challenge_method: 'plain' }, 'unsupported method'], + ])('rejects %s PKCE parameters before Better Auth', async (parameters) => { + const response = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + ...parameters, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it('accepts a canonical S256 challenge and rejects unsupported resource audiences', async () => { + const acceptedRequest = request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + code_challenge: 'a'.repeat(43), + code_challenge_method: 'S256', + }) + const accepted = await GET(acceptedRequest) + const resource = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + resource: 'https://api.example.test', + }) + ) + + expect(accepted.status).toBe(302) + expect(mocks.betterAuthGET).toHaveBeenCalledWith(acceptedRequest) + expect(resource.status).toBe(400) + await expect(resource.json()).resolves.toMatchObject({ error: 'invalid_request' }) + }) + + it('redirects a malformed request only to its registered callback with state and issuer', async () => { + queueTableRows(schemaMock.oauthClient, [ + { disabled: false, redirectUris: ['http://127.0.0.1/callback'] }, + ]) + + const response = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'http://127.0.0.1:43123/callback', + state: 'state-1', + code_challenge: 'too-short', + code_challenge_method: 'S256', + }) + ) + const location = new URL(response.headers.get('location') ?? '') + + expect(response.status).toBe(302) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(location.origin).toBe('http://127.0.0.1:43123') + expect(location.searchParams.get('error')).toBe('invalid_request') + expect(location.searchParams.get('state')).toBe('state-1') + expect(location.searchParams.get('iss')).toBe(`${BASE_URL}/api/auth`) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it('returns the authorization-specific error code to a registered callback', async () => { + queueTableRows(schemaMock.oauthClient, [ + { disabled: false, redirectUris: ['https://client.example/callback'] }, + ]) + + const response = await GET( + request({ + client_id: 'client-1', + response_type: 'token', + redirect_uri: 'https://client.example/callback', + state: 'state-1', + }) + ) + const location = new URL(response.headers.get('location') ?? '') + + expect(response.status).toBe(302) + expect(location.searchParams.get('error')).toBe('unsupported_response_type') + expect(location.searchParams.get('state')).toBe('state-1') + expect(mocks.betterAuthGET).not.toHaveBeenCalled() }) it('creates a canonical application draft for a legacy connect URL', async () => { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index e15d0ad6074..b479db0c044 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -1,10 +1,14 @@ import { createLogger } from '@sim/logger' +import { toNextJsHandler } from 'better-auth/next-js' import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' +import { oauthAuthorizationErrorResponse } from '@/lib/auth/oauth-authorization-error' +import { validateOAuthPkceAuthorizationRequest } from '@/lib/auth/oauth-protocol-request' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { isOAuthProviderEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -18,10 +22,115 @@ const logger = createLogger('OAuth2Authorize') export const dynamic = 'force-dynamic' +const { GET: betterAuthGET } = toNextJsHandler(auth.handler) + +const OAUTH_AUTHORIZE_PARAMETERS = new Set([ + 'response_type', + 'client_id', + 'redirect_uri', + 'scope', + 'state', + 'request_uri', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'prompt', + 'resource', +]) + +/** Returns the first ambiguous OAuth authorization parameter. */ +function repeatedOAuthAuthorizeParameter(request: NextRequest): string | null { + for (const name of OAUTH_AUTHORIZE_PARAMETERS) { + if (request.nextUrl.searchParams.getAll(name).length <= 1) continue + return name + } + return null +} + +/** + * Whether this is a client asking Sim to sign its user in — the OAuth provider's + * authorize request — rather than a user linking an external account. + * + * This route sits on the same path Better Auth mounts the provider's authorize + * endpoint, so the catch-all never sees it. Connector links use only the + * contract's draft/provider/workspace fields; any provider-specific parameter + * keeps even a malformed OAuth request out of the credential-linking flow. + */ +function isOAuthProviderAuthorize(request: NextRequest): boolean { + for (const name of OAUTH_AUTHORIZE_PARAMETERS) { + if (request.nextUrl.searchParams.has(name)) return true + } + return false +} + /** - * Browser-initiated entrypoint for linking a generic OAuth2 account. + * Browser-initiated entrypoint for linking a generic OAuth2 account, and the + * OAuth provider's authorize endpoint when the request is a client's. */ export const GET = withRouteHandler(async (request: NextRequest) => { + if (isOAuthProviderAuthorize(request)) { + if (!isOAuthProviderEnabled) { + return NextResponse.json({ error: 'OAuth provider is not enabled' }, { status: 404 }) + } + const repeatedParameter = repeatedOAuthAuthorizeParameter(request) + if (repeatedParameter) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + `OAuth parameter ${repeatedParameter} appears more than once.` + ) + } + const params = request.nextUrl.searchParams + if (!params.has('client_id')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The client_id parameter is required.' + ) + } + if (!params.has('redirect_uri')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The redirect_uri parameter is required.' + ) + } + if (params.has('resource')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The resource parameter is not supported.' + ) + } + if (params.has('request_uri')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The request_uri parameter is not supported.' + ) + } + const responseType = params.get('response_type') + if (!responseType) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The response_type parameter is required.' + ) + } + if (responseType !== 'code') { + return oauthAuthorizationErrorResponse( + request, + 'unsupported_response_type', + 'Only the code response type is supported.' + ) + } + const pkceError = validateOAuthPkceAuthorizationRequest(params) + if (pkceError) { + return oauthAuthorizationErrorResponse(request, 'invalid_request', pkceError) + } + return betterAuthGET(request) + } + const baseUrl = getBaseUrl() const session = await getSession() diff --git a/apps/sim/app/api/auth/oauth2/revoke/route.test.ts b/apps/sim/app/api/auth/oauth2/revoke/route.test.ts new file mode 100644 index 00000000000..ba8ffeee203 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/revoke/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + enabled: true, + rateLimit: vi.fn(async () => null), + revoke: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + get isOAuthProviderEnabled() { + return mocks.enabled + }, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit })) +vi.mock('@/lib/auth/oauth-token-family', () => ({ revokeOAuthToken: mocks.revoke })) + +import { POST } from '@/app/api/auth/oauth2/revoke/route' + +function revokeRequest(body: string) { + return new NextRequest('http://localhost/api/auth/oauth2/revoke', { + method: 'POST', + body, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) +} + +describe('OAuth revocation route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.enabled = true + mocks.revoke.mockResolvedValue({ success: true, value: undefined }) + }) + + it('returns the empty RFC 7009 success response for known or unknown tokens', async () => { + const response = await POST( + revokeRequest('client_id=sim-cli&token=sim_ort_current&token_type_hint=not-a-real-hint') + ) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('') + expect(mocks.revoke).toHaveBeenCalledWith({ + credentials: { clientId: 'sim-cli', method: 'none' }, + token: 'sim_ort_current', + }) + }) + + it('returns a Basic challenge for Basic client-authentication failure', async () => { + mocks.revoke.mockResolvedValue({ + success: false, + error: 'invalid_client', + description: 'Client authentication failed.', + }) + const basic = Buffer.from('client:wrong').toString('base64') + const request = revokeRequest('token=sim_ort_current') + request.headers.set('authorization', `Basic ${basic}`) + const response = await POST(request) + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toContain('Basic') + }) + + it('normalizes an unexpected revocation failure', async () => { + mocks.revoke.mockRejectedValueOnce(new Error('database details')) + + const response = await POST(revokeRequest('client_id=sim-cli&token=sim_ort_current')) + + expect(response.status).toBe(500) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('pragma')).toBe('no-cache') + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Revocation endpoint failed.', + }) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/revoke/route.ts b/apps/sim/app/api/auth/oauth2/revoke/route.ts new file mode 100644 index 00000000000..ab79ffcd72c --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/revoke/route.ts @@ -0,0 +1,67 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + oauthErrorResponse, + oauthProtocolErrorResponse, + oauthRevocationSuccessResponse, + parseOAuthFormRequest, +} from '@/lib/auth/oauth-protocol-request' +import { revokeOAuthToken } from '@/lib/auth/oauth-token-family' +import { isOAuthProviderEnabled } from '@/lib/core/config/env-flags' +import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OAuthRevocationEndpoint') + +const REVOKE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 30, + refillRate: 30, + refillIntervalMs: 60_000, +} + +/** Revokes one opaque access token or the complete family named by a refresh token. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + if (!isOAuthProviderEnabled) { + return NextResponse.json( + { error: 'OAuth provider is not enabled' }, + { status: 404, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } + + try { + const rateLimited = await enforceIpRateLimit( + 'oauth-provider-revoke', + request, + REVOKE_RATE_LIMIT + ) + if (rateLimited) { + rateLimited.headers.set('Cache-Control', 'no-store') + rateLimited.headers.set('Pragma', 'no-cache') + return rateLimited + } + const parsed = await parseOAuthFormRequest(request) + if (!parsed.success) return parsed.response + + if (!parsed.value.credentials) { + return oauthErrorResponse('invalid_client', 'Client authentication is required.') + } + const token = parsed.value.form.get('token') + if (!token) return oauthErrorResponse('invalid_request', 'Token is required.') + + const result = await revokeOAuthToken({ credentials: parsed.value.credentials, token }) + if (!result.success) { + return oauthProtocolErrorResponse( + result.error, + result.description, + parsed.value.credentials.method + ) + } + return oauthRevocationSuccessResponse() + } catch (error) { + logger.error('OAuth revocation endpoint failed', { error: toError(error) }) + return oauthErrorResponse('server_error', 'Revocation endpoint failed.', 500) + } +}) diff --git a/apps/sim/app/api/auth/oauth2/token/route.postgres.test.ts b/apps/sim/app/api/auth/oauth2/token/route.postgres.test.ts new file mode 100644 index 00000000000..193e7d79920 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/token/route.postgres.test.ts @@ -0,0 +1,346 @@ +/** + * @vitest-environment node + */ +import { randomBytes } from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('@sim/db') +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') +vi.unmock('@/lib/auth') + +const databaseUrl = process.env.OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL + +interface TokenResponseBody { + access_token: string + refresh_token: string + scope: string +} + +describe.skipIf(!databaseUrl)('OAuth token route in PostgreSQL', () => { + it('issues, rotates, contains replay, and revokes a real Better Auth PKCE grant', async () => { + process.env.DATABASE_URL = databaseUrl + const authSecret = 'test-secret-that-is-at-least-32-chars-long' + process.env.BETTER_AUTH_SECRET = authSecret + + const [ + { db }, + schema, + { eq, inArray, like }, + { makeSignature }, + { auth }, + { POST: exchangeToken }, + { POST: revokeToken }, + tokenStore, + provider, + { requestUtilsMockFns }, + ] = await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('better-auth/crypto'), + import('@/lib/auth'), + import('@/app/api/auth/oauth2/token/route'), + import('@/app/api/auth/oauth2/revoke/route'), + import('@/lib/auth/oauth-access-token'), + import('@/lib/auth/oauth-provider'), + import('@sim/testing/mocks/request.mock'), + ]) + + const testId = randomBytes(8).toString('hex') + const userId = `oauth-route-test-user-${testId}` + const sessionId = `oauth-route-test-session-${testId}` + const sessionToken = `oauth-route-test-session-token-${testId}` + const consentId = `oauth-route-test-consent-${testId}` + const email = `oauth-route-${testId}@example.com` + const clientIp = `192.0.2.${Number.parseInt(testId.slice(0, 2), 16) || 1}` + const baseUrl = 'https://test.sim.ai' + const redirectUri = `http://127.0.0.1:${40_000 + (Number.parseInt(testId.slice(0, 4), 16) % 20_000)}/callback` + const grantedScopes = ['offline_access', 'api:read', 'api:write'] + const issuedCodeHashes: string[] = [] + + const signature = await makeSignature(sessionToken, authSecret) + const sessionCookie = `__Secure-better-auth.session_token=${encodeURIComponent(`${sessionToken}.${signature}`)}` + + const createFormRequest = (path: string, form: URLSearchParams) => + new NextRequest(`${baseUrl}${path}`, { + method: 'POST', + body: form.toString(), + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'x-forwarded-for': clientIp, + }, + }) + + const issueAuthorizationCode = async (verifier: string): Promise => { + const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) + const authorizeUrl = new URL('/api/auth/oauth2/authorize', baseUrl) + authorizeUrl.searchParams.set('client_id', provider.SIM_CLI_CLIENT_ID) + authorizeUrl.searchParams.set('response_type', 'code') + authorizeUrl.searchParams.set('redirect_uri', redirectUri) + authorizeUrl.searchParams.set('scope', grantedScopes.join(' ')) + authorizeUrl.searchParams.set('code_challenge', Buffer.from(challenge).toString('base64url')) + authorizeUrl.searchParams.set('code_challenge_method', 'S256') + authorizeUrl.searchParams.set('state', `state-${testId}`) + + const response = await auth.handler( + new Request(authorizeUrl, { headers: { cookie: sessionCookie } }) + ) + expect(response.status).toBe(302) + const location = response.headers.get('location') + expect(location).toBeTruthy() + const code = new URL(location as string, baseUrl).searchParams.get('code') + expect(code, `Expected authorization code redirect, received ${location}`).toBeTruthy() + issuedCodeHashes.push(tokenStore.hashOAuthToken(code as string)) + const authorizationCodes = await db + .select({ identifier: schema.verification.identifier }) + .from(schema.verification) + .where(like(schema.verification.value, `%${userId}%`)) + expect(authorizationCodes).toHaveLength(1) + return code as string + } + + const exchangeAuthorizationCode = async (code: string, verifier: string) => { + const response = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: provider.SIM_CLI_CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }) + ) + ) + expect(response.status).toBe(200) + return (await response.json()) as TokenResponseBody + } + + requestUtilsMockFns.mockGetClientIp.mockReturnValue(clientIp) + const now = new Date() + await db.insert(schema.user).values({ + id: userId, + name: 'OAuth route integration test', + email, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + const sessionExpiresAt = new Date(now.getTime() + 86_400_000) + await db.insert(schema.session).values({ + id: sessionId, + token: sessionToken, + userId, + expiresAt: sessionExpiresAt, + createdAt: now, + updatedAt: now, + }) + await db.insert(schema.oauthConsent).values({ + id: consentId, + clientId: provider.SIM_CLI_CLIENT_ID, + userId, + referenceId: null, + scopes: grantedScopes, + createdAt: now, + updatedAt: now, + }) + + try { + const firstVerifier = `${testId}-first-verifier-with-more-than-forty-three-characters` + const firstTokens = await exchangeAuthorizationCode( + await issueAuthorizationCode(firstVerifier), + firstVerifier + ) + const [sessionAfterAuthorization] = await db + .select({ expiresAt: schema.session.expiresAt }) + .from(schema.session) + .where(eq(schema.session.id, sessionId)) + expect(sessionAfterAuthorization?.expiresAt).toEqual(sessionExpiresAt) + expect( + await db + .select({ id: schema.verification.id }) + .from(schema.verification) + .where(like(schema.verification.value, `%${userId}%`)) + ).toHaveLength(0) + + expect(firstTokens.access_token).toMatch(/^sim_oat_/) + expect(firstTokens.refresh_token).toMatch(/^sim_ort_/) + expect(firstTokens.scope).toBe(grantedScopes.join(' ')) + + const firstAccessHash = tokenStore.hashOAuthToken( + firstTokens.access_token.slice(provider.OAUTH_ACCESS_TOKEN_PREFIX.length) + ) + const firstRefreshHash = tokenStore.hashOAuthToken( + firstTokens.refresh_token.slice(provider.OAUTH_REFRESH_TOKEN_PREFIX.length) + ) + const [firstRefresh] = await db + .select({ + id: schema.oauthRefreshToken.id, + token: schema.oauthRefreshToken.token, + familyId: schema.oauthRefreshToken.familyId, + generation: schema.oauthRefreshToken.generation, + scopes: schema.oauthRefreshToken.scopes, + }) + .from(schema.oauthRefreshToken) + .where(eq(schema.oauthRefreshToken.token, firstRefreshHash)) + const [firstAccess] = await db + .select({ + token: schema.oauthAccessToken.token, + refreshId: schema.oauthAccessToken.refreshId, + scopes: schema.oauthAccessToken.scopes, + }) + .from(schema.oauthAccessToken) + .where(eq(schema.oauthAccessToken.token, firstAccessHash)) + const [firstFamily] = await db + .select({ + id: schema.oauthTokenFamily.id, + consentId: schema.oauthTokenFamily.consentId, + currentGeneration: schema.oauthTokenFamily.currentGeneration, + }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, firstRefresh?.familyId ?? 'missing')) + + expect(firstRefresh).toMatchObject({ + token: firstRefreshHash, + generation: 0, + scopes: grantedScopes, + }) + expect(firstRefresh?.token).not.toBe(firstTokens.refresh_token) + expect(firstAccess).toEqual({ + token: firstAccessHash, + refreshId: firstRefresh?.id, + scopes: grantedScopes, + }) + expect(firstAccess?.token).not.toBe(firstTokens.access_token) + expect(firstFamily).toEqual({ + id: firstRefresh?.id, + consentId, + currentGeneration: 0, + }) + + const narrowedRefresh = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: provider.SIM_CLI_CLIENT_ID, + refresh_token: firstTokens.refresh_token, + scope: 'offline_access api:read', + }) + ) + ) + expect(narrowedRefresh.status).toBe(200) + const narrowedTokens = (await narrowedRefresh.json()) as TokenResponseBody + expect(narrowedTokens.scope).toBe('offline_access api:read') + + const nextRefreshHash = tokenStore.hashOAuthToken( + narrowedTokens.refresh_token.slice(provider.OAUTH_REFRESH_TOKEN_PREFIX.length) + ) + const nextAccessHash = tokenStore.hashOAuthToken( + narrowedTokens.access_token.slice(provider.OAUTH_ACCESS_TOKEN_PREFIX.length) + ) + const [nextRefresh] = await db + .select({ + id: schema.oauthRefreshToken.id, + familyId: schema.oauthRefreshToken.familyId, + generation: schema.oauthRefreshToken.generation, + scopes: schema.oauthRefreshToken.scopes, + }) + .from(schema.oauthRefreshToken) + .where(eq(schema.oauthRefreshToken.token, nextRefreshHash)) + const [nextAccess] = await db + .select({ + refreshId: schema.oauthAccessToken.refreshId, + scopes: schema.oauthAccessToken.scopes, + }) + .from(schema.oauthAccessToken) + .where(eq(schema.oauthAccessToken.token, nextAccessHash)) + + expect(nextRefresh).toMatchObject({ + familyId: firstRefresh?.id, + generation: 1, + scopes: grantedScopes, + }) + expect(nextAccess).toEqual({ + refreshId: nextRefresh?.id, + scopes: ['offline_access', 'api:read'], + }) + + const replay = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: provider.SIM_CLI_CLIENT_ID, + refresh_token: firstTokens.refresh_token, + }) + ) + ) + expect(replay.status).toBe(400) + await expect(replay.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, firstRefresh?.id ?? 'missing')) + ).toHaveLength(0) + + const secondVerifier = `${testId}-second-verifier-with-more-than-forty-three-characters` + const secondTokens = await exchangeAuthorizationCode( + await issueAuthorizationCode(secondVerifier), + secondVerifier + ) + expect( + await db + .select({ id: schema.verification.id }) + .from(schema.verification) + .where(like(schema.verification.value, `%${userId}%`)) + ).toHaveLength(0) + const secondRefreshHash = tokenStore.hashOAuthToken( + secondTokens.refresh_token.slice(provider.OAUTH_REFRESH_TOKEN_PREFIX.length) + ) + const [secondRefresh] = await db + .select({ familyId: schema.oauthRefreshToken.familyId }) + .from(schema.oauthRefreshToken) + .where(eq(schema.oauthRefreshToken.token, secondRefreshHash)) + + const revoked = await revokeToken( + createFormRequest( + '/api/auth/oauth2/revoke', + new URLSearchParams({ + client_id: provider.SIM_CLI_CLIENT_ID, + token: secondTokens.refresh_token, + }) + ) + ) + expect(revoked.status).toBe(200) + expect(await revoked.text()).toBe('') + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, secondRefresh?.familyId ?? 'missing')) + ).toHaveLength(0) + } finally { + if (issuedCodeHashes.length) { + await db + .delete(schema.verification) + .where(inArray(schema.verification.identifier, issuedCodeHashes)) + } + await db.delete(schema.verification).where(like(schema.verification.value, `%${userId}%`)) + await db.delete(schema.user).where(eq(schema.user.id, userId)) + await db + .delete(schema.rateLimitBucket) + .where( + inArray(schema.rateLimitBucket.key, [ + `route:oauth-provider-token:ip:${clientIp}`, + `route:oauth-provider-revoke:ip:${clientIp}`, + ]) + ) + requestUtilsMockFns.mockGetClientIp.mockReset() + requestUtilsMockFns.mockGetClientIp.mockReturnValue('127.0.0.1') + } + }, 60_000) +}) diff --git a/apps/sim/app/api/auth/oauth2/token/route.test.ts b/apps/sim/app/api/auth/oauth2/token/route.test.ts new file mode 100644 index 00000000000..f770be7b8a2 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/token/route.test.ts @@ -0,0 +1,358 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + betterAuthPost: vi.fn(async () => new Response('delegated', { status: 201 })), + enabled: true, + rateLimit: vi.fn(async () => null), + rotate: vi.fn(), + validateClient: vi.fn(), +})) + +vi.mock('better-auth/next-js', () => ({ + toNextJsHandler: () => ({ POST: mocks.betterAuthPost }), +})) +vi.mock('@/lib/auth', () => ({ auth: { handler: vi.fn() } })) +vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({ + withOAuthProviderIssuanceCompensation: (work: () => Promise) => work(), +})) +vi.mock('@/lib/core/config/env-flags', () => ({ + get isOAuthProviderEnabled() { + return mocks.enabled + }, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit })) +vi.mock('@/lib/auth/oauth-token-family', () => ({ + rotateOAuthRefreshToken: mocks.rotate, + validateOAuthClientCredentials: mocks.validateClient, +})) + +import { POST } from '@/app/api/auth/oauth2/token/route' + +function tokenRequest(body: string) { + return new NextRequest('http://localhost/api/auth/oauth2/token', { + method: 'POST', + body, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) +} + +describe('OAuth token route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.enabled = true + mocks.rotate.mockResolvedValue({ + success: true, + value: { + accessToken: 'sim_oat_next', + refreshToken: 'sim_ort_next', + expiresIn: 3600, + expiresAt: 2_000_000_000, + scope: 'offline_access api:read', + }, + }) + mocks.validateClient.mockResolvedValue({ success: true, value: undefined }) + }) + + it('delegates authorization-code exchange through an equivalent rebuilt request', async () => { + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + expect(response.status).toBe(201) + expect(mocks.betterAuthPost).toHaveBeenCalledOnce() + expect(mocks.validateClient).toHaveBeenCalledWith({ clientId: 'sim-cli', method: 'none' }) + const delegated = mocks.betterAuthPost.mock.calls[0]?.[0] + await expect(delegated.text()).resolves.toContain('grant_type=authorization_code') + expect(mocks.rateLimit).toHaveBeenCalledOnce() + }) + + it('rejects an authorization-code client using the wrong registered auth method', async () => { + mocks.validateClient.mockResolvedValue({ + success: false, + error: 'invalid_client', + description: 'Client authentication method does not match registration.', + }) + const basic = Buffer.from('client:secret').toString('base64') + const request = tokenRequest('grant_type=authorization_code&code=code') + request.headers.set('authorization', `Basic ${basic}`) + + const response = await POST(request) + + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toContain('Basic') + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + }) + + it('passes decoded Basic credentials through Better Auth body authentication', async () => { + const request = tokenRequest('grant_type=authorization_code&code=code') + request.headers.set('authorization', `basic ${Buffer.from('client:secret').toString('base64')}`) + + await POST(request) + + const delegated = mocks.betterAuthPost.mock.calls[0]?.[0] + expect(delegated.headers.has('authorization')).toBe(false) + const delegatedForm = new URLSearchParams(await delegated.text()) + expect(delegatedForm.get('client_id')).toBe('client') + expect(delegatedForm.get('client_secret')).toBe('secret') + }) + + it('normalizes delegated invalid-code and PKCE failures', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json({ error: 'invalid_grant', error_description: 'invalid code' }, { status: 401 }) + ) + const invalidCode = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=bad') + ) + expect(invalidCode.status).toBe(400) + expect(invalidCode.headers.get('cache-control')).toBe('no-store') + expect(invalidCode.headers.get('pragma')).toBe('no-cache') + + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { error: 'invalid_request', error_description: 'code verification failed' }, + { status: 401 } + ) + ) + const invalidVerifier = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=bad') + ) + expect(invalidVerifier.status).toBe(400) + await expect(invalidVerifier.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + }) + + it.each([ + ['invalid_request', 'Either code_verifier or client_secret is required'], + ['invalid_request', 'PKCE is required for this client'], + ['invalid_request', 'redirect_uri mismatch'], + ['invalid_user', 'missing user, user may have been deleted'], + ['invalid_user', 'session no longer exists'], + ])('normalizes a consumed code failure from %s to invalid_grant', async (error, description) => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json({ error, error_description: description }, { status: 401 }) + ) + + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + + expect(response.status).toBe(400) + expect(response.headers.has('www-authenticate')).toBe(false) + await expect(response.json()).resolves.toEqual({ + error: 'invalid_grant', + error_description: description, + }) + }) + + it.each(['short', `${'a'.repeat(42)}=`, 'a'.repeat(129)])( + 'rejects a malformed PKCE verifier before a code can be consumed', + async (codeVerifier) => { + const response = await POST( + tokenRequest( + `grant_type=authorization_code&client_id=sim-cli&code=code&code_verifier=${encodeURIComponent(codeVerifier)}` + ) + ) + + expect(response.status).toBe(400) + expect(response.headers.get('cache-control')).toBe('no-store') + await expect(response.json()).resolves.toEqual({ + error: 'invalid_grant', + error_description: 'Code verifier is invalid.', + }) + expect(mocks.validateClient).not.toHaveBeenCalled() + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + } + ) + + it('does not challenge an authenticated client for a code bound to another client', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { error: 'invalid_client', error_description: 'invalid client_id' }, + { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="oauth2"' } } + ) + ) + const request = tokenRequest('grant_type=authorization_code&code=code') + request.headers.set('authorization', `Basic ${Buffer.from('client:secret').toString('base64')}`) + + const response = await POST(request) + + expect(response.status).toBe(400) + expect(response.headers.has('www-authenticate')).toBe(false) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + }) + + it('returns a bounded OAuth error when delegated issuance fails without JSON', async () => { + mocks.betterAuthPost.mockResolvedValueOnce(new Response(null, { status: 500 })) + + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Token exchange failed.', + }) + }) + + it('normalizes Better Auth validation errors without exposing its internal shape', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { + message: '[body.redirect_uri] Invalid URL; received not-a-url', + code: 'VALIDATION_ERROR', + }, + { status: 400 } + ) + ) + + const response = await POST( + tokenRequest( + 'grant_type=authorization_code&client_id=sim-cli&code=code&redirect_uri=not-a-url' + ) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'invalid_request', + error_description: 'Token request is invalid.', + }) + }) + + it('redacts delegated JSON server failures to a bounded OAuth error', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { message: 'internal database details', stack: 'secret stack' }, + { status: 503 } + ) + ) + + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Token exchange failed.', + }) + }) + + it('rotates refresh tokens and preserves the Better Auth response shape', async () => { + const response = await POST( + tokenRequest( + 'grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_current&scope=api%3Aread' + ) + ) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + await expect(response.json()).resolves.toEqual({ + access_token: 'sim_oat_next', + expires_in: 3600, + expires_at: 2_000_000_000, + token_type: 'Bearer', + refresh_token: 'sim_ort_next', + scope: 'offline_access api:read', + }) + expect(mocks.rotate).toHaveBeenCalledWith({ + credentials: { clientId: 'sim-cli', method: 'none' }, + refreshToken: 'sim_ort_current', + requestedScopes: ['api:read'], + }) + }) + + it('renders protocol failures only after the rotation service returns', async () => { + mocks.rotate.mockResolvedValue({ + success: false, + error: 'invalid_grant', + description: 'Refresh token is invalid or has already been used.', + }) + const response = await POST( + tokenRequest('grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_old') + ) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + }) + + it('distinguishes a missing grant type from an unsupported grant type', async () => { + const missing = await POST(tokenRequest('client_id=sim-cli')) + expect(missing.status).toBe(400) + await expect(missing.json()).resolves.toMatchObject({ error: 'invalid_request' }) + + const unsupported = await POST(tokenRequest('grant_type=client_credentials&client_id=sim-cli')) + expect(unsupported.status).toBe(400) + await expect(unsupported.json()).resolves.toMatchObject({ error: 'unsupported_grant_type' }) + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + expect(mocks.rotate).not.toHaveBeenCalled() + }) + + it('reports a missing refresh token as an invalid request', async () => { + const response = await POST(tokenRequest('grant_type=refresh_token&client_id=sim-cli')) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.rotate).not.toHaveBeenCalled() + }) + + it.each(['authorization_code', 'refresh_token'])( + 'rejects an unenforceable resource audience for %s grants', + async (grantType) => { + const response = await POST( + tokenRequest( + `grant_type=${grantType}&client_id=sim-cli&code=code&refresh_token=sim_ort_current&resource=https%3A%2F%2Fapi.example` + ) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + expect(mocks.rotate).not.toHaveBeenCalled() + } + ) + + it('applies rate admission before parsing and prevents caching a refusal', async () => { + mocks.rateLimit.mockResolvedValueOnce(new Response('limited', { status: 429 })) + const request = new NextRequest('http://localhost/api/auth/oauth2/token', { + method: 'POST', + body: '{}', + headers: { 'content-type': 'application/json' }, + }) + + const response = await POST(request) + + expect(response.status).toBe(429) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('pragma')).toBe('no-cache') + }) + + it.each([ + ['authorization code exchange', () => mocks.betterAuthPost.mockRejectedValueOnce(new Error())], + ['refresh rotation', () => mocks.rotate.mockRejectedValueOnce(new Error())], + ])('normalizes an unexpected %s failure', async (grant, fail) => { + fail() + const body = + grant === 'authorization code exchange' + ? 'grant_type=authorization_code&client_id=sim-cli&code=code' + : 'grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_current' + + const response = await POST(tokenRequest(body)) + + expect(response.status).toBe(500) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('pragma')).toBe('no-cache') + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Token endpoint failed.', + }) + }) + + it('does not expose the custom endpoint while the provider is disabled', async () => { + mocks.enabled = false + const response = await POST( + tokenRequest('grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_old') + ) + expect(response.status).toBe(404) + expect(mocks.rotate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/token/route.ts b/apps/sim/app/api/auth/oauth2/token/route.ts new file mode 100644 index 00000000000..03557898766 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/token/route.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { toNextJsHandler } from 'better-auth/next-js' +import { type NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { + buildDelegatedOAuthRequest, + isValidOAuthCodeVerifier, + missingOAuthParameterResponse, + normalizeDelegatedOAuthTokenResponse, + oauthErrorResponse, + oauthProtocolErrorResponse, + parseOAuthFormRequest, + parseRequestedScopes, + unsupportedGrantResponse, +} from '@/lib/auth/oauth-protocol-request' +import { withOAuthProviderIssuanceCompensation } from '@/lib/auth/oauth-provider-adapter-guard' +import { + rotateOAuthRefreshToken, + validateOAuthClientCredentials, +} from '@/lib/auth/oauth-token-family' +import { isOAuthProviderEnabled } from '@/lib/core/config/env-flags' +import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OAuthTokenEndpoint') +const { POST: betterAuthPOST } = toNextJsHandler(auth.handler) + +const TOKEN_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 20, + refillRate: 20, + refillIntervalMs: 60_000, +} + +/** + * Delegates authorization-code exchange to Better Auth and owns refresh + * rotation, whose per-login replay containment requires one PostgreSQL + * transaction that the provider does not expose as a configuration hook. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + if (!isOAuthProviderEnabled) { + return NextResponse.json( + { error: 'OAuth provider is not enabled' }, + { status: 404, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } + + try { + const rateLimited = await enforceIpRateLimit('oauth-provider-token', request, TOKEN_RATE_LIMIT) + if (rateLimited) { + rateLimited.headers.set('Cache-Control', 'no-store') + rateLimited.headers.set('Pragma', 'no-cache') + return rateLimited + } + const parsed = await parseOAuthFormRequest(request) + if (!parsed.success) return parsed.response + const grantType = parsed.value.form.get('grant_type') + if (!grantType) return missingOAuthParameterResponse('grant_type') + if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { + return unsupportedGrantResponse(grantType) + } + if (parsed.value.form.has('resource')) { + return oauthErrorResponse('invalid_request', 'The resource parameter is not supported.') + } + if (grantType === 'authorization_code') { + const codeVerifier = parsed.value.form.get('code_verifier') + if (codeVerifier !== null && !isValidOAuthCodeVerifier(codeVerifier)) { + return oauthErrorResponse('invalid_grant', 'Code verifier is invalid.') + } + if (!parsed.value.credentials) { + return oauthErrorResponse('invalid_client', 'Client authentication is required.') + } + const authenticated = await validateOAuthClientCredentials(parsed.value.credentials) + if (!authenticated.success) { + return oauthProtocolErrorResponse( + authenticated.error, + authenticated.description, + parsed.value.credentials.method + ) + } + const response = await withOAuthProviderIssuanceCompensation(() => + betterAuthPOST(buildDelegatedOAuthRequest(request, parsed.value)) + ) + return normalizeDelegatedOAuthTokenResponse(response, parsed.value.credentials.method) + } + + if (!parsed.value.credentials) { + return oauthErrorResponse('invalid_client', 'Client authentication is required.') + } + const refreshToken = parsed.value.form.get('refresh_token') + if (!refreshToken) return missingOAuthParameterResponse('refresh_token') + + const result = await rotateOAuthRefreshToken({ + credentials: parsed.value.credentials, + refreshToken, + requestedScopes: parseRequestedScopes(parsed.value.form), + }) + if (!result.success) { + return oauthProtocolErrorResponse( + result.error, + result.description, + parsed.value.credentials.method + ) + } + + return NextResponse.json( + { + access_token: result.value.accessToken, + expires_in: result.value.expiresIn, + expires_at: result.value.expiresAt, + token_type: 'Bearer', + refresh_token: result.value.refreshToken, + scope: result.value.scope, + }, + { headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } catch (error) { + logger.error('OAuth token endpoint failed', { error: toError(error) }) + return oauthErrorResponse('server_error', 'Token endpoint failed.', 500) + } +}) diff --git a/apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts b/apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts new file mode 100644 index 00000000000..2eca2b3eab6 --- /dev/null +++ b/apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts @@ -0,0 +1,31 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runCleanupOAuthTokens } from '@/background/cleanup-oauth-tokens' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('CleanupOAuthTokensAPI') + +/** + * Retention sweep for lapsed OAuth token rows. Issuance state does not gate + * retention: a deployment that disables the provider must still drain rows + * created while it was enabled. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'OAuth token cleanup') + if (authError) return authError + + try { + const result = await runCleanupOAuthTokens() + return NextResponse.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to sweep expired OAuth tokens', { error }) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to sweep expired OAuth tokens') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 38982824cf7..4616418550a 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -237,6 +237,7 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom switch (principal.kind) { case 'session': case 'personal_api_key': + case 'oauth_access_token': return principal.userId case 'workspace_api_key': if (!workspaceId || principal.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/users/me/authorized-apps/[clientId]/route.ts b/apps/sim/app/api/users/me/authorized-apps/[clientId]/route.ts new file mode 100644 index 00000000000..7f7752b05cb --- /dev/null +++ b/apps/sim/app/api/users/me/authorized-apps/[clientId]/route.ts @@ -0,0 +1,24 @@ +import { revokeAuthorizedAppContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { revokeAuthorizedAppUseCase } from '@/lib/users/application/authorized-apps' +import { userAccountOperations } from '@/lib/users/application/operations' + +export const dynamic = 'force-dynamic' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeAuthorizedAppContract, + auth: internalSessionAuth, + operation: userAccountOperations.revokeAuthorizedApp, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated current-user settings mutation', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ clientId: params.clientId }), + useCase: revokeAuthorizedAppUseCase, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/users/me/authorized-apps/route.ts b/apps/sim/app/api/users/me/authorized-apps/route.ts new file mode 100644 index 00000000000..6feffd04c23 --- /dev/null +++ b/apps/sim/app/api/users/me/authorized-apps/route.ts @@ -0,0 +1,24 @@ +import { listAuthorizedAppsContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listAuthorizedAppsUseCase } from '@/lib/users/application/authorized-apps' +import { userAccountOperations } from '@/lib/users/application/operations' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: listAuthorizedAppsContract, + auth: internalSessionAuth, + operation: userAccountOperations.readAuthorizedApps, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated current-user settings read', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => ({}), + useCase: listAuthorizedAppsUseCase, + present: (apps) => ({ apps }), +}) diff --git a/apps/sim/app/api/v1/knowledge/utils.ts b/apps/sim/app/api/v1/knowledge/utils.ts index 95655cbe756..8d721d0ec4a 100644 --- a/apps/sim/app/api/v1/knowledge/utils.ts +++ b/apps/sim/app/api/v1/knowledge/utils.ts @@ -58,7 +58,7 @@ export async function resolveKnowledgeBase( */ export async function resolveV1KnowledgeAccessScope( userId: string, - rateLimit: { keyType?: 'personal' | 'workspace' }, + rateLimit: { keyType?: 'personal' | 'workspace' | 'oauth_access_token' }, workspaceId: string | undefined ): Promise { if (rateLimit.keyType === 'workspace') return WORKSPACE_ACCESS_SCOPE diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index dbde4f2837a..d390a2403f9 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -73,7 +73,12 @@ export interface RateLimitResult { retryAfterMs?: number userId?: string workspaceId?: string - keyType?: 'personal' | 'workspace' + /** + * `oauth_access_token` never arises on v1, which authenticates API keys only; + * it is here because the v2 builders record their rate-limit snapshot in this + * shape. + */ + keyType?: 'personal' | 'workspace' | 'oauth_access_token' principal?: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal error?: string } diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index af7f28a3930..0e724a2d61d 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -273,7 +273,7 @@ describe('POST /api/v2/chat', () => { it('rejects a missing or invalid API key', async () => { mockAuthenticateV2ApiKey.mockRejectedValue( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) @@ -337,8 +337,8 @@ describe('POST /api/v2/chat', () => { }) /** - * The route only ever runs for a personal API key, and `admitV2Request` never - * authorizes, so both halves of the funnel's personal-key policy have to be + * The route only runs for a user-held credential, and `admitV2Request` never + * authorizes, so both halves of the funnel's credential policy have to be * repeated here. The workspace column is the first half. */ it('answers 403 when the workspace has switched personal API keys off', async () => { diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 17932605f92..f4f09bbaaaa 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -1,3 +1,8 @@ +import { + isUserCredentialPrincipal, + type OAuthAccessTokenPrincipal, + type PersonalApiKeyPrincipal, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -37,9 +42,10 @@ import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explic import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { + ForbiddenOperationError, forbiddenErrorDetails, PersonalApiKeysDisabledError, - requirePersonalApiKeysAllowed, + requireUserCredentialCapabilities, type WorkspaceAuthorizationContext, } from '@/lib/core/application' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' @@ -87,8 +93,9 @@ function deriveConversationTitle(message: string): string | undefined { } /** - * The two personal-API-key checks `authorizeWorkspaceOperation` applies, for the - * one route that never reaches it, or `null` when the key may proceed. + * The two user-credential checks `authorizeWorkspaceOperation` applies, for + * the one route that never reaches it, or `null` when the credential may + * proceed. * * The group half runs through the same {@link requirePersonalApiKeysAllowed} the * funnel and the billing reads call, so a third wording of the same refusal @@ -96,19 +103,19 @@ function deriveConversationTitle(message: string): string | undefined { * renders its own v2 envelope, and the detail code is read off the error so the * column refusal and the group refusal answer with one code. */ -async function personalApiKeyPolicyRefusal( - userId: string, +async function userCredentialPolicyRefusal( + principal: PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal, context: WorkspaceAuthorizationContext ): Promise { - const refuse = (error: PersonalApiKeysDisabledError) => + const refuse = (error: ForbiddenOperationError) => v2Error('FORBIDDEN', error.message, { details: forbiddenErrorDetails(error) }) if (!context.allowPersonalApiKeys) return refuse(new PersonalApiKeysDisabledError()) try { - await requirePersonalApiKeysAllowed(userId, context) + await requireUserCredentialCapabilities(principal, context) } catch (error) { - if (error instanceof PersonalApiKeysDisabledError) return refuse(error) + if (error instanceof ForbiddenOperationError) return refuse(error) throw error } return null @@ -164,8 +171,8 @@ function buildChatResultPayload( * POST /api/v2/chat * * One conversational turn against the same headless execution path as the Sim - * Chat block (`/api/mothership/execute`), authenticated with a personal API key - * instead of the executor's internal JWT. JSON callers get one final response; + * Chat block (`/api/mothership/execute`), authenticated with an OAuth access + * token or personal API key instead of the executor's internal JWT. JSON callers get one final response; * NDJSON callers (`Accept: application/x-ndjson`) get heartbeats and incremental * `chunk` events followed by a `final` event, so long-running turns do not look * idle to intermediaries. @@ -185,8 +192,8 @@ export const POST = withRouteHandler( if (!admission.success) return admission.response const { principal } = admission.auth - if (principal.kind !== 'personal_api_key') { - return v2Error('FORBIDDEN', 'Chat requires a personal API key', { + if (!isUserCredentialPrincipal(principal)) { + return v2Error('FORBIDDEN', 'Chat requires a personal API key or an OAuth access token', { details: { code: 'PRINCIPAL_KIND_NOT_PERMITTED' }, }) } @@ -205,16 +212,17 @@ export const POST = withRouteHandler( const userPermission = workspaceAccess.permission /** - * permission-group-enforced: personal_api_key.use — this route only ever - * runs for a personal API key, and `admitV2Request` authenticates one - * without authorizing it, so the funnel's personal-key policy has to be - * repeated here or the same key `authorizeWorkspaceOperation` refuses - * still starts a chat turn. + * permission-group-enforced: personal_api_key.use — this route runs for a + * user-held credential, and `admitV2Request` authenticates one without + * authorizing it, so the funnel's user-credential policy has to be repeated + * here or the same principal `authorizeWorkspaceOperation` refuses still + * starts a chat turn. * * Both halves, because they combine with AND: the workspace column is the * coarse switch every workspace has, and the group key narrows it further * for one cohort inside an enterprise organization. Either one saying no - * is a no, and checking only `copilot.use` applied neither. + * is a no, and checking only `copilot.use` applied neither. A CLI token + * passes `cli.use` in the same call, for the same reason. * * Both run after workspace access rather than before it, unlike the * funnel, which can afford to check the column first because its caller @@ -222,12 +230,12 @@ export const POST = withRouteHandler( * it, and answering later only ever conceals more: a caller with no reach * into the workspace is refused without learning how it is configured. */ - const personalKeyRefusal = await personalApiKeyPolicyRefusal(userId, { + const credentialRefusal = await userCredentialPolicyRefusal(principal, { workspaceId, workspaceOrganizationId: workspaceAccess.workspace?.organizationId ?? null, allowPersonalApiKeys: workspaceAccess.workspace?.allowPersonalApiKeys ?? false, }) - if (personalKeyRefusal) return personalKeyRefusal + if (credentialRefusal) return credentialRefusal /** * permission-group-enforced: copilot.use — read off the operation so this diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 68a57cab85b..f7023b562a6 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -104,7 +104,7 @@ describe('GET /api/v2/files/[fileId]/share', () => { it('authenticates and rate-limits before parsing or executing', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await callGet() diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts index 1a9b32f4d7b..b798b626073 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { V2_WRITABLE_TAG_SLOTS, type V2UpdateKnowledgeDocumentBody, @@ -136,7 +137,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: deleteKnowledgeDocument, onSuccess: ({ principal, input }) => { - if (principal.kind === 'personal_api_key') { + if (isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_document_deleted', diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts index 70d57103e8f..c2b141ef844 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { omit } from '@sim/utils/object' import { parseV2KnowledgeTagFiltersParam, @@ -243,7 +244,7 @@ export const POST = defineV2BodyLifecycleRoute({ mimeType: result.document.mimeType, fileSize: result.document.fileSize, }) - if (principal.kind === 'personal_api_key') { + if (isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_document_uploaded', diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts index c95c245fcbf..65736ace100 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { PlatformEvents } from '@/lib/core/telemetry' @@ -22,7 +23,7 @@ export const POST = defineV2JsonRoute({ }), useCase: completeKnowledgeDocumentUpload, onSuccess: ({ principal, result }) => { - if (result.value.created && principal.kind === 'personal_api_key') { + if (result.value.created && isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_document_uploaded', diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index bfa26e6ccdd..31fcbd08ccd 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, @@ -105,7 +106,7 @@ export const POST = defineV2JsonRoute({ name: knowledgeBase.name, workspaceId: knowledgeBase.workspaceId ?? undefined, }) - if (principal.kind === 'personal_api_key') { + if (isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_created', diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 4cb0bb11edb..2e666f359df 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -14,6 +14,8 @@ export const V2_KNOWLEDGE_SEARCH_MAX_BODY_BYTES = 2 * 1024 * 1024 export const POST = defineV2JsonRoute({ contract: v2SearchKnowledgeContract, auth: v2ApiKeyAuth, + /** Search is resource-read-only even though metering writes a usage record. */ + readOnly: true, operation: knowledgeOperations.search, rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUsageAuthorization, diff --git a/apps/sim/app/api/v2/lib/response.test.ts b/apps/sim/app/api/v2/lib/response.test.ts index b45aa898340..ad892f4974e 100644 --- a/apps/sim/app/api/v2/lib/response.test.ts +++ b/apps/sim/app/api/v2/lib/response.test.ts @@ -2,8 +2,33 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { InsufficientScopeError } from '@/lib/core/application' import { HttpError } from '@/lib/core/utils/http-error' -import { v2Error, v2HttpError, v2RateLimitError } from '@/app/api/v2/lib/response' +import { + v2CaughtOrchestrationError, + v2Error, + v2HttpError, + v2RateLimitError, +} from '@/app/api/v2/lib/response' + +describe('v2 403 insufficient_scope challenge', () => { + /** + * RFC 6750 §3.1: a token that authenticated but lacks the scope gets a 403 + * whose challenge names the scope to ask for, alongside the closed detail + * code so a client can branch without parsing prose. + */ + it('names the missing scope in WWW-Authenticate and the detail code', async () => { + const response = v2CaughtOrchestrationError(new InsufficientScopeError('api:write')) + + expect(response?.status).toBe(403) + expect(response?.headers.get('WWW-Authenticate')).toBe( + 'Bearer realm="Sim API", error="insufficient_scope", scope="api:write"' + ) + await expect(response?.json()).resolves.toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'INSUFFICIENT_SCOPE' } }, + }) + }) +}) describe('v2Error retry guidance', () => { it('sends Retry-After on 503 so a client does not retry a degraded dependency immediately', () => { @@ -62,10 +87,12 @@ describe('v2 401 authentication challenge', () => { * without a test noticing. The reachability tests below stay loose on purpose * — they pin that the header arrives down each path, not its value twice. */ - const EXPECTED_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' + const EXPECTED_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key", Bearer realm="Sim API"' const challenge = () => - v2Error('UNAUTHORIZED', 'API key required').headers.get('WWW-Authenticate') + v2Error('UNAUTHORIZED', 'API key or OAuth access token required').headers.get( + 'WWW-Authenticate' + ) it('sends a challenge on 401', () => { const response = v2Error('UNAUTHORIZED', 'Invalid API key') @@ -74,6 +101,23 @@ describe('v2 401 authentication challenge', () => { expect(response.headers.get('WWW-Authenticate')).toBe(EXPECTED_CHALLENGE) }) + /** + * RFC 6750 §3.1: `error="invalid_token"` only when a bearer token was + * presented and refused. A caller that sent nothing is told what would work, + * and the scheme it tried leads the list. + */ + it('leads with an invalid_token bearer challenge when a bearer token was refused', () => { + const response = v2Error('UNAUTHORIZED', 'Invalid access token', { authChallenge: 'bearer' }) + + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer realm="Sim API", error="invalid_token", SimApiKey realm="Sim API", header="x-api-key"' + ) + }) + + it('never marks a bearer token invalid when none was presented', () => { + expect(challenge()).not.toContain('invalid_token') + }) + it('names the x-api-key header, the only channel v2 actually reads', () => { expect(challenge()).toContain('x-api-key') }) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index b259db824e4..30eaa7bb044 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -9,7 +9,11 @@ import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/ import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' -import { forbiddenErrorDetails } from '@/lib/core/application' +import { + forbiddenErrorDetails, + InsufficientScopeError, + OAuthAccessTokenExpiredError, +} from '@/lib/core/application' import { asOrchestrationError, OrchestrationError, @@ -71,26 +75,54 @@ const RETRY_AFTER_SECONDS_BY_STATUS: Partial> = { } /** - * The challenge every v2 `401` carries, so a 401 is a complete one. - * - * RFC 9110 §11.6.1 makes `WWW-Authenticate` a MUST on 401 — a 401 without it is - * a refusal that never says what would have been accepted, and a generic HTTP - * client has nothing to react to. + * The API-key half of every v2 `401` challenge. * * The scheme name is deliberately Sim-specific rather than a registered one. - * v2 authenticates from the `x-api-key` header and accepts no `Authorization` - * scheme at all — `Authorization: Bearer ` is not a channel here — so - * `Bearer` and `Basic` would both be false advertising. `Basic` is worse than - * false: a browser reacts to it by opening a native credential prompt that - * cannot produce an API key. An unregistered scheme is what remains, and it is - * legal: §11.6.1's grammar requires *an* `auth-scheme` token, not a registered - * one. Every challenge implies "retry via `Authorization: …`" by - * construction, so the token is chosen to be one no client has a built-in - * handler for — the challenge surfaces to a human instead of triggering an - * automatic retry down a channel v2 does not read — and the real channel is - * named outright in the `header` parameter beside it. + * An API key travels in `x-api-key`, not in `Authorization`, so `Basic` would + * be false advertising — and worse than false: a browser reacts to it by + * opening a native credential prompt that cannot produce an API key. An + * unregistered scheme is legal (RFC 9110 §11.6.1's grammar requires *an* + * `auth-scheme` token, not a registered one) and is chosen so no client has a + * built-in handler for it: the challenge surfaces to a human, and the real + * channel is named outright in the `header` parameter beside it. + */ +const V2_API_KEY_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' + +/** + * The `WWW-Authenticate` value for a v2 `401`, so a 401 is a complete one. + * + * RFC 9110 §11.6.1 makes the header a MUST on 401 — a 401 without it is a + * refusal that never says what would have been accepted. v2 reads two + * credentials, so the header lists two challenges (§11.6.1 allows a list): + * `Bearer`, which is a registered scheme because Sim's OAuth access tokens + * really do travel as `Authorization: Bearer`, and the API-key scheme above. + * + * The one the caller tried leads, and only a bearer that was presented and + * refused carries `error="invalid_token"` (RFC 6750 §3.1): a request that sent + * nothing is told what would work, not what was wrong with a token it never + * offered. */ -const V2_AUTH_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' +function v2AuthChallenge(tried: 'api_key' | 'bearer' = 'api_key'): string { + const bearer = + tried === 'bearer' ? 'Bearer realm="Sim API", error="invalid_token"' : 'Bearer realm="Sim API"' + return tried === 'bearer' + ? `${bearer}, ${V2_API_KEY_CHALLENGE}` + : `${V2_API_KEY_CHALLENGE}, ${bearer}` +} + +/** + * The `403` for a bearer token that authenticated but was not granted the scope + * the request needs. The challenge names the scope to ask for (RFC 6750 §3.1) + * and the detail code lets a client branch without parsing prose. + */ +export function v2InsufficientScope(error: InsufficientScopeError): NextResponse { + return v2Error('FORBIDDEN', error.message, { + details: { code: error.detailCode }, + headers: { + 'WWW-Authenticate': `Bearer realm="Sim API", error="insufficient_scope", scope="${error.requiredScope}"`, + }, + }) +} type RateLimitHeaderSource = Pick @@ -142,6 +174,8 @@ interface V2ErrorOptions { status?: number details?: unknown headers?: Record + /** For a 401: which credential the caller presented, so its challenge leads. */ + authChallenge?: 'api_key' | 'bearer' /** * Suppresses the code's default `Retry-After` for a failure whose outcome is * *unknown* rather than *absent*. @@ -175,7 +209,7 @@ export function v2Error( status, headers: { ...PRIVATE_NO_STORE, - ...(status === 401 ? { 'WWW-Authenticate': V2_AUTH_CHALLENGE } : {}), + ...(status === 401 ? { 'WWW-Authenticate': v2AuthChallenge(options.authChallenge) } : {}), ...(retryAfterSeconds === undefined ? {} : { 'Retry-After': retryAfterSeconds.toString() }), ...options.headers, }, @@ -498,6 +532,10 @@ export function v2ErrorForOrchestration( export function v2CaughtOrchestrationError(error: unknown): NextResponse | null { const classified = asOrchestrationError(error) if (!classified) return null + if (classified instanceof InsufficientScopeError) return v2InsufficientScope(classified) + if (classified instanceof OAuthAccessTokenExpiredError) { + return v2Error('UNAUTHORIZED', classified.message, { authChallenge: 'bearer' }) + } return v2ErrorForOrchestration( classified.code, classified.message, diff --git a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts index e2cb7d09c6f..d81ec057a3d 100644 --- a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2DeleteMcpServerContract, v2GetMcpServerContract, @@ -61,7 +62,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: deleteMcpServerUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'mcp_server_disconnected', diff --git a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.ts index 4265ea60b5a..a8efdda4707 100644 --- a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.ts @@ -22,6 +22,7 @@ export const GET = defineV2JsonRoute({ operation: mcpServerOperations.discoverTools, auth: v2ApiKeyAuth, headSafe: false, + write: true, rateLimit: v2RateLimits.publicApi, errorPolicy: v2McpToolDiscoveryErrorPolicy, mapInput: ({ params, query }) => ({ diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 91ea160afc2..38484db7dba 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CreateMcpServerContract, v2ListMcpServersContract, @@ -68,7 +69,7 @@ export const POST = defineV2JsonRoute({ mapInput: ({ body }) => ({ ...body, source: 'api' as const }), useCase: createMcpServerUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key' || result.updated) return + if (!isUserCredentialPrincipal(principal) || result.updated) return captureServerEvent( principal.userId, 'mcp_server_connected', diff --git a/apps/sim/app/api/v2/meta/route.test.ts b/apps/sim/app/api/v2/meta/route.test.ts index 56ce546cdbf..aa6b7f2f353 100644 --- a/apps/sim/app/api/v2/meta/route.test.ts +++ b/apps/sim/app/api/v2/meta/route.test.ts @@ -81,7 +81,7 @@ describe('GET /api/v2/meta', () => { it('requires authentication', async () => { v2RouteMocks.authenticate.mockRejectedValue( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await GET(new NextRequest('http://localhost:3000/api/v2/meta')) diff --git a/apps/sim/app/api/v2/meta/route.ts b/apps/sim/app/api/v2/meta/route.ts index 7dbf75b0c2d..b32be28d85a 100644 --- a/apps/sim/app/api/v2/meta/route.ts +++ b/apps/sim/app/api/v2/meta/route.ts @@ -11,7 +11,7 @@ import { export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/meta — Report the calling key's API availability and lifecycle. */ +/** GET /api/v2/meta — Report the calling credential's API availability and lifecycle. */ export const GET = defineV2JsonRoute({ contract: v2GetMetaContract, auth: v2ApiKeyAuth, diff --git a/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts b/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts index dbd76d080b3..d5feeb07d91 100644 --- a/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { type V2SkillEditor, v2GrantSkillEditorContract, @@ -88,7 +89,7 @@ export const POST = defineV2JsonRoute({ }), useCase: grantSkillEditorUseCase, onSuccess: ({ principal, input, result }) => { - if (!result.created || principal.kind !== 'personal_api_key') return + if (!result.created || !isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_shared', @@ -113,7 +114,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: revokeSkillEditorUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_unshared', diff --git a/apps/sim/app/api/v2/skills/[skillId]/route.ts b/apps/sim/app/api/v2/skills/[skillId]/route.ts index 6414280f4c4..7786b43d439 100644 --- a/apps/sim/app/api/v2/skills/[skillId]/route.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2DeleteSkillContract, v2GetSkillContract, @@ -51,7 +52,7 @@ export const PATCH = defineV2JsonRoute({ }), useCase: updateSkillUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_updated', @@ -81,7 +82,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: deleteSkillUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_deleted', diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 42de2765968..e0d932d4577 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { @@ -69,7 +70,7 @@ export const POST = defineV2JsonRoute({ mapInput: ({ body }) => ({ ...body, source: 'api' as const }), useCase: createSkillUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_created', diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts index f71712224dc..bcc25c9c561 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts @@ -24,6 +24,8 @@ export const revalidate = 0 export const POST = defineV2JsonRoute({ contract: v2QueryRowsCountContract, operation: tableOperations.queryRows, + /** POST carries structured filters but performs a read-only query. */ + readOnly: true, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableRowsErrorPolicy, diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 79695721060..28790d23d2a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -27,6 +27,8 @@ function queryRowCursorScope(tableId: string): string { export const POST = defineV2JsonRoute({ contract: v2QueryRowsContract, operation: tableOperations.queryRows, + /** POST carries structured filters but performs a read-only query. */ + readOnly: true, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableRowsErrorPolicy, diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts index ed0ca3ea5d7..a44e4a8e10c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts @@ -11,6 +11,8 @@ export const revalidate = 0 export const POST = defineV2JsonRoute({ contract: v2SearchTableRowsContract, operation: tableOperations.searchRows, + /** POST carries structured filters but performs a read-only search. */ + readOnly: true, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableRowsErrorPolicy, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts index 59aa0fd0e9d..6dc563160c9 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2DeployWorkflowContract, v2UndeployWorkflowContract, @@ -67,7 +68,7 @@ export const DELETE = defineV2JsonRoute({ * would report a succeeded undeploy as a 500 rather than catching anything. */ onSuccess: ({ principal, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'workflow_undeployed', diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 6449e170f57..e1890d05097 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -218,6 +218,14 @@ function callPublicExecute(body: Record, headers: Record) { + const req = createMockRequest('POST', body, { + 'Content-Type': 'application/json', + Authorization: 'Bearer sim_oat_token', + }) + return POST(req, { params: Promise.resolve({ workflowId: 'workflow-1' }) }) +} + /** * Queues the two reads the anonymous public path makes, in order: the workflow's * public-API eligibility, then the workspace billing account it runs as. Keeping @@ -468,6 +476,32 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() }) + it('dispatches an OAuth manual trigger run through the same manual operation', async () => { + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { + kind: 'oauth_access_token', + userId: 'actor-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['api:write'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }, + rateLimitSubjectIds: ['oauth-token:token-1', 'user:actor-1'], + rateLimitSubscription: null, + keyType: 'oauth_access_token', + keyExpiresAt: new Date('2099-01-01T00:00:00.000Z'), + }) + + const response = await callOAuthExecute({ run: { source: 'manual' } }) + + expect(response.status).toBe(200) + expect(mockExecuteManualTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ kind: 'oauth_access_token', clientId: 'sim-cli' }), + }) + ) + }) + it('returns the typed workspace-key denial for manual execution', async () => { mockExecuteManualTrigger.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) @@ -826,7 +860,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { expect(response.status).toBe(401) expect((await response.json()).error.message).toBe( - 'Manual execution requires a personal API key' + 'Manual execution requires an OAuth access token or personal API key' ) expect(mockExecuteManualTrigger).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index b3271d894a1..ad737bbe6dc 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -230,7 +230,10 @@ export const POST = withRouteHandler( const manualRun = body.run?.source === 'manual' ? body.run : undefined if (manualRun && !apiKeyPrincipal) { - return v2Error('UNAUTHORIZED', 'Manual execution requires a personal API key') + return v2Error( + 'UNAUTHORIZED', + 'Manual execution requires an OAuth access token or personal API key' + ) } if (manualRun && body.async) { return v2Error('BAD_REQUEST', 'Manual execution does not support async mode') @@ -244,7 +247,7 @@ export const POST = withRouteHandler( } if (body.async && isPublicApiAccess) { - return v2Error('BAD_REQUEST', 'Async execution requires an API key') + return v2Error('BAD_REQUEST', 'Async execution requires an OAuth access token or API key') } if (body.async && body.stream) { return v2Error('BAD_REQUEST', 'async and stream cannot be combined') diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts index c7c0db44799..bfd3758957a 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts @@ -336,7 +336,7 @@ describe('v2 run detail and cancel adapters', () => { it('rejects missing API keys before reading the run', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await callStatus() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps.tsx new file mode 100644 index 00000000000..8ab5da8c515 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps.tsx @@ -0,0 +1,120 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { formatDate } from '@sim/utils/formatting' +import type { AuthorizedApp } from '@/lib/api/contracts/user' +import { summarizeOAuthAccess } from '@/lib/auth/oauth-provider' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { useAuthorizedApps, useRevokeAuthorizedApp } from '@/hooks/queries/oauth-provider' + +const EMPTY_APPS: AuthorizedApp[] = [] + +/** + * The apps this account has authorized through Sim's OAuth provider. Revoking + * one withdraws its consent and kills every token it holds, so the next + * request it makes fails and the next sign-in asks again. + */ +export function AuthorizedApps() { + const apps = useAuthorizedApps() + const revoke = useRevokeAuthorizedApp() + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [pendingRevokeClientId, setPendingRevokeClientId] = useState(null) + + const list = apps.data ?? EMPTY_APPS + const pendingRevoke = list.find((app) => app.clientId === pendingRevokeClientId) ?? null + const term = searchTerm.trim().toLowerCase() + const filtered = term ? list.filter((app) => app.name.toLowerCase().includes(term)) : list + + const confirmRevoke = () => { + if (!pendingRevokeClientId) return + const appName = pendingRevoke?.name ?? 'app' + revoke.mutate(pendingRevokeClientId, { + onSuccess: () => toast.success(`Revoked ${appName}`), + onError: (error) => toast.error(getErrorMessage(error, 'Failed to revoke access')), + /** Keep the modal open so its pending state remains visible through the mutation. */ + onSettled: () => setPendingRevokeClientId(null), + }) + } + + return ( + <> + + {apps.isError && apps.data === undefined ? ( + + {getErrorMessage(apps.error, 'Failed to load authorized apps')} + + ) : apps.isPending ? null : list.length === 0 ? ( + No apps have access to your account + ) : filtered.length === 0 ? ( + + No apps found matching "{searchTerm}" + + ) : ( +
+ {filtered.map((app) => ( + + {`authorized ${formatDate(new Date(app.authorizedAt))}`} + + } + trailing={ + setPendingRevokeClientId(app.clientId), + }, + ]} + /> + } + /> + ))} +
+ )} +
+ + { + if (!open) setPendingRevokeClientId(null) + }} + srTitle='Revoke access' + title='Revoke access' + text={[ + 'Revoking ', + { text: pendingRevoke?.name ?? 'this app', bold: true }, + ' ', + { text: 'immediately signs it out everywhere.', error: true }, + ' You will have to authorize it again to reconnect.', + ]} + confirm={{ + label: 'Revoke', + onClick: confirmRevoke, + pending: revoke.isPending, + pendingLabel: 'Revoking...', + }} + /> + + ) +} diff --git a/apps/sim/background/cleanup-oauth-tokens.test.ts b/apps/sim/background/cleanup-oauth-tokens.test.ts new file mode 100644 index 00000000000..309c9b21d5a --- /dev/null +++ b/apps/sim/background/cleanup-oauth-tokens.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + select: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + txSelect: vi.fn(), + txDelete: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mocks.select, + delete: mocks.delete, + transaction: mocks.transaction, + }, +})) + +import { + OAUTH_TOKEN_RETENTION_DAYS, + runCleanupOAuthTokens, +} from '@/background/cleanup-oauth-tokens' + +/** A select chain that answers `rows` once awaited, capturing its `where`. */ +function selectChain(rows: unknown[], captured: unknown[]) { + const chain: Record = {} + chain.from = () => chain + chain.where = (clause: unknown) => { + captured.push(clause) + return chain + } + chain.orderBy = () => chain + chain.limit = () => chain + chain.for = () => chain + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve) + return chain +} + +describe('runCleanupOAuthTokens', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.transaction.mockImplementation((work) => + work({ select: mocks.txSelect, delete: mocks.txDelete }) + ) + mocks.txSelect.mockImplementation(() => selectChain([], [])) + }) + + it('deletes exactly the expired rows it selected, and reports both counts', async () => { + const deletedFrom: unknown[] = [] + mocks.select + .mockReturnValueOnce( + selectChain( + [ + { + id: 'r1', + clientId: 'client-1', + sessionId: null, + userId: 'user-1', + consentId: null, + }, + { + id: 'r2', + clientId: 'client-1', + sessionId: null, + userId: 'user-1', + consentId: null, + }, + ], + [] + ) + ) + .mockReturnValueOnce(selectChain([{ id: 'a1' }], [])) + mocks.txDelete.mockImplementation((table: unknown) => ({ + where: (clause: unknown) => { + deletedFrom.push([table, clause]) + return { returning: () => Promise.resolve([{ id: 'r1' }, { id: 'r2' }]) } + }, + })) + mocks.delete.mockImplementation((table: unknown) => ({ + where: (clause: unknown) => { + deletedFrom.push([table, clause]) + return { returning: () => Promise.resolve([{ id: 'a1' }]) } + }, + })) + + await expect(runCleanupOAuthTokens()).resolves.toEqual({ + tokenFamilies: 2, + accessTokens: 1, + }) + expect(deletedFrom).toHaveLength(2) + expect(mocks.transaction).toHaveBeenCalledOnce() + }) + + /** + * A sweep that issued its deletes unconditionally would send an empty `IN ()` + * to the database on every quiet run. + */ + it('issues no delete when nothing has expired', async () => { + mocks.select.mockReturnValueOnce(selectChain([], [])).mockReturnValueOnce(selectChain([], [])) + + await expect(runCleanupOAuthTokens()).resolves.toEqual({ + tokenFamilies: 0, + accessTokens: 0, + }) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('keeps a tail rather than deleting the moment a token lapses', () => { + expect(OAUTH_TOKEN_RETENTION_DAYS).toBeGreaterThan(0) + }) +}) diff --git a/apps/sim/background/cleanup-oauth-tokens.ts b/apps/sim/background/cleanup-oauth-tokens.ts new file mode 100644 index 00000000000..558233e5607 --- /dev/null +++ b/apps/sim/background/cleanup-oauth-tokens.ts @@ -0,0 +1,176 @@ +import { db } from '@sim/db' +import { + oauthAccessToken, + oauthClient, + oauthConsent, + oauthTokenFamily, + session, + user, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, inArray, lt } from 'drizzle-orm' + +const logger = createLogger('CleanupOAuthTokens') + +/** + * How long a lapsed token row is kept before the sweep removes it. + * + * Not zero: a short tail keeps the rows readable while a support question about + * a login that stopped working is still live, and it means clock skew between + * the app and the database can never delete a token that is in fact current. + */ +export const OAUTH_TOKEN_RETENTION_DAYS = 7 + +/** + * Rows removed per statement. A run drains several bounded pages so routine + * rotation volume cannot create a permanent backlog, while every delete keeps + * a predictable lock footprint. + */ +const OAUTH_TOKEN_SWEEP_LIMIT = 5_000 +const OAUTH_TOKEN_SWEEP_MAX_PAGES = 10 + +export interface CleanupOAuthTokensResult { + tokenFamilies: number + accessTokens: number +} + +interface StaleFamilyCandidate { + id: string + clientId: string + sessionId: string | null + userId: string + consentId: string | null +} + +/** Locks a bounded batch in the same parent-to-child order used by refresh and revocation. */ +async function deleteExpiredFamilyBatch( + staleFamilies: StaleFamilyCandidate[], + cutoff: Date +): Promise { + return db.transaction(async (tx) => { + const userIds = [...new Set(staleFamilies.map((family) => family.userId))] + const sessionIds = [ + ...new Set( + staleFamilies + .map((family) => family.sessionId) + .filter((sessionId): sessionId is string => sessionId !== null) + ), + ] + const clientIds = [...new Set(staleFamilies.map((family) => family.clientId))] + const consentIds = [ + ...new Set( + staleFamilies + .map((family) => family.consentId) + .filter((consentId): consentId is string => consentId !== null) + ), + ] + + await tx + .select({ id: user.id }) + .from(user) + .where(inArray(user.id, userIds)) + .orderBy(asc(user.id)) + .for('share') + if (sessionIds.length > 0) { + await tx + .select({ id: session.id }) + .from(session) + .where(inArray(session.id, sessionIds)) + .orderBy(asc(session.id)) + .for('share') + } + await tx + .select({ clientId: oauthClient.clientId }) + .from(oauthClient) + .where(inArray(oauthClient.clientId, clientIds)) + .orderBy(asc(oauthClient.clientId)) + .for('share') + if (consentIds.length > 0) { + await tx + .select({ id: oauthConsent.id }) + .from(oauthConsent) + .where(inArray(oauthConsent.id, consentIds)) + .orderBy(asc(oauthConsent.id)) + .for('share') + } + + const familyIds = staleFamilies.map((family) => family.id) + await tx + .select({ id: oauthTokenFamily.id }) + .from(oauthTokenFamily) + .where(and(inArray(oauthTokenFamily.id, familyIds), lt(oauthTokenFamily.expiresAt, cutoff))) + .orderBy(asc(oauthTokenFamily.id)) + .for('update') + const deleted = await tx + .delete(oauthTokenFamily) + .where(and(inArray(oauthTokenFamily.id, familyIds), lt(oauthTokenFamily.expiresAt, cutoff))) + .returning({ id: oauthTokenFamily.id }) + return deleted.length + }) +} + +/** + * Removes OAuth login families that expired long enough ago to be of no use. + * + * Rotated refresh rows are replay evidence and remain for the lifetime of the + * bounded family. Removing an old generation by its own expiry would let its + * later reuse go unnoticed while descendants remained active. The family row + * therefore owns retention and cascades every generation when it expires. + * + * Families go first and their refresh/access tokens follow by cascade. The + * second pass catches expired access tokens for still-live families and access + * tokens issued without a refresh grant. Both passes use bounded indexed pages. + */ +export async function runCleanupOAuthTokens(): Promise { + const cutoff = new Date(Date.now() - OAUTH_TOKEN_RETENTION_DAYS * 24 * 60 * 60 * 1000) + let tokenFamilies = 0 + let accessTokens = 0 + + for (let page = 0; page < OAUTH_TOKEN_SWEEP_MAX_PAGES; page += 1) { + const staleFamilies = await db + .select({ + id: oauthTokenFamily.id, + clientId: oauthTokenFamily.clientId, + sessionId: oauthTokenFamily.sessionId, + userId: oauthTokenFamily.userId, + consentId: oauthTokenFamily.consentId, + }) + .from(oauthTokenFamily) + .where(lt(oauthTokenFamily.expiresAt, cutoff)) + .orderBy(asc(oauthTokenFamily.expiresAt), asc(oauthTokenFamily.id)) + .limit(OAUTH_TOKEN_SWEEP_LIMIT) + if (staleFamilies.length === 0) break + + tokenFamilies += await deleteExpiredFamilyBatch(staleFamilies, cutoff) + if (staleFamilies.length < OAUTH_TOKEN_SWEEP_LIMIT) break + } + + for (let page = 0; page < OAUTH_TOKEN_SWEEP_MAX_PAGES; page += 1) { + const staleAccess = await db + .select({ id: oauthAccessToken.id }) + .from(oauthAccessToken) + .where(lt(oauthAccessToken.expiresAt, cutoff)) + .orderBy(asc(oauthAccessToken.expiresAt), asc(oauthAccessToken.id)) + .limit(OAUTH_TOKEN_SWEEP_LIMIT) + if (staleAccess.length === 0) break + + const deleted = await db + .delete(oauthAccessToken) + .where( + inArray( + oauthAccessToken.id, + staleAccess.map((row) => row.id) + ) + ) + .returning({ id: oauthAccessToken.id }) + accessTokens += deleted.length + if (staleAccess.length < OAUTH_TOKEN_SWEEP_LIMIT) break + } + + const result = { tokenFamilies, accessTokens } + logger.info('Swept expired OAuth tokens', { + ...result, + retentionDays: OAUTH_TOKEN_RETENTION_DAYS, + }) + return result +} diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 7cefeba5d47..56af48a60cc 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -17,6 +17,11 @@ const ApiKeys = dynamic(() => (module) => module.ApiKeys ) ) +const AuthorizedApps = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then( + (module) => module.AuthorizedApps + ) +) const Admin = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then( (module) => module.Admin @@ -42,6 +47,7 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp if (section === 'general') return if (section === 'billing') return if (section === 'api-keys') return + if (section === 'authorized-apps') return if (section === 'admin') return return } diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 270209f733d..0aacb72ead0 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -129,6 +129,7 @@ describe('settings navigation boundaries', () => { 'general', 'billing', 'api-keys', + 'authorized-apps', 'admin', 'mothership', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index b1f09e3054c..cb7a70e9ceb 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -3,6 +3,7 @@ import { ChartColumn, ClipboardList, Clock, + Connections, Credit, Database, Globe, @@ -33,7 +34,13 @@ import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/wo export type SettingsPlane = 'account' | 'selfhost' | 'workspace' -export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' +export type AccountSettingsSection = + | 'general' + | 'billing' + | 'api-keys' + | 'authorized-apps' + | 'admin' + | 'mothership' /** * Settings a self-hoster needs from the managed service: their profile, what @@ -583,6 +590,18 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, }, + { + label: 'Authorized apps', + icon: Connections, + planes: { + account: { + id: 'authorized-apps', + description: 'Review and revoke apps that can act on your account.', + group: 'developer', + order: 3, + }, + }, + }, { label: 'MCP servers', icon: Server, diff --git a/apps/sim/hooks/queries/oauth-provider.ts b/apps/sim/hooks/queries/oauth-provider.ts new file mode 100644 index 00000000000..f109950b33b --- /dev/null +++ b/apps/sim/hooks/queries/oauth-provider.ts @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type AuthorizedApp, + listAuthorizedAppsContract, + revokeAuthorizedAppContract, +} from '@/lib/api/contracts/user' +import { client } from '@/lib/auth/auth-client' + +export const oauthProviderKeys = { + all: ['oauth-provider'] as const, + clients: () => [...oauthProviderKeys.all, 'client'] as const, + client: (clientId?: string, authorizationRequestKey?: string) => + [...oauthProviderKeys.clients(), clientId ?? '', authorizationRequestKey ?? ''] as const, + authorizedApps: () => [...oauthProviderKeys.all, 'authorized-apps'] as const, +} + +export const AUTHORIZED_APPS_STALE_TIME = 30 * 1000 + +async function fetchAuthorizedApps(signal?: AbortSignal): Promise { + const data = await requestJson(listAuthorizedAppsContract, { signal }) + return data.apps +} + +export function useAuthorizedApps() { + return useQuery({ + queryKey: oauthProviderKeys.authorizedApps(), + queryFn: ({ signal }) => fetchAuthorizedApps(signal), + staleTime: AUTHORIZED_APPS_STALE_TIME, + }) +} + +export function useRevokeAuthorizedApp() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (clientId: string) => + requestJson(revokeAuthorizedAppContract, { params: { clientId } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: oauthProviderKeys.authorizedApps() }) + }, + }) +} + +/** A client's public registration: what the consent page names it. */ +export interface OAuthPublicClient { + clientId: string + name: string | null +} + +export const OAUTH_PUBLIC_CLIENT_STALE_TIME = 5 * 60 * 1000 + +/** + * The Better Auth endpoints below are plugin catch-all routes, typed by the + * `oauthProviderClient()` plugin rather than by a Sim contract, so they are + * called through the auth client instead of `requestJson`. + */ +async function fetchPublicClient( + clientId: string, + signal?: AbortSignal +): Promise { + const { data, error } = await client.oauth2.publicClientPrelogin({ + client_id: clientId, + fetchOptions: { signal }, + }) + if (error || !data) { + throw new Error(error?.message ?? 'This app could not be found.') + } + return { clientId: data.client_id, name: data.client_name ?? null } +} + +export function useOAuthPublicClient(clientId?: string, authorizationRequestKey?: string) { + return useQuery({ + queryKey: oauthProviderKeys.client(clientId, authorizationRequestKey), + queryFn: ({ signal }) => fetchPublicClient(clientId as string, signal), + enabled: Boolean(clientId && authorizationRequestKey), + staleTime: OAUTH_PUBLIC_CLIENT_STALE_TIME, + }) +} + +/** + * Records the user's decision and returns where the browser goes next: the + * client's `redirect_uri` with an authorization code, or with + * `error=access_denied` when declined. The signed authorize query travels in + * the request body automatically (see `oauthProviderClient` in `auth-client`). + */ +export function useOAuthConsent() { + return useMutation({ + mutationFn: async (accept: boolean): Promise => { + const { data, error } = await client.oauth2.consent({ accept }) + if (error || !data?.url) { + throw new Error(error?.message ?? 'The authorization could not be completed.') + } + return data.url + }, + }) +} diff --git a/apps/sim/lib/api/application/operations.ts b/apps/sim/lib/api/application/operations.ts index c524a401368..dbe48461fdf 100644 --- a/apps/sim/lib/api/application/operations.ts +++ b/apps/sim/lib/api/application/operations.ts @@ -14,6 +14,6 @@ export const v2MetaOperations = { read: defineOperation({ id: 'meta.capabilities.read', capability: 'none', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), } as const diff --git a/apps/sim/lib/api/application/read-v2-api-capabilities.test.ts b/apps/sim/lib/api/application/read-v2-api-capabilities.test.ts index 5d707c474fa..936297487c1 100644 --- a/apps/sim/lib/api/application/read-v2-api-capabilities.test.ts +++ b/apps/sim/lib/api/application/read-v2-api-capabilities.test.ts @@ -63,7 +63,7 @@ describe('readV2ApiCapabilities', () => { it('declares its principal policy as frozen data rather than leaving it implicit', () => { expect(v2MetaOperations.read).toMatchObject({ id: 'meta.capabilities.read', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], }) expect(Object.isFrozen(v2MetaOperations.read)).toBe(true) expect(Object.isFrozen(v2MetaOperations.read.principalKinds)).toBe(true) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 30dd03797e3..b52947711d5 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -671,17 +671,9 @@ const predicateGroupsJsonSchema = (selfRef: string) => */ const PREDICATE_LIMITS_DESCRIPTION = `At most ${MAX_PREDICATE_GROUP_SIZE} members per group, ${MAX_PREDICATE_DEPTH} levels of nesting, and ${MAX_PREDICATE_NODES} nodes in total.` const PREDICATE_NEGATION_DESCRIPTION = - 'The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`.' -const PREDICATE_TREE_DESCRIPTION = [ - `Recursive predicate tree. Each group node is exactly one non-empty \`all\` or \`any\` array whose members are further groups or \`{ field, op, value }\` conditions; the root must be a group, not a bare condition. ${PREDICATE_LIMITS_DESCRIPTION}`, - PREDICATE_NEGATION_DESCRIPTION, - PREDICATE_OPERATOR_GRAMMAR, -].join(' ') -const PREDICATE_INPUT_DESCRIPTION = [ - `A single \`{ field, op, value }\` condition or a recursive \`all\`/\`any\` group; either form is normalized to a grouped predicate after validation. ${PREDICATE_LIMITS_DESCRIPTION}`, - PREDICATE_NEGATION_DESCRIPTION, - PREDICATE_OPERATOR_GRAMMAR, -].join(' ') + 'Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.' +const PREDICATE_TREE_DESCRIPTION = `Recursive non-empty \`all\`/\`any\` groups containing groups or conditions; the root cannot be a condition. ${PREDICATE_LIMITS_DESCRIPTION} ${PREDICATE_NEGATION_DESCRIPTION}` +const PREDICATE_INPUT_DESCRIPTION = `One condition or a recursive \`all\`/\`any\` group, normalized to a grouped predicate. ${PREDICATE_LIMITS_DESCRIPTION} ${PREDICATE_NEGATION_DESCRIPTION}` /** * The canonical grouped predicate schema for dual-grammar boundaries. Keeping diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 9b34e9bbf5a..fabaefce163 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -14,6 +14,44 @@ export const userProfileSchema = z.object({ export type UserProfileApiUser = z.output +/** An OAuth client the account has authorized, as the "Authorized apps" settings list shows it. */ +export const authorizedAppSchema = z.object({ + clientId: z.string().min(1).max(200), + name: z.string().min(1).max(200), + scopes: z.array(z.string().min(1).max(100)).max(50), + /** + * When the user granted this app access, which is what decides whether to + * revoke it. ISO-checked because the row is rendered through `new Date(...)` + * — anything else reaches the settings list as "Invalid Date". + */ + authorizedAt: z.iso.datetime(), +}) + +export type AuthorizedApp = z.output + +export const listAuthorizedAppsContract = defineRouteContract({ + method: 'GET', + path: '/api/users/me/authorized-apps', + response: { + mode: 'json', + schema: z.object({ apps: z.array(authorizedAppSchema) }), + }, +}) + +export const authorizedAppParamsSchema = z.object({ + clientId: z.string().min(1, 'Client ID is required').max(200), +}) + +export const revokeAuthorizedAppContract = defineRouteContract({ + method: 'DELETE', + path: '/api/users/me/authorized-apps/[clientId]', + params: authorizedAppParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) + export const getUserProfileContract = defineRouteContract({ method: 'GET', path: '/api/users/me/profile', diff --git a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts index 4a7ed2f910f..25149ed980f 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/cross-cutting.test.ts @@ -8,26 +8,18 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' import { filesAuditOpenApiDocument } from '@/lib/api/contracts/v2/openapi/files-audit' import { knowledgeOpenApiDocument } from '@/lib/api/contracts/v2/openapi/knowledge' import { ERROR_RESPONSES } from '@/lib/api/contracts/v2/openapi/shared' -import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared' +import { v2ForbiddenDetailCodeSchema } from '@/lib/api/contracts/v2/shared' import { v2CreateTableViewContract, v2QueryRowsBodySchema } from '@/lib/api/contracts/v2/tables' import { v2GetWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' -import { - FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, - FORBIDDEN_DETAIL_CODES, -} from '@/lib/core/application/forbidden' +import { FORBIDDEN_DETAIL_CODES } from '@/lib/core/application/forbidden' /** * The cross-cutting promises that no single resource family owns, and that * therefore have nowhere else to be asserted. */ describe('v2 403 cause codes', () => { - it("publishes every code on the error envelope's details field", () => { - const details = v2ErrorResponseSchema.shape.error.shape.details - const published = details.description ?? '' - for (const code of FORBIDDEN_DETAIL_CODES) { - expect(published).toContain(code) - expect(published).toContain(FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]) - } + it('publishes every actionable code as a structural enum', () => { + expect(v2ForbiddenDetailCodeSchema.options).toEqual([...FORBIDDEN_DETAIL_CODES]) }) it('tells a client the codes live on error.details.code', () => { diff --git a/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts index 10a3125753d..66870ec7ec5 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts @@ -53,10 +53,6 @@ const ALLOWED = new Map([ 'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', 'not touched here: lives in v2/knowledge.ts', ], - [ - 'Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.', - 'not touched here: lives in v2/knowledge.ts', - ], [ 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction.', 'not touched here: lives in v2/workflows.ts', diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index d55e3333f25..6cdca3c89c3 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -26,8 +26,8 @@ import { * demotes a workspace-scoped question to account scope and answers 200 about a * different payer than the caller asked about. It is a wrong answer, not a cross-tenant * read: `resolveBillingReadScope` still pins a workspace API key to its own workspace - * whatever the query says, so the reachable case is a personal key being told about its - * own account when it asked about a workspace. Rejecting the unknown key turns that + * whatever the query says, so the reachable case is a user-held credential being told + * about its own account when it asked about a workspace. Rejecting the unknown key turns that * wrong answer about money into a 400. */ export const v2BillingStatusQuerySchema = z @@ -172,7 +172,7 @@ export const v2BillingLogsQuerySchema = z workspaceId: workspaceIdSchema .optional() .describe( - "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers." + "Narrow the ledger to one workspace. An OAuth token or personal API key reports only its user's events; a workspace API key reports every member's events in its bound workspace. The response `scope` identifies which view was returned. A workspace key asking for another workspace receives the same `404 Workspace not found` as an unknown id." ), period: usageLogPeriodSchema .optional() @@ -287,12 +287,12 @@ export type V2BillingLogEntry = z.output * indistinguishable on the wire. The same workspace, window, and filters return * a strict subset of the rows on `user` scope that they return on `workspace` * scope, and nothing else in the response says which set arrived — a caller - * auditing a workspace's spend with a personal key would silently undercount. + * auditing a workspace's spend with a user-held credential would silently undercount. */ export const v2BillingLogsScopeSchema = z .enum(['user', 'workspace']) .describe( - "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to." + "Whose usage this page reports. `user` contains only the OAuth or personal-key user's events, optionally narrowed by `workspaceId`; it omits other members. `workspace` contains every member's events for the workspace API key's bound workspace." ) export const v2ListBillingLogsContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index a30fc2bd912..6bd8473a3c4 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -946,7 +946,7 @@ export const v2KnowledgeSearchBodySchema = z ) .optional() .describe( - `Structured tag filters, at most ${MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS} of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching \`GET /api/v2/knowledge/{knowledgeBaseId}/documents\`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with \`GET /api/v2/knowledge/{knowledgeBaseId}/tags\`.` + `Up to ${MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS} filters combined with AND; repeating a tag narrows results. To express OR, run separate searches. Every tag must exist with the same slot and field type in each selected knowledge base or the request is rejected. List valid names with the knowledge-base tag-list operation.` ), searchMode: v1KnowledgeSearchBodySchema.shape.searchMode.describe( 'Retrieval strategy: vector is semantic-only, while hybrid also runs full-text search.' diff --git a/apps/sim/lib/api/contracts/v2/logs-stats.ts b/apps/sim/lib/api/contracts/v2/logs-stats.ts index 163150c2443..64a449982ef 100644 --- a/apps/sim/lib/api/contracts/v2/logs-stats.ts +++ b/apps/sim/lib/api/contracts/v2/logs-stats.ts @@ -105,7 +105,7 @@ export const v2LogStatsSchema = z end: v2TimestampSchema.describe('ISO 8601 end of the window.'), }) .describe( - 'The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width.' + 'Actual bucket window. Supplied bounds are exact. Without `startDate`, the left edge is the oldest match, or 24 hours before the right edge when no run matches. Without `endDate`, the right edge is at least now. `startDate` alone spans through now.' ), segmentMs: z.number().describe('Width of one bucket in milliseconds.'), }) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 04c69efc40f..8c3af5a7f23 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -542,7 +542,7 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema */ triggers: v2CommaListSchema( 'triggers', - 'Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.', + 'Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values.', V2_LOG_TRIGGERS_MAX ).optional(), level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), @@ -550,7 +550,7 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema workflowName: v2WorkflowNameFilterSchema.optional(), includeJobRuns: booleanQueryFlagSchema .describe( - 'Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.' + 'Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`.' ) .optional() .default(false), diff --git a/apps/sim/lib/api/contracts/v2/meta.ts b/apps/sim/lib/api/contracts/v2/meta.ts index eec1daf8461..d896e4cff50 100644 --- a/apps/sim/lib/api/contracts/v2/meta.ts +++ b/apps/sim/lib/api/contracts/v2/meta.ts @@ -4,9 +4,9 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { v2DataResponse, v2TimestampSchema } from '@/lib/api/contracts/v2/shared' export const v2ApiKeyTypeSchema = z - .enum(['personal', 'workspace']) + .enum(['personal', 'workspace', 'oauth_access_token']) .describe( - 'Whether the calling key carries the full authority of its owner across their workspaces, or is scoped to one workspace.' + 'Whether the calling credential is a personal API key carrying the full authority of its owner across their workspaces, a key scoped to one workspace, or an OAuth access token acting for its user within the scopes it was granted.' ) export type V2ApiKeyType = z.output @@ -19,12 +19,14 @@ export const v2MetaSchema = z keyType: v2ApiKeyTypeSchema, expiresAt: v2TimestampSchema .nullable() - .describe('ISO 8601 timestamp when the calling key expires, or null when it never does.'), + .describe( + 'ISO 8601 timestamp when the calling credential expires, or null when it does not.' + ), }) .meta({ id: 'V2Meta', title: 'API capabilities', - description: 'API availability and lifecycle facts about the calling API key.', + description: 'API availability and lifecycle facts about the calling credential.', }) export type V2Meta = z.output diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index 381cd2939b4..7b37eb662ca 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -8,8 +8,8 @@ import { type ErrorResponseId, RATE_LIMIT_HEADERS, RESOURCE_ERRORS, - V2_API_KEY_SECURITY, - V2_API_KEY_SECURITY_SCHEMES, + V2_AUTH_SECURITY, + V2_AUTH_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, } from '@/lib/api/contracts/v2/openapi/shared' @@ -153,8 +153,8 @@ export const billingOpenApiDocument = defineOpenApiDocument({ description: 'Inspect billing standing, credit allowance, storage quota, and usage history.', }, ], - security: V2_API_KEY_SECURITY, - securitySchemes: V2_API_KEY_SECURITY_SCHEMES, + security: V2_AUTH_SECURITY, + securitySchemes: V2_AUTH_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, errorResponses: ERROR_RESPONSES, diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index f49601c6622..8e4f3959279 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -38,8 +38,8 @@ import { RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, - V2_API_KEY_SECURITY, - V2_API_KEY_SECURITY_SCHEMES, + V2_AUTH_SECURITY, + V2_AUTH_SECURITY_SCHEMES, V2_BINARY_DOWNLOAD_HEADERS, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, @@ -380,7 +380,7 @@ const declaredRoutes = [ filesOperation({ operationId: 'readFileText', summary: 'Read File Text', - description: `Return a file's text content, parsed out of the stored bytes. This reads the file; it writes nothing — \`POST /api/v2/files/{fileId}/unzip\` is the endpoint that unzips an archive into the workspace. Answers \`400\` for a type no parser supports, naming the raw-bytes download as the escape hatch, and \`413\` for a file above the extraction ceiling. A generated document is extracted from its compiled artifact rather than its generation source, so one still compiling answers \`409\` and is worth retrying. **\`degraded: true\` means text extraction did not fully succeed and the returned text may be incomplete or synthesized from the file's raw bytes. Do not treat it as authoritative content.** The legacy \`.doc\` and \`.ppt\` parsers deliberately return best-effort content rather than failing, so this flag — not an error status — is how a partial extraction is reported. \`truncated\` separately reports that a parser limit stopped extraction early.`, + description: `Extract text from stored file bytes without modifying the file; use \`POST /api/v2/files/{fileId}/unzip\` to unpack archives. Unsupported types return \`400\` and point to raw-byte download; generated documents still compiling return \`409\`, and files above the extraction ceiling return \`413\`. \`degraded: true\` means extraction was incomplete or synthesized from raw bytes and is not authoritative; legacy \`.doc\` and \`.ppt\` extraction may return this best-effort result. \`truncated\` means a parser limit stopped extraction.`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The extracted text and its extraction-quality flags.' }, }), @@ -410,7 +410,7 @@ const declaredRoutes = [ filesOperation({ operationId: 'bulkDownloadFiles', summary: 'Bulk Download Files', - description: `Stream a selection of workspace files as one zip. Select files by id and folders by path, each as one comma-separated parameter; a folder expands to all its descendants, and a path matching no folder is rejected rather than ignored. Each parameter accepts at most ${MAX_ZIP_DOWNLOAD_FILES} entries — the same ceiling the resolved selection is held to — and the resolved file count and total bytes are checked again, so an over-broad selection answers \`400\` rather than streaming indefinitely. Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + description: `Stream files as a zip. Provide comma-separated file IDs and folder paths; folders expand recursively, and unmatched paths are rejected. Each parameter and the resolved selection allow at most ${MAX_ZIP_DOWNLOAD_FILES} entries, with bytes bounded. Oversized selections return \`400\`; downloads record an audit event. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The selected files as a zip archive.', @@ -433,7 +433,7 @@ const declaredRoutes = [ operationId: 'unzipFile', summary: 'Unzip File', description: - "Unzip a `.zip` archive into a new folder beside it and answer counts plus the destination path. This writes new workspace files; it does not read anything out of the archive into the response — `GET /api/v2/files/{fileId}/text` is the endpoint that returns a file's text. The unpacked files are deliberately not returned — a large archive would materialize thousands of objects into one response — so page `GET /api/v2/files?folderPath=...` for the contents. Unzipping is slow: an archive near the size ceiling can run for minutes. Only one unzip of a given archive runs at a time; a concurrent attempt answers `409`. Archives past the size ceiling, and runs that outrun their time budget, answer `413`.", + 'Unzip a `.zip` archive into a new sibling folder, creating workspace files and returning only counts and the destination path. Use `GET /api/v2/files/{fileId}/text` to read text; page `GET /api/v2/files?folderPath=...` to inspect unpacked files. Large archives can take minutes. Only one unzip per archive may run; concurrent attempts return `409`. Archives above the size ceiling or operations exceeding their time budget return `413`.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Counts and destination folder for the unpacked archive.' }, }), @@ -464,7 +464,7 @@ const declaredRoutes = [ filesOperation({ operationId: 'downloadFile', summary: 'Download File', - description: `Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers \`409\` while that artifact is still compiling and \`413\` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + description: `Download current file bytes. Generated documents use compiled artifacts, returning \`409\` while compiling and \`413\` above the rendered-size ceiling. Downloading records an audit event. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file bytes.', @@ -793,7 +793,7 @@ const declaredRoutes = [ filesOperation({ operationId: 'editFileContent', summary: 'Edit File Content', - description: `Change part of a text file in place, leaving the rest untouched. \`PUT\` on this path replaces the whole file; this is the partial counterpart. \`search_replace\` matches exact text and requires one match unless \`replaceAll\` is true. \`replace_between\`, \`insert_after\`, and \`delete_between\` match complete lines after trimming surrounding whitespace, so edits remain stable when unrelated changes move the target to another line. Anchored replacement preserves both boundary lines; insertion preserves its anchor; deletion removes the start anchor and preserves the end anchor. Use \`occurrence\` when an anchor line repeats. Only files whose stored bytes are UTF-8 text can be edited: a PDF or DOCX answers \`400\`. A concurrent write answers \`409\`, and retrying means re-reading first.`, + description: `Modify part of a text file; \`PUT\` on this path replaces the whole file. \`search_replace\` requires one exact match unless \`replaceAll\` is true. The anchored modes match trimmed complete lines: replacement preserves both boundaries, insertion preserves its anchor, and deletion removes the start but preserves the end. Use \`occurrence\` for repeated anchors. Non-UTF-8 files return \`400\`. Concurrent writes return \`409\`; re-read before retrying.`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'Locked'], success: { description: 'The edited file and its new line count.' }, }), @@ -843,7 +843,7 @@ const declaredRoutes = [ filesOperation({ operationId: 'searchFileContent', summary: 'Search File Content', - description: `Search the indexed text of active workspace files and return each matching line with its file id and line number. \`folderPaths\` confines the search to one or more folder trees, which also narrows the reported coverage, so \`complete\` and \`indexStatus\` describe the folders searched rather than the whole workspace. Coverage matters: the index is built asynchronously, so when \`complete\` is \`false\` a term that was not found is **unknown rather than absent**, and acting on the absence risks creating a duplicate of something already stored. \`truncated\` separately reports that more matches exist beyond \`maxResults\`.`, + description: `Search indexed text in active workspace files and return matching lines with file IDs and line numbers. \`folderPaths\` limits both results and the coverage reported by \`complete\` and \`indexStatus\`. Because indexing is asynchronous, a missing term is unknown rather than absent when \`complete\` is false. \`truncated\` means additional matches exist beyond \`maxResults\`.`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'Locked'], success: { description: 'Matching lines and the index coverage they were drawn from.' }, }), @@ -1125,8 +1125,8 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ description: 'Query the organization audit trail with Enterprise authorization.', }, ], - security: V2_API_KEY_SECURITY, - securitySchemes: V2_API_KEY_SECURITY_SCHEMES, + security: V2_AUTH_SECURITY, + securitySchemes: V2_AUTH_SECURITY_SCHEMES, headers: { ...V2_BINARY_DOWNLOAD_HEADERS, ...V2_COMMON_HEADERS }, errorSchema: V2_ERROR_SCHEMA, /* diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts index 9f8e603b254..1dc5c6f7baa 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts @@ -27,11 +27,10 @@ import { defineOpenApiRoute } from '@/lib/api/openapi/types' * on the request itself. */ -const CONNECTOR_MANAGED = - 'Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"` — change the content at the source and re-sync, or exclude the document from the connector.' - const DOCUMENT_NOT_READY = 'A document that has not finished processing answers `409`; the message names the status it is in.' +const CONNECTOR_MANAGED = + 'Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document.' export const knowledgeChunkOpenApiRoutes = [ defineOpenApiRoute( @@ -69,7 +68,7 @@ export const knowledgeChunkOpenApiRoutes = [ knowledgeOperation({ operationId: 'createKnowledgeChunk', summary: 'Create Chunk', - description: `Append a chunk to a document. The text is embedded before the response returns, so the chunk is searchable immediately, and it inherits the document's tag values and the next \`chunkIndex\`. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, + description: `Append a chunk, embedding it before the response so it is immediately searchable. It inherits the document's tags and next \`chunkIndex\`. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'The created chunk.' }, }), @@ -106,7 +105,7 @@ export const knowledgeChunkOpenApiRoutes = [ knowledgeOperation({ operationId: 'bulkUpdateKnowledgeChunks', summary: 'Bulk Update Chunks', - description: `Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in \`errors\` rather than failing the request. \`processed\` counts the chunks the operation matched, not the chunks it changed. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, + description: `Enable, disable, or delete multiple chunks in one best-effort request. Unknown chunk IDs appear in \`errors\` without failing the request; \`processed\` counts matched chunks, not changes. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'Outcome of the bulk chunk operation.' }, }), @@ -174,7 +173,7 @@ export const knowledgeChunkOpenApiRoutes = [ knowledgeOperation({ operationId: 'updateKnowledgeChunk', summary: 'Update Chunk', - description: `Correct a chunk's text or take it out of search. Changing \`content\` re-embeds the chunk and re-derives the document's token and character counts, so the correction reaches search immediately; disabling keeps the chunk indexed. ${CONNECTOR_MANAGED} ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, + description: `Correct chunk text or disable it from search. Changing \`content\` re-embeds immediately and recalculates document token and character counts; disabling retains the index. ${CONNECTOR_MANAGED} ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated chunk.' }, }), @@ -206,7 +205,7 @@ export const knowledgeChunkOpenApiRoutes = [ knowledgeOperation({ operationId: 'deleteKnowledgeChunk', summary: 'Delete Chunk', - description: `Permanently remove one chunk and subtract it from the document's counts. Deleting does not renumber the remaining chunks, so \`chunkIndex\` values stay stable but become non-contiguous. ${CONNECTOR_MANAGED} ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, + description: `Permanently remove one chunk and subtract it from document counts. Remaining \`chunkIndex\` values stay stable and may become non-contiguous. ${CONNECTOR_MANAGED} ${DOCUMENT_NOT_READY} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Chunk deletion acknowledgement.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge-tags.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge-tags.ts index e5425bc1ccf..a3d6c69d6df 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge-tags.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge-tags.ts @@ -29,16 +29,13 @@ import { defineOpenApiRoute } from '@/lib/api/openapi/types' * first place. */ -const TAG_LOOP = - 'Define a tag here, write its `tagSlot` on a document with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`, then filter by its `displayName` on the document list or on search.' - export const knowledgeTagOpenApiRoutes = [ defineOpenApiRoute( v2CreateKnowledgeTagContract, knowledgeOperation({ operationId: 'createKnowledgeTag', summary: 'Create Tag', - description: `Define one tag on a knowledge base; use \`PUT\` on this path to declare several at once. ${TAG_LOOP} Omit \`tagSlot\` to take the next free slot for the field type; a field type with no free slot left is a \`400\` naming it, since the remedy is a different type or a deleted definition rather than a retry. A \`tagSlot\` already taken, or a \`displayName\` already defined on this knowledge base, is a \`409\` naming which of the two to change. ${WORKSPACE_API_KEY_DENIED}`, + description: `Define one tag; use \`PUT\` on this path for several. Write its \`tagSlot\` on documents, then filter by \`displayName\`. Omitting \`tagSlot\` selects the next free slot; exhaustion returns \`400\`. An occupied slot or duplicate display name returns \`409\` naming the conflict. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The created tag definition.' }, }), @@ -70,7 +67,7 @@ export const knowledgeTagOpenApiRoutes = [ knowledgeOperation({ operationId: 'updateKnowledgeTag', summary: 'Update Tag', - description: `Rename a tag, or change the value type stored in its slot. Renaming changes the name filters and document reads use; the slot, and every value in it, is untouched. A tag's slot is fixed for its lifetime and each slot holds one kind of value, so \`fieldType\` can only change to another type valid for the slot the tag already occupies — anything else is a \`400\`, and the way to get a tag of that type is to create one. A name another tag on this knowledge base already holds is a \`409\`. ${WORKSPACE_API_KEY_DENIED}`, + description: `Rename a tag or change its slot-compatible \`fieldType\`. Renaming changes read and filter names without moving the slot or its values. Slots are fixed for a tag's lifetime; an incompatible type returns \`400\` and requires creating a new tag. A duplicate display name returns \`409\`. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated tag definition.' }, }), @@ -192,7 +189,7 @@ export const knowledgeTagOpenApiRoutes = [ knowledgeOperation({ operationId: 'bulkSaveKnowledgeTagDefinitions', summary: 'Bulk Save Tag Definitions', - description: `Declare, in one request, several of the knowledge base's tag definitions. \`POST\` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in \`originalDisplayName\`; that is the only form that edits one in place. Without it the entry is a create, and a requested \`tagSlot\` another name already holds is refused in \`errors\` — it is neither overwritten nor relocated to a different slot, so an explicitly requested slot always means that slot or an error. A create whose \`displayName\` already exists is refused in \`errors\`. Per-definition failures are reported in \`errors\` and still answer \`200\`. This writes the vocabulary, not one document's tag values — set those with \`PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`, + description: `Declare multiple tag definitions while leaving unspecified slots unchanged. Updating requires the current name in \`originalDisplayName\`; otherwise the entry creates a tag. Occupied explicit slots and duplicate display names appear in per-definition \`errors\`, never overwrite or relocate data, and still return \`200\`. This writes the vocabulary, not document tag values; set those through the document update endpoint. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'Definitions created and updated by the save.' }, }), @@ -229,7 +226,7 @@ export const knowledgeTagOpenApiRoutes = [ knowledgeOperation({ operationId: 'deleteKnowledgeTagDefinitions', summary: 'Delete Tag Definitions', - description: `Remove tag definitions from the knowledge base. \`unused\` defaults to \`true\`, which removes only the definitions no document still carries a value for — the recoverable half, since a definition with nothing behind it can simply be redefined. Pass \`unused=false\` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. Delete one definition at a time with \`DELETE /api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}\`. ${WORKSPACE_API_KEY_DENIED}`, + description: `Remove tag definitions. \`unused\` defaults to \`true\`, deleting only definitions with no document values, which can be recreated safely. \`unused=false\` deletes every definition and irreversibly clears its slot from all documents and chunks. Use \`DELETE /api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}\` to delete one definition. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'Number of tag definitions removed.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index 9103224eb76..b6dc9b31750 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -42,8 +42,8 @@ import { RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, - V2_API_KEY_SECURITY, - V2_API_KEY_SECURITY_SCHEMES, + V2_AUTH_SECURITY, + V2_AUTH_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, @@ -148,7 +148,13 @@ const declaredRoutes = [ 'CreateKnowledgeBaseRequest', 'Create knowledge base request', 'Workspace, name, description, chunking configuration, and folder placement.', - [{ workspaceId: WORKSPACE_ID, name: 'Product Documentation', folderPath: '/Product' }] + [ + { + workspaceId: WORKSPACE_ID, + name: 'Product Documentation', + folderPath: '/Product', + }, + ] ), response: documentedSchema( v2CreateKnowledgeBaseContract.response.schema, @@ -288,7 +294,9 @@ const declaredRoutes = [ 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.' }, + success: { + description: 'The created connector without secret material.', + }, }), { query: v2CreateKnowledgeConnectorContract.query, @@ -329,7 +337,9 @@ const declaredRoutes = [ 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.' }, + success: { + description: 'The connector and recent synchronization history.', + }, }), { params: documentedSchema( @@ -393,7 +403,9 @@ const declaredRoutes = [ 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.' }, + success: { + description: 'Connector deletion acknowledgement and document counts.', + }, }), { params: documentedSchema( @@ -497,7 +509,9 @@ const declaredRoutes = [ 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.' }, + success: { + description: 'The selected connector documents were updated.', + }, }), { query: v2UpdateKnowledgeConnectorDocumentsContract.query, @@ -545,7 +559,9 @@ const declaredRoutes = [ description: 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.', errors: [...WORKSPACE_ERRORS, 'UsageLimitExceeded', 'NotFound', 'PayloadTooLarge'], - success: { description: 'Matching document chunks ordered by relevance.' }, + success: { + description: 'Matching document chunks ordered by relevance.', + }, }), { query: v2SearchKnowledgeContract.query, @@ -639,7 +655,9 @@ const declaredRoutes = [ summary: 'Bulk Enable or Disable Documents', description: `Enable or disable many documents in one request, either by identifier or, with \`selectAll\`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with \`DELETE /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}\`. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], - success: { description: 'The number and identifiers of the documents that changed.' }, + success: { + description: 'The number and identifiers of the documents that changed.', + }, }), { query: v2BulkUpdateKnowledgeDocumentsContract.query, @@ -730,7 +748,9 @@ const declaredRoutes = [ 'PayloadTooLarge', 'UnsupportedMediaType', ], - success: { description: 'The created upload session and transfer instructions.' }, + success: { + description: 'The created upload session and transfer instructions.', + }, }), { query: v2CreateKnowledgeDocumentUploadContract.query, @@ -915,7 +935,9 @@ const declaredRoutes = [ summary: 'Update Document', description: `Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with \`GET /api/v2/knowledge/{knowledgeBaseId}/tags\`. The returned document omits the connector provenance the detail read carries. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], - success: { description: 'The updated document, or the requeue acknowledgement.' }, + success: { + description: 'The updated document, or the requeue acknowledgement.', + }, }), { query: v2UpdateKnowledgeDocumentContract.query, @@ -1060,7 +1082,9 @@ const declaredRoutes = [ summary: 'Delete Folder', description: 'Delete a folder, optionally including nested folders and knowledge bases.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], - success: { description: 'Folder deletion acknowledgement and deleted item counts.' }, + success: { + description: 'Folder deletion acknowledgement and deleted item counts.', + }, }), { query: documentedSchema( @@ -1115,9 +1139,12 @@ const declaredRoutes = [ operationId: 'addWorkspaceFilesToKnowledgeBase', summary: 'Index Workspace Files', description: - 'Index files the workspace already stores, without re-uploading their bytes. Each reference is authorized against the file it names, so a reference the caller cannot read, one over the 100 MB document limit, or one whose type is not supported is reported in `failed` while the rest are queued — a partial outcome is a `200`, not a multi-status. A queued document starts in the `pending` processing state; the entries returned here carry only its identity, so read `GET /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` for its current state. A workspace API key is rejected with `403`; use a personal API key.', + 'Index stored workspace files without re-uploading bytes. Each reference is authorized independently; unreadable, unsupported, or over-100 MB files appear in `failed` while valid files are queued. This partial outcome returns `200`, not multi-status. Queued documents begin as `pending`; the response carries identities only, so read each document endpoint for current processing state. ' + + WORKSPACE_API_KEY_DENIED, errors: [...RESOURCE_ERRORS, 'UsageLimitExceeded'], - success: { description: 'Files queued for indexing, with any that could not be.' }, + success: { + description: 'Files queued for indexing, with any that could not be.', + }, }), { query: v2AddWorkspaceFilesToKnowledgeBaseContract.query, @@ -1173,8 +1200,8 @@ export const knowledgeOpenApiDocument = defineOpenApiDocument({ 'Create and organize knowledge bases, ingest documents, and search indexed content.', }, ], - security: V2_API_KEY_SECURITY, - securitySchemes: V2_API_KEY_SECURITY_SCHEMES, + security: V2_AUTH_SECURITY, + securitySchemes: V2_AUTH_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, errorResponses: withErrorExamples({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index 721472f6c9e..60b50f191ba 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -8,8 +8,8 @@ import { RATE_LIMIT_HEADERS, RESOURCE_ERRORS, RUN_RETENTION, - V2_API_KEY_SECURITY, - V2_API_KEY_SECURITY_SCHEMES, + V2_AUTH_SECURITY, + V2_AUTH_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, withRequestBodyErrors, @@ -163,7 +163,7 @@ const declaredRoutes = [ logsOperation({ operationId: 'listLogs', summary: 'List Logs', - description: `List workflow execution logs for a workspace with filters, selectable detail, sorting by start time, duration, cost, or status, and opaque cursor pagination. Chat and Sim-agent job runs join the sequence with \`includeJobRuns=true\`, which is accepted only under \`sortBy=startedAt\` — their cost is stored as a document and their status is not comparable, so they cannot participate in the other orderings. Each item's \`files\` lists only the files the run itself produced, addressed by \`downloadPath\`; input attachments a caller supplied are read through the files API instead. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + description: `List logs with filters, selectable detail, sorting, and cursor pagination. \`includeJobRuns=true\` includes chat and Sim-agent jobs only with \`sortBy=startedAt\`, because other orderings are unsupported. \`files\` contains only run-produced files; use the files API for input attachments. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'A page of execution logs matching the filters.' }, }), @@ -188,7 +188,7 @@ const declaredRoutes = [ logsOperation({ operationId: 'getLog', summary: 'Get Log', - description: `Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty \`traceSpans\` array does not mean the run recorded none. ${FOLDER_TREE_TOO_LARGE} ${RUN_RETENTION}`, + description: `Retrieve a run's workflow snapshot, trace spans, final output, and cost. Trace spans have separate retention, so an empty \`traceSpans\` array does not prove none were recorded. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'The requested diagnostic log representation.' }, }), @@ -214,7 +214,7 @@ const declaredRoutes = [ logsOperation({ operationId: 'getLogStats', summary: 'Get Log Statistics', - description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans \`startDate\` through \`endDate\` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding \`endDate\` when only \`endDate\` was supplied. A supplied \`startDate\` is still used verbatim, so a \`startDate\` without an \`endDate\` yields \`[startDate, now]\`, which can be any width. The window is divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + description: `Return workspace/workflow counts, success, errors, and latency. Defaults span runs, or 24 hours if empty; supplied bounds stay exact. Buckets are one-minute minimum and may pass the end. Folders include descendants; \`workflowsTruncated\` marks capped series, totals include all. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'Bucketed execution statistics for the workspace.' }, }), @@ -263,8 +263,8 @@ export const logsOpenApiDocument = defineOpenApiDocument({ description: 'Query workflow execution logs and retrieve complete run diagnostics.', }, ], - security: V2_API_KEY_SECURITY, - securitySchemes: V2_API_KEY_SECURITY_SCHEMES, + security: V2_AUTH_SECURITY, + securitySchemes: V2_AUTH_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, errorResponses: ERROR_RESPONSES, diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index b5808398a3f..ca0d560de88 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -38,8 +38,8 @@ import { RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, - V2_API_KEY_SECURITY, - V2_API_KEY_SECURITY_SCHEMES, + V2_AUTH_SECURITY, + V2_AUTH_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, @@ -224,6 +224,11 @@ const TOOL_EXECUTION_EXAMPLE = { error: null, } as const +const SANDBOX_ADMIN_PLAN_NOTE = + 'Requires a workspace admin on Max or Enterprise; lower plans return `403` with `error.details.code: WORKSPACE_PLAN_CAPABILITY_REQUIRED`.' +const SANDBOX_BUILD_BUDGET_NOTE = + 'Creates and updates share a write budget; bursts return `429` with `Retry-After`.' + const TOOL_DETAIL_EXAMPLE = { ...TOOL_SUMMARY_EXAMPLE, params: { @@ -396,13 +401,6 @@ const SANDBOX_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const -const SANDBOX_ADMIN_PLAN_NOTE = - 'Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`.' - -/** Creates and updates only: deleting builds nothing and is never refused on budget. */ -const SANDBOX_BUILD_BUDGET_NOTE = - 'Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header.' - const CREDENTIAL_EXAMPLE = { id: '7c9e6679-7425-40de-944b-e07fc1f90ae7', type: 'service_account', @@ -543,9 +541,9 @@ const declaredRoutes = [ 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.', + 'List active workspaces available to the calling credential with opaque cursor pagination. A personal API key or OAuth token sees accessible workspaces that permit user-held API credentials; a workspace API key sees only its bound workspace.', errors: RESOURCE_ERRORS, - success: { description: 'Public metadata for workspaces available to the API key.' }, + success: { description: 'Public metadata for workspaces available to the credential.' }, }), { query: documentedSchema( @@ -558,7 +556,7 @@ const declaredRoutes = [ v2ListWorkspacesContract.response.schema, 'ListWorkspacesResponse', 'List workspaces response', - 'Public metadata for workspaces available to the API key.', + 'Public metadata for workspaces available to the credential.', [{ data: [WORKSPACE_EXAMPLE], nextCursor: null }] ), } @@ -787,7 +785,7 @@ const declaredRoutes = [ resourceOperation('MCP Servers', { operationId: 'listMcpServerTools', summary: 'List MCP Server Tools', - description: `Connect to a registered MCP server and return the tools it exposes. This read has side effects: it opens a live connection to the third-party server and writes \`connectionStatus\`, \`toolCount\`, \`lastError\`, and \`lastToolsRefresh\`. ${HEAD_MIRRORS_GET} Discovery is bounded at 1,000 tools and 5 MB of tool payload per server. ${FULL_SET_LIST} An unreachable, slow, or cooling-down server is a \`503\`; a stored OAuth grant that no longer works is a \`409\` with \`error.details.code\` \`MCP_SERVER_REAUTHORIZATION_REQUIRED\`, which only a human reauthorizing in Sim can clear. ${WORKSPACE_API_KEY_DENIED}`, + description: `Return up to 1,000 tools and 5 MB with \`nextCursor: null\`, opening a connection and updating connection metadata. ${HEAD_MIRRORS_GET} Unavailable servers return \`503\`; invalid OAuth returns \`409\` with \`error.details.code: MCP_SERVER_REAUTHORIZATION_REQUIRED\` and requires human reauthorization. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Tools exposed by the MCP server.' }, }), @@ -1259,7 +1257,7 @@ const declaredRoutes = [ resourceOperation('Sandboxes', { operationId: 'createSandbox', summary: 'Create Sandbox', - description: `Create a sandbox. The name must be unique within the workspace. Where the deployment prebuilds dependency images, the build is scheduled and reported through \`buildStatus\`; a deployment that installs at run time, or a sandbox with nothing to install, has no build and reports \`buildStatus: null\`. A dependency or system-package entry the builder cannot accept is a \`400\` whose \`error.details\` names the field and the offending entries. ${SANDBOX_ADMIN_PLAN_NOTE} ${SANDBOX_BUILD_BUDGET_NOTE} ${WORKSPACE_API_KEY_DENIED}`, + description: `Create a uniquely named sandbox. Prebuild deployments schedule an image build reported by \`buildStatus\`; runtime-install deployments or empty specs report \`buildStatus: null\`. Invalid dependency or system-package entries return \`400\` with field details. ${SANDBOX_ADMIN_PLAN_NOTE} ${SANDBOX_BUILD_BUDGET_NOTE} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: @@ -1329,7 +1327,7 @@ const declaredRoutes = [ resourceOperation('Sandboxes', { operationId: 'updateSandbox', summary: 'Update Sandbox', - description: `Update the supplied sandbox fields. Omitted fields retain their stored values; a supplied list replaces the whole list; names must remain unique within the workspace. Where the deployment prebuilds dependency images, a changed spec is rebuilt and re-sending an unchanged spec after a failed build retries it; a deployment that installs at run time, or a spec with nothing to install, has no build and reports \`buildStatus: null\`. ${SANDBOX_ADMIN_PLAN_NOTE} ${SANDBOX_BUILD_BUDGET_NOTE} ${WORKSPACE_API_KEY_DENIED}`, + description: `Update supplied fields; omissions preserve values, lists replace whole lists, and names remain unique. Prebuild deployments rebuild changed specs, while resending an unchanged failed spec retries it; runtime-install or empty specs report \`buildStatus: null\`. ${SANDBOX_ADMIN_PLAN_NOTE} ${SANDBOX_BUILD_BUDGET_NOTE} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated sandbox.' }, }), @@ -1371,7 +1369,7 @@ const declaredRoutes = [ resourceOperation('Sandboxes', { operationId: 'deleteSandbox', summary: 'Delete Sandbox', - description: `Delete a sandbox. Function blocks that still select it fail closed at run time until they are re-pointed. Where the deployment prebuilds dependency images, the sandbox's image is released once nothing else shares it; a runtime-install deployment, or a spec with nothing to install, had no image and nothing is released. ${SANDBOX_ADMIN_PLAN_NOTE} ${WORKSPACE_API_KEY_DENIED}`, + description: `Delete a sandbox. Function blocks still selecting it fail closed until reconfigured. A prebuilt image is released when no sandbox shares it; runtime-install and empty specs have no image to release. ${SANDBOX_ADMIN_PLAN_NOTE} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'The sandbox was deleted.' }, }), @@ -1499,7 +1497,7 @@ const declaredRoutes = [ resourceOperation('Credentials', { operationId: 'createCredentialConnection', summary: 'Create Credential Connection', - description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL, sign in as the authenticated user, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'A short-lived browser authorization URL.' }, }), @@ -1581,7 +1579,7 @@ const declaredRoutes = [ resourceOperation('Secrets', { operationId: 'setSecret', summary: 'Set Secret', - description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit \`value\` on a workspace secret to update \`description\` and \`unredacted\` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers \`404\` when the named secret does not exist. A personal secret always requires \`value\`, having no other writable field. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create or replace a workspace or personal secret. Values are encrypted at rest, write-only, and never returned. For an existing workspace secret, omit \`value\` to update only \`description\` or \`unredacted\`; the value remains untouched. This metadata-only form cannot create a secret and returns \`404\` when absent. Personal secrets always require \`value\`. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { byStatus: { @@ -1673,9 +1671,9 @@ const declaredRoutes = [ operationId: 'getApiMeta', summary: 'Get API Capabilities', description: - 'Report whether v2 is available, whether the calling API key is personal or workspace-scoped, and when it expires. Requires a valid key.', + 'Report whether v2 is available, what kind of API credential is calling, and when it expires. Requires a valid API key or OAuth access token.', errors: META_ERRORS, - success: { description: 'Availability and lifecycle facts about the calling key.' }, + success: { description: 'Availability and lifecycle facts about the calling credential.' }, }), { query: v2GetMetaContract.query, @@ -1683,7 +1681,7 @@ const declaredRoutes = [ v2GetMetaContract.response.schema, 'GetApiMetaResponse', 'API capabilities response', - 'API availability, key type, and expiry for the calling key.', + 'API availability, credential type, and expiry for the caller.', [{ data: { v2Enabled: true, keyType: 'personal', expiresAt: null } }] ), } @@ -1693,7 +1691,7 @@ const declaredRoutes = [ resourceOperation('MCP Servers', { operationId: 'listWorkflowMcpServers', summary: 'List Workflow MCP Servers', - description: `List the MCP servers a workspace *publishes*. These serve deployed workflows as tools to outside MCP clients, which is the opposite direction from \`GET /api/v2/mcp-servers\` — that lists external servers Sim calls. Each entry carries the endpoint clients connect to and the tool names it exposes; those names are gathered under a 2,000-tool budget shared across the page, so on a page of unusually large servers the trailing entries can list fewer names than they publish. Read one server's full inventory with \`GET /api/v2/workflow-mcp-servers/{serverId}/tools\`. ${WORKSPACE_API_KEY_DENIED}`, + description: `List servers that publish deployed workflows to outside MCP clients; \`GET /api/v2/mcp-servers\` instead lists external servers Sim calls. Entries include client endpoints and tool names. A page shares a 2,000-name budget, so trailing servers may show partial inventories; read a server's tools endpoint for its full set. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'A page of published MCP servers.' }, }), @@ -1877,7 +1875,7 @@ const declaredRoutes = [ resourceOperation('Credentials', { operationId: 'updateCredential', summary: 'Update Credential', - description: `Rotate a service-account credential's secret material, or rename it. Send only the fields to change: an omitted field is left unchanged, and \`description: null\` clears the stored description. Secret fields are write-only and are never returned, and only a service-account credential has any: sending one for a credential of another type answers \`400\` rather than dropping it. The provider re-verifies replacement secret material before it replaces the stored secret, so a rejected secret leaves the stored one untouched and answers \`400\` with the provider's code in \`error.details.providerErrorCode\`; a provider that cannot be reached answers \`503\`. The credential ID is preserved, so every workflow, deployment, paused run, knowledge connector, and webhook that references it keeps working — which disconnecting and re-creating does not. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, + description: `Rename a service-account credential or rotate its write-only secret fields. Omissions preserve values; \`description: null\` clears the description. Secret fields sent for another credential type return \`400\`. The provider verifies replacements before storage: rejection leaves the old secret intact and returns \`400\` with \`providerErrorCode\`; provider outages return \`503\`. The preserved credential ID keeps all references working. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'The updated credential without secret material.' }, }), @@ -2031,7 +2029,7 @@ const declaredRoutes = [ resourceOperation('Catalog', { operationId: 'executeTool', summary: 'Run Tool', - description: `Run one built-in tool and return what it produced. Supply \`input\` using the parameter ids \`GET /api/v2/tools/{toolId}\` publishes; Sim resolves the credential named by \`credentialId\`, injects a hosted API key for the tools it supplies one for, and substitutes environment-variable references, so the request carries arguments rather than secrets. A parameter the tool marks \`user-only\` also accepts \`{{VAR_NAME}}\` as its whole value, resolved server-side against the workspace environment; every other value is sent verbatim, so a literal secret passes through untouched. A tool that runs and refuses is a \`200\` carrying \`status: "failed"\` and the reason — the error envelope is reserved for failures of this API, not of the third party. A tool the workspace's visible blocks do not expose answers \`404\` identically to one that does not exist; one whose integration the workspace does not permit answers \`403\` with \`error.details.code\` \`INTEGRATION_NOT_ALLOWED\`. Hosted-key spend this call incurs is billed to the workspace. ${WORKSPACE_API_KEY_DENIED}`, + description: `Run a built-in tool using published parameter IDs. Sim resolves \`credentialId\`, hosted keys, and whole-value \`{{VAR_NAME}}\` references for \`user-only\` parameters; other values pass through verbatim. Third-party refusal returns \`200\` with \`status: "failed"\`; the error envelope covers API failures. Hidden or missing tools return \`404\`; disallowed integrations return \`403\` with \`error.details.code: INTEGRATION_NOT_ALLOWED\`. Hosted-key use is billed to the workspace. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'The outcome of the tool call.' }, }), @@ -2070,7 +2068,7 @@ const declaredRoutes = [ resourceOperation('Catalog', { operationId: 'listConnectorTypes', summary: 'List Connector Types', - description: `List every knowledge-base connector type and the source configuration each accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with \`multi: true\` stores a \`string[]\` rather than a \`string\`, and a \`canonicalParamId\` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by \`canonicalParamId\` rather than by the field's own \`id\`. ${FULL_SET_LIST}`, + description: `List connector types and accepted source configuration. A field with \`multi: true\` stores \`string[]\`. \`canonicalParamId\` links picker and manual fields that write the same key; send exactly one, keyed by \`canonicalParamId\` rather than its own \`id\`. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, success: { description: 'The connector-type catalog.' }, }), @@ -2115,7 +2113,7 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ tags: [ { name: 'Meta', - description: 'Discover what the calling API key can reach.', + description: 'Discover what the calling API credential can reach.', }, { name: 'Workspaces', @@ -2152,8 +2150,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ description: 'Discover the blocks, tools, and connector types this workspace can build with.', }, ], - security: V2_API_KEY_SECURITY, - securitySchemes: V2_API_KEY_SECURITY_SCHEMES, + security: V2_AUTH_SECURITY, + securitySchemes: V2_AUTH_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, /** diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 7ce7bbe0a18..81d6726b3e3 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -102,8 +102,8 @@ export const ERROR_RESPONSES = { message: 'Invalid request', } ), - Unauthorized: errorResponse(401, 'The API key is missing or invalid.', { - message: 'API key required', + Unauthorized: errorResponse(401, 'The API credential is missing or invalid.', { + message: 'Authentication required', }), UsageLimitExceeded: errorResponse( 402, @@ -214,7 +214,7 @@ export type ErrorResponseId = keyof typeof ERROR_RESPONSES * {@link ERROR_RESPONSES} with a document's own body for the statuses whose message is the * domain's rather than the surface's. * - * Most statuses read the same everywhere — a `401` is `API key required` whatever you were + * Most statuses read the same everywhere — a `401` is `Authentication required` whatever you were * asking for. `409` and `423` are not: nothing in the response layer supplies them, so the * only real strings are each domain's, and one shared example necessarily shows four of the * seven documents a message they never send. Tables answering `Workflow is locked` is the @@ -317,9 +317,9 @@ export function withRequestBodyErrors(route: OpenApiRouteDefinition): OpenApiRou return { ...route, operation: { ...route.operation, errors: derived } } } -export const V2_API_KEY_SECURITY = [{ apiKey: [] }] as const +export const V2_AUTH_SECURITY = [{ apiKey: [] }, { oauthBearer: [] }] as const -export const V2_API_KEY_SECURITY_SCHEMES = { +export const V2_AUTH_SECURITY_SCHEMES = { apiKey: { type: 'apiKey', in: 'header', @@ -327,6 +327,13 @@ export const V2_API_KEY_SECURITY_SCHEMES = { description: 'Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description.', }, + oauthBearer: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'OAuth 2.0 access token', + description: + 'A Sim OAuth access token obtained by a registered client through the authorization-code flow. The token must carry the scope required by the operation.', + }, } as const satisfies Readonly> /** @@ -359,7 +366,7 @@ export const FULL_SET_LIST = 'The bounded set is returned in one page; `nextCurs * Pinned by `contracts/v2/openapi/head-not-safe.test.ts`. */ export const HEAD_MIRRORS_GET = - 'A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return.' + '`HEAD` skips the effect but uses `GET` authorization, returning the corresponding `400`, `401`, `403`, or `404`, or an empty `200` with no payload headers; it confirms access only.' /** * Appended where the skipped payload headers are the ones a caller is most @@ -371,7 +378,7 @@ export const HEAD_MIRRORS_GET = * `headSafe: false` exists to skip. */ export const HEAD_OMITS_PAYLOAD_HEADERS = - 'In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.' + '`HEAD` omits `Content-Length`; use file metadata to size downloads.' /** * Appended to an operation whose semantic operation sets `workspaceApiKey: 'deny'`. @@ -379,7 +386,7 @@ export const HEAD_OMITS_PAYLOAD_HEADERS = * so it is not something a workspace owner can grant around. */ export const WORKSPACE_API_KEY_DENIED = - 'A workspace API key is rejected with `403`; use a personal API key.' + 'A workspace API key is rejected with `403`; use a personal API key or an appropriately scoped OAuth token.' /** * {@link WORKSPACE_API_KEY_DENIED} for an operation behind the resource-concealment @@ -395,7 +402,7 @@ export const WORKSPACE_API_KEY_DENIED = * and the wording it guards drift apart. */ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = - 'A workspace API key is rejected as `404` rather than `403`, because unauthorized resources are concealed; use a personal API key.' + 'A workspace API key is rejected as `404` rather than `403`, because unauthorized resources are concealed; use a personal API key or an appropriately scoped OAuth token.' /** * Appended to the two reads over `workflow_execution_logs`, which is the only @@ -417,7 +424,7 @@ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = * cannot drift into two paraphrases of one window. */ export const RUN_RETENTION = - "Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override." + 'Expired runs are hard-deleted and simply absent. Retention is 30 days from run start on Free, unbounded on Pro and Team, and configured per organization on Enterprise with an optional workspace override.' /** * Response headers a binary download declares on top of the common set. Shared diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index 4163bf6ad58..8fe5aeb0896 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -7,8 +7,8 @@ import { RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, RESOURCE_MUTATION_ERRORS, - V2_API_KEY_SECURITY, - V2_API_KEY_SECURITY_SCHEMES, + V2_AUTH_SECURITY, + V2_AUTH_SECURITY_SCHEMES, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_ERRORS, @@ -262,7 +262,7 @@ const declaredRoutes = [ tableOperation({ operationId: 'updateTable', summary: 'Update Table', - description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. When at least one field landed before the failure the error body carries \`details.applied\` naming those fields — retry with only the ones missing from it. Its absence means nothing was applied.\n\n${FOLDER_TREE_TOO_LARGE}`, + description: `Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags are read-only.\n\nNOT atomic: fields are written independently, so a 4xx may follow a partial update. When fields were applied, \`details.applied\` names them; retry only the missing fields. If it is absent, nothing changed.\n\n${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The updated table.' }, }), @@ -658,7 +658,7 @@ const declaredRoutes = [ operationId: 'queryTableRows', summary: 'Query Rows', description: - "Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`. Set `includeRunState: true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set. Row totals live on the companion `POST /api/v2/tables/{tableId}/query/count`, which is a separate snapshot — a caller needing a consistent pair should take the count first and treat it as a floor.", + 'Query rows using an optional typed condition or `all`/`any` group, ordered sorting, and opaque cursor pagination. Omit the predicate to match all rows. Pages default to a 5 MB cap and may return fewer rows than requested; continue until `nextCursor` is null. Oversized predicates return `413`. `includeRunState` adds per-group outcomes and lowers the row cap. Counts come from a separate snapshot at `POST /query/count`; take the count first and treat it as a floor.', errors: TABLE_QUERY_ERRORS, success: { description: 'A page of matching table rows.' }, }), @@ -1117,7 +1117,7 @@ const declaredRoutes = [ tableOperation({ operationId: 'searchTableRows', summary: 'Search Rows', - description: `Text-search every cell case-insensitively for the substring \`q\`, optionally within a predicate-filtered and sorted view. This is TEXT search, not the structured predicate read: \`POST /api/v2/tables/{tableId}/query\` is that one, and on this surface \`query\` always means a structured predicate while \`search\` always means text.\n\nIt returns cell COORDINATES — \`{ ordinal, rowId, column }\` — and never row data. \`ordinal\` is the row's zero-based index in the same filtered, sorted view \`POST /query\` pages, so read the rows themselves through that. The result is uncursored and capped: at most ${TABLE_LIMITS.MAX_FIND_MATCHES} matches come back and \`truncated\` is \`true\` when more matched than were returned. There is no cursor to page with — narrow \`q\` or the predicate instead.`, + description: `Search every cell case-insensitively for substring \`q\`, optionally within a predicate-filtered, sorted view. This is text search; \`POST /query\` performs structured predicate reads. Results are cell coordinates \`{ ordinal, rowId, column }\`, never row data; \`ordinal\` indexes the same view paged by \`POST /query\`. Results are uncursored and capped at ${TABLE_LIMITS.MAX_FIND_MATCHES}; \`truncated\` signals more matches. Narrow \`q\` or the predicate instead of paging.`, errors: RESOURCE_ERRORS, success: { description: 'The matching table cells.' }, }), @@ -1611,7 +1611,7 @@ const declaredRoutes = [ operationId: 'restoreTablesFolder', summary: 'Restore Folder', description: - "Un-archive a table folder a recursive `DELETE` archived, along with every subfolder and table archived with it. Address it by the path it held when it was deleted. The restore may legally land it elsewhere: a folder whose parent is still archived is re-rooted to `/`, and a name an active sibling has taken meanwhile is deduplicated — so read the returned folder's `path` rather than assuming the requested one. A path that is not archived answers `404`. `DELETE /api/v2/tables/folders` returns the path it archived, which is the value to keep and send here; unlike the files surface, `GET /api/v2/tables/folders` does not yet list archived folders, so a caller that discards that path cannot recover it over the API.", + 'Restore a recursively archived table folder with its subfolders and tables, addressed by its former path. If its parent remains archived, it is re-rooted to `/`; active-name conflicts are deduplicated, so use the returned `path`. Non-archived paths return `404`. Preserve the path returned by `DELETE /api/v2/tables/folders`: unlike the files API, the table-folder list cannot discover archived paths.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The restored table folder and what it brought back.' }, }), @@ -1836,7 +1836,7 @@ const declaredRoutes = [ operationId: 'moveTables', summary: 'Move Tables and Folders', description: - 'Move up to 100 tables and table folders into one destination folder in a single authorized request. Folders are named by canonical path, and `null` or `/` moves to the workspace root. Best-effort per item: a table filed inside a selected folder is reported in `skipped` because the folder already carries it, an entry that resolves to nothing lands in `notFound`, and an item refused by a lock or a folder cycle lands in `failed` with a reason. An invalid destination fails the whole request before anything moves.', + 'Move up to 100 tables and canonical-path folders to one destination; `null` or `/` means the workspace root. Processing is best-effort per item: tables already carried by selected folders are `skipped`, missing items are `notFound`, and lock or cycle refusals are `failed` with reasons. An invalid destination rejects the entire request before any move.', errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'Per-item outcome of the bulk move.' }, }), @@ -1918,8 +1918,8 @@ export const tablesOpenApiDocument = defineOpenApiDocument({ description: 'Manage tables, columns, rows, views, runs, folders, imports, and exports.', }, ], - security: V2_API_KEY_SECURITY, - securitySchemes: V2_API_KEY_SECURITY_SCHEMES, + security: V2_AUTH_SECURITY, + securitySchemes: V2_AUTH_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, errorResponses: withErrorExamples({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index ec23ed2c53a..1b67ab23860 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -18,8 +18,8 @@ import { RESOURCE_ERRORS, RESOURCE_MUTATION_ERRORS, RUN_RETENTION, - V2_API_KEY_SECURITY, - V2_API_KEY_SECURITY_SCHEMES, + V2_AUTH_SECURITY, + V2_AUTH_SECURITY_SCHEMES, V2_BINARY_DOWNLOAD_HEADERS, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, @@ -133,10 +133,10 @@ const WORKFLOW_VERSION_EXAMPLE = { * caller happens to open first. */ const WORKFLOW_DEPLOYMENT_VS_CHAT = - 'Not to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable.' + '`/workflows/{workflowId}/deployment` controls overall API executability; `/deployments/chat` controls only the hosted-chat surface. A workflow can remain deployed without a chat.' const CHAT_VS_WORKFLOW_DEPLOYMENT = - "Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write." + '`/workflows/{workflowId}/deployment` controls API execution; this singleton path controls hosted chat. `PUT` creates or replaces it without a chat-id path.' const CHAT_DEPLOYMENT_EXAMPLE = { id: 'chat_01J8ZK3QW4M6X2R9T7B5C0V2', @@ -300,7 +300,7 @@ const declaredRoutes = [ operationId: 'getWorkflowState', summary: 'Get Workflow State', description: - 'Get the editable draft graph of a workflow: blocks, edges, the loop and parallel containers derived from them, and variables. This is the pollable read — it records no audit event, and `HEAD` mirrors `GET`. The payload is **unsanitized**: it carries workspace-scoped `credentialId`, `knowledgeBaseId`, and `tableId` values verbatim, so it is not portable to another workspace. Use `GET /workflows/{workflowId}/export` for a portable, sanitized copy — and note that export is not a read-modify-write source, because sanitizing it drops every credential binding. Unknown members are stripped, so what this returns is exactly the set of keys `PUT /workflows/{workflowId}/state` accepts.', + 'Get the editable draft graph: blocks, edges, derived loop and parallel containers, and variables. This pollable read records no audit event, and `HEAD` mirrors `GET`. The unsanitized payload includes workspace-scoped credential, knowledge-base, and table ids, so it is not portable. Use `export` for a sanitized copy, but not for read-modify-write because credential bindings are removed. Returned keys exactly match what `PUT /workflows/{workflowId}/state` accepts.', /** * No `413`: unlike the workflow reads beside it this one resolves no * folder path, so it never materializes the workspace's folder tree, and @@ -327,7 +327,8 @@ const declaredRoutes = [ workflowOperation({ operationId: 'replaceWorkflowState', summary: 'Replace Workflow State', - description: `Replace a workflow\u2019s editable draft graph wholesale. \`loops\` and \`parallels\` are accepted but ignored — both are recomputed from \`blocks\`. Omitting \`variables\` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with \`409\` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that \`needsRedeployment\` becomes true; \`POST /workflows/{workflowId}/deploy\` publishes the draft.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — including the warnings the write\u2019s own preparation step raises, and the same \`409\` when an id is already owned by another workflow. Only \`needsRedeployment\` differs: it describes the state before the write.`, + description: + 'Atomically replace the editable draft graph. Concurrent writes are row-locked and last-write-wins; no partial state is stored. `loops` and `parallels` are recomputed from `blocks`; omitted `variables` remain unchanged. Foreign ids return `409`. This leaves deployment unchanged and marks the draft for redeployment; lint is advisory. `dryRun=true` runs the same validation, lint, and conflict checks without persistence, audit, or notification; `needsRedeployment` reflects pre-write state. Workspace keys are rejected; use personal keys or OAuth.', errors: RESOURCE_MUTATION_ERRORS, success: jsonSuccess('The draft graph was replaced.'), }), @@ -364,7 +365,8 @@ const declaredRoutes = [ workflowOperation({ operationId: 'applyWorkflowOperations', summary: 'Apply Workflow Operations', - description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts. The Agent block’s \`inputs.tools\` value is the important exception to that open catalog shape: it is published here as the named \`AgentToolInput\` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — including the warnings the write\u2019s own preparation step raises, and the same \`409\` when an id is already owned by another workflow. Only \`needsRedeployment\` differs: it describes the state before the write.`, + description: + 'Apply graph edits and optional block enablement in one atomic write. Failed operations appear in `skipped`; `deferred` edges resolve when targets exist and must not be retried. With `atomic`, any skip or dropped input returns `409` with `OPERATIONS_NOT_APPLIED` and persists nothing. Non-UUID labels are minted and same-batch references remapped in `mintedBlockIds`. Lint is advisory. `dryRun=true` runs the same checks without persistence, audit, or notification. This changes only the draft. Workspace keys are rejected; use personal keys or OAuth.', errors: RESOURCE_MUTATION_ERRORS, success: jsonSuccess('The batch was applied.'), }), @@ -717,7 +719,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'revertWorkflowVersion', summary: 'Revert Workflow To Version', - description: `Overwrite the editable draft with the graph pinned by a deployment version, discarding every unsaved edit. This is the most destructive operation in the deployment family and it does **not** change what is live — to move production, use \`activate\` or \`rollback\`, both of which leave the draft alone. Pass \`active\` as the version to discard draft edits and return to the live graph. ${WORKSPACE_API_KEY_DENIED}`, + description: `Overwrite the editable draft with a deployment version, irreversibly discarding unsaved edits. This does not change the live version; use \`activate\` or \`rollback\` for production, both of which leave the draft unchanged. Pass \`active\` to reset the draft to the live graph. ${WORKSPACE_API_KEY_DENIED}`, errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The draft after it was overwritten.'), }), @@ -739,7 +741,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'getWorkflowDeployment', summary: 'Get Workflow Deployment', - description: `Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes \`needsRedeployment\` and \`isPublicApi\`.\n\n\`isPublicApi\` is the security-relevant one: while it is \`true\` the deployed workflow executes without an API key, so anyone holding the execution URL can run it — and consume the workspace’s billed usage — anonymously. It is set through \`PATCH /workflows/{workflowId}/deployment\`, and this read is the only way to audit whether it is on.\n\n${WORKFLOW_DEPLOYMENT_VS_CHAT}`, + description: `Read the live version, latest deployment attempt and readiness, draft drift (\`needsRedeployment\`), and \`isPublicApi\`. When \`isPublicApi\` is true, anyone with the execution URL can run and consume billed usage without an API key; change it with \`PATCH /workflows/{workflowId}/deployment\`. ${WORKFLOW_DEPLOYMENT_VS_CHAT}`, errors: RESOURCE_ERRORS, success: jsonSuccess('The current deployment state.'), }), @@ -932,7 +934,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'exportWorkflow', summary: 'Export Workflow', - description: `Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, + description: `Export a portable, secret-sanitized workflow; workspace-scoped bindings must be selected again after import. Exporting records an audit event. ${HEAD_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow export payload.'), }), @@ -1002,7 +1004,7 @@ const declaredRoutes = [ operationId: 'listChatDeployments', summary: 'List Chat Deployments', description: - 'List the workflows a workspace has published as hosted chats. Each entry carries the public `url` a visitor uses — there is no chat subdomain, the identifier is a path segment.\n\nThis is the only chat path not addressed under a workflow, and deliberately so: every chat is a singleton of the workflow it publishes, but "what does this workspace serve" is a question no per-workflow path can answer. Filter by `workflowId` to resolve one workflow\'s chat without holding its id.\n\nEntries are deliberately narrower than the singleton read: `allowedEmails`, `hasPassword`, and `customizations` are available only from `GET /api/v2/workflows/{workflowId}/deployments/chat`, which requires workspace `admin`. That is what keeps this list callable at workspace `read` and by a workspace API key. A stored password is never returned by either.', + 'List hosted chats in a workspace with opaque cursor pagination. Filter by `workflowId` to resolve one workflow’s singleton chat. Each item includes its public URL, whose identifier is a path segment, but omits `allowedEmails`, `hasPassword`, and `customizations`; read those through the admin-only singleton endpoint. This list requires workspace read access and accepts workspace API keys. Stored passwords are never returned.', errors: RESOURCE_ERRORS, success: jsonSuccess('A page of chat deployments.'), }), @@ -1022,7 +1024,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'getWorkflowChatDeployment', summary: 'Get Workflow Chat Deployment', - description: `Read the hosted chat a workflow is published as. Answers \`404\` when the workflow publishes no chat. ${CHAT_VS_WORKFLOW_DEPLOYMENT} The stored password is never returned — \`hasPassword\` reports only whether one is set. This carries the visitor gate — \`authType\`, \`hasPassword\`, and the \`allowedEmails\` allow-list — so it requires workspace \`admin\`, unlike the workspace-wide list. ${WORKSPACE_API_KEY_DENIED}`, + description: `Read a workflow’s singleton hosted chat, or return \`404\` when none exists. ${CHAT_VS_WORKFLOW_DEPLOYMENT} The password is never returned; \`hasPassword\` reports its presence. Visitor-gate fields (\`authType\`, \`hasPassword\`, and \`allowedEmails\`) require workspace admin access. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: jsonSuccess("The workflow's chat deployment."), }), @@ -1043,7 +1045,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'replaceWorkflowChatDeployment', summary: 'Create or Replace Workflow Chat Deployment', - description: `Publish a workflow as a hosted chat, or replace the chat it already publishes. ${CHAT_VS_WORKFLOW_DEPLOYMENT}\n\n**Replace, not merge.** The chat ends up as exactly what the body describes: an omitted optional field takes its platform default rather than whatever the previous chat carried, so sending the same body twice leaves the same result. \`password\` is therefore required whenever \`authType\` is \`"password"\` and rejected otherwise — it is write-only and never readable back, so carrying one over implicitly is the one place a replace would quietly stop meaning replace. \`allowedEmails\` follows the same rule: required and non-empty for \`"email"\` and \`"sso"\`, rejected for the modes that admit no allow-list. \`customizations\` is the one documented exception: it merges per field, so an omitted \`imageUrl\` keeps the stored one rather than clearing it, and customization keys this surface does not declare do not survive the write. That behaviour is shared with the in-app editor and the Copilot deploy tool, which both send partial objects.\n\nThis also deploys the workflow, because a chat serves the live version: a draft that has drifted is republished as part of the call. Two conditions answer \`409\` — an \`identifier\` another live chat already holds, and a workflow deployment attempt still preparing, which the caller can retry once it becomes active. \`authType: "public"\` leaves the chat open to anyone holding the URL. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create or replace hosted chat. Omitted fields reset to defaults except per-field \`customizations\`. \`password\` is write-only and required for password auth; \`allowedEmails\` is required and non-empty for email or SSO. This also deploys the draft. A duplicate identifier or pending deployment returns \`409\`; public auth exposes the URL. ${CHAT_VS_WORKFLOW_DEPLOYMENT} Workspace keys are rejected; use personal keys or OAuth.`, errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'], success: jsonSuccess('The published chat deployment.'), }), @@ -1086,7 +1088,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'executeWorkflowV2', summary: 'Execute Workflow', - description: `Execute the active deployment by default, or select manual execution of the current saved workflow state with \`run.source: "manual"\`. Manual runs require a personal API key with current write access and support synchronous or Server-Sent Event execution only; workspace keys, anonymous public access, and async manual runs are rejected. A manual run can enter through one runnable trigger (including external integration/webhook triggers) or resume at a named block from the exact same-workflow run identified by \`sourceRunId\`; the server loads that run's persisted snapshot, which is never accepted from the request. Omit a trigger block id only when the saved workflow has exactly one runnable trigger. Public deployed workflows permit anonymous synchronous and streaming execution, while asynchronous deployed execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with \`status: "failed"\` and \`error.code: "TIMEOUT"\` rather than an HTTP error, so branch on \`status\`. ${EXECUTE_OPTION_CONSTRAINTS}`, + description: `Execute the deployment, or use \`run.source: "manual"\` for draft state. Manual runs require a personal key or OAuth write access; workspace keys, anonymous callers, and async mode are rejected. Start at a runnable trigger, or resume from \`sourceRunId\` using the same-workflow snapshot. Public deployments allow anonymous sync or streaming; async requires credentials. Sync timeouts return \`200\` with failed status and \`TIMEOUT\`. ${EXECUTE_OPTION_CONSTRAINTS}`, errors: [ 'BadRequest', 'Unauthorized', @@ -1100,7 +1102,7 @@ const declaredRoutes = [ 'InternalError', 'ServiceUnavailable', ], - security: [...V2_API_KEY_SECURITY, {}], + security: [...V2_AUTH_SECURITY, {}], success: { byStatus: { 200: { @@ -1166,7 +1168,7 @@ const declaredRoutes = [ workflowRunOperation({ operationId: 'getWorkflowRunV2', summary: 'Get Workflow Run', - description: `Get current workflow run state, optionally including final and block outputs. With \`includeOutput\`, \`files\` lists the files the run produced, each with a \`downloadPath\`; add \`includeFileBase64\` to inline their bytes, which answers \`413\` naming the download path when a single file, or the run's inlined total, exceeds the 16 MiB ceiling. Because inlining reads object storage, this \`GET\` is not a safe read. ${HEAD_MIRRORS_GET}`, + description: `Get current run state with optional final and block outputs. With \`includeOutput\`, \`files\` includes download paths; \`includeFileBase64\` reads object storage to inline bytes and returns \`413\` with the download path when one file or the total exceeds 16 MiB. ${HEAD_MIRRORS_GET}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow run status.'), }), @@ -1214,7 +1216,7 @@ const declaredRoutes = [ workflowRunOperation({ operationId: 'downloadWorkflowRunFileV2', summary: 'Download Workflow Run File', - description: `Download one file a run produced. The run resource reports the files a run emitted; address one of them by its \`id\` here. Run output carries \`/api/files/serve/...\` URLs that reject API keys, so this is the byte path out of a run for an API-key caller. Execution objects are not retained indefinitely, so a \`404\` for a file an older run produced is expected rather than a fault. ${RUN_RETENTION} Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + description: `Download one run-produced file by id. Downloads record an audit event. ${RUN_RETENTION} ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, errors: [...RESOURCE_CONFLICT_ERRORS], success: { description: 'The run file bytes.', @@ -1442,8 +1444,8 @@ export const workflowsOpenApiDocument = defineOpenApiDocument({ description: 'Inspect, resume, and cancel workflow runs.', }, ], - security: V2_API_KEY_SECURITY, - securitySchemes: V2_API_KEY_SECURITY_SCHEMES, + security: V2_AUTH_SECURITY, + securitySchemes: V2_AUTH_SECURITY_SCHEMES, headers: { ...V2_BINARY_DOWNLOAD_HEADERS, ...V2_COMMON_HEADERS }, errorSchema: V2_ERROR_SCHEMA, errorResponses: ERROR_RESPONSES, diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index fd594f14f90..a113a094958 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -1,10 +1,7 @@ import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { LIST_SORT_ORDERS, type ListSortOrder } from '@/lib/api/list-query' -import { - FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, - FORBIDDEN_DETAIL_CODES, -} from '@/lib/core/application/forbidden' +import { FORBIDDEN_DETAIL_CODES } from '@/lib/core/application/forbidden' import { FolderPathError, MAX_FOLDER_PATH_BYTES, @@ -186,6 +183,21 @@ export const v2ResourceWebUrlSchema = z .url() .describe('Canonical absolute URL for opening this resource in the Sim web application.') +export const v2ForbiddenDetailCodeSchema = z.enum(FORBIDDEN_DETAIL_CODES).meta({ + id: 'V2ForbiddenDetailCode', + title: 'Forbidden detail code', + description: 'Stable cause code for an actionable `403` response.', +}) + +const v2ActionableForbiddenDetailsSchema = z + .object({ code: v2ForbiddenDetailCodeSchema }) + .catchall(z.unknown().describe('Additional context for this refusal.')) + .meta({ + id: 'V2ActionableForbiddenDetails', + title: 'Actionable forbidden details', + description: 'Machine-readable cause and optional context for an actionable `403` response.', + }) + /** Canonical v2 error envelope. */ export const v2ErrorResponseSchema = z.object({ error: z @@ -193,15 +205,13 @@ export const v2ErrorResponseSchema = z.object({ code: z.string().describe('Stable machine-readable error code.'), message: z.string().describe('Human-readable explanation of the error.'), details: z - .unknown() + .union([ + v2ActionableForbiddenDetailsSchema, + z.unknown().describe('Other structured context defined by the specific error.'), + ]) .optional() .describe( - [ - 'Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:', - ...FORBIDDEN_DETAIL_CODES.map( - (code) => `- \`${code}\` — ${FORBIDDEN_DETAIL_CODE_DESCRIPTIONS[code]}` - ), - ].join('\n') + 'Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.' ), }) .describe('Canonical error details.'), diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts index 4cc7ad15961..c9d8b506f66 100644 --- a/apps/sim/lib/api/contracts/v2/uploads.ts +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -38,7 +38,7 @@ export const v2OptionalUploadTokenHeadersSchema = z.object({ * contract. */ const TRANSFER_STEP_CONTRACT = - 'Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim\'s own data plane: success is `204` with an empty body, and a failure is the same `{ "error": { "code", "message" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider\'s own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider\'s error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.' + 'Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML.' export const v2PutUploadTransferSchema = z .object({ @@ -104,7 +104,7 @@ export const v2UploadPartUrlSchema = z .string() .url() .describe( - `Signed URL for this upload part. ${TRANSFER_STEP_CONTRACT}\n\nYou do not need to retain the \`ETag\` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so \`POST .../complete\` only has to happen after every part has been sent.` + `Signed URL for this upload part. ${TRANSFER_STEP_CONTRACT} Do not retain part \`ETag\` values; after every part succeeds, call the completion endpoint without a request body.` ), headers: z .record(z.string(), z.string()) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 2a2e828361b..e19bd0d0990 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1257,14 +1257,14 @@ export const v2ExecuteWorkflowBodySchema = z run: v2WorkflowRunSelectionSchema .optional() .describe( - 'Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires a personal API key with write access and supports synchronous or streamed runs only.' + 'Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.' ), async: z .boolean() .optional() .default(false) .describe( - 'Queue the run and return a 202 receipt when true. Requires an API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).' + 'Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).' ), /** * An upper bound on the request, not the effective timeout: the server @@ -2631,12 +2631,9 @@ export type V2WorkflowSkippedItem = z.output * not describe it differently. */ const WORKFLOW_OPERATION_PARAM_ENVELOPE = - "`inputs` carries the block's own configuration keyed by sub-block id, for example " + - '`inputs: { model: "gpt-4o", systemPrompt: "..." }` — never wrapped in `subBlocks`. ' + - 'Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, ' + - '`advancedMode`. `connections` is keyed by source handle and each value is a target ' + - 'block id, `{ block, handle }`, or an array of either; `success` is accepted as an ' + - 'alias for the `source` handle.' + '`inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, ' + + '`triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to ' + + 'target ids, `{ block, handle }`, or arrays; `success` aliases `source`.' const v2AgentToolUsageControlSchema = z .enum(['auto', 'force', 'none']) @@ -2934,12 +2931,9 @@ const v2WorkflowOperationParamsSchema = z ) ) .describe( - 'Fields to change on the target block. Send only what changes. Accepted keys: `inputs`, ' + - '`name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, ' + - `\`advancedMode\`. ${WORKFLOW_OPERATION_PARAM_ENVELOPE} Re-sending \`connections\` ` + - "replaces that block's outgoing edges, so use `removeEdges` — " + - '`[{ targetBlockId, sourceHandle? }]`, `sourceHandle` defaulting to `source` — to drop ' + - 'one edge without restating the rest.' + 'Patch only supplied fields: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, ' + + `\`retry\`, \`triggerMode\`, and \`advancedMode\`. ${WORKFLOW_OPERATION_PARAM_ENVELOPE} ` + + 'Re-sending `connections` replaces outgoing edges; use `removeEdges` to delete selected edges.' ) const v2AddWorkflowBlockParamsSchema = z @@ -2956,8 +2950,8 @@ const v2AddWorkflowBlockParamsSchema = z }) .catchall(z.unknown().describe('One block-specific input or connection descriptor.')) .describe( - 'Block type and name, plus any block-specific configuration. Beyond `type` and `name` the ' + - 'accepted keys are `inputs`, `connections`, `retry`, `triggerMode`, and `advancedMode`. ' + + 'Block `type`, `name`, and optional `inputs`, `connections`, `retry`, `triggerMode`, or ' + + '`advancedMode`. ' + WORKFLOW_OPERATION_PARAM_ENVELOPE ) @@ -2989,8 +2983,7 @@ const v2InsertIntoSubflowParamsSchema = z }) .catchall(z.unknown().describe('One block-specific input or connection descriptor.')) .describe( - 'Container, block type and name, plus any block-specific configuration. Takes the same ' + - 'keys as an `add`: `inputs`, `connections`, `retry`, `triggerMode`, `advancedMode`. ' + + 'Container, block `type`, `name`, and the same optional fields as `add`. ' + WORKFLOW_OPERATION_PARAM_ENVELOPE ) @@ -3163,7 +3156,7 @@ export const v2ApplyWorkflowOperationsDataSchema = v2WorkflowGraphWriteResultSch mintedBlockIds: z .record(z.string(), z.string().describe('The id the block was actually given.')) .describe( - 'The id each newly created block was actually given, keyed by the `block_id` you asked for, and present only for the ones that differ. A `block_id` on an `add` or `insert_into_subflow` that is not already a UUID is replaced with a minted one, so this is how you learn what to reference afterwards. Within a single batch you can keep using your own ids — references between operations are remapped for you — but a later request must use the minted id, so send your own UUIDs when you want an id you chose to survive.' + 'Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged.' ), lint: v2WorkflowLintSchema, dryRun: z diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 318562ae4bf..ff4e201de94 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -253,6 +253,8 @@ export const deploymentFeaturesSchema = z.object({ dataDrains: z.boolean(), dataRetention: z.boolean(), inbox: z.boolean(), + /** Sim's OAuth provider is a deployment toggle rather than an enterprise entitlement. */ + oauthProvider: z.boolean(), sandboxes: z.boolean(), sessionPolicies: z.boolean(), sso: z.boolean(), diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts index 71773811294..457e952db5c 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -10,8 +10,13 @@ const mocks = vi.hoisted(() => ({ getHighestPrioritySubscription: vi.fn(), })) -vi.mock('@/lib/core/config/env-flags', () => ({ isAuthDisabled: false })) +vi.mock('@/lib/core/config/env-flags', () => ({ + isAuthDisabled: false, + isOAuthProviderEnabled: true, +})) vi.mock('@/lib/api-key/crypto', () => ({ hashApiKey: (value: string) => `hash:${value}` })) +vi.mock('@/lib/auth/oauth-provider', () => ({ OAUTH_ACCESS_TOKEN_PREFIX: 'sim_oat_' })) +vi.mock('@sim/security/hash', () => ({ sha256Hex: (value: string) => `oauth-hash:${value}` })) vi.mock('@/lib/api-key/service', () => ({ updateApiKeyLastUsed: mocks.updateLastUsed })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveWorkspaceBillingPayer: mocks.resolveWorkspaceBillingPayer, @@ -24,6 +29,10 @@ import { authenticateV2ApiKey, V2ApiKeyUnauthenticatedError, } from '@/lib/api/server/routes/v2-api-key-auth' +import { + hasV2Credential, + readV2CredentialHeaders, +} from '@/lib/api/server/routes/v2-credential-headers' describe('v2 API key authentication', () => { beforeEach(() => { @@ -45,7 +54,7 @@ describe('v2 API key authentication', () => { }, ]) - const result = await authenticateV2ApiKey('secret') + const result = await authenticateV2ApiKey({ apiKey: 'secret', bearer: null }) expect(result).toEqual({ principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, @@ -76,7 +85,7 @@ describe('v2 API key authentication', () => { }, ]) - const result = await authenticateV2ApiKey('secret') + const result = await authenticateV2ApiKey({ apiKey: 'secret', bearer: null }) expect(result.keyExpiresAt).toEqual(new Date('2027-01-01T00:00:00.000Z')) }) @@ -101,7 +110,7 @@ describe('v2 API key authentication', () => { }, }) - const result = await authenticateV2ApiKey('secret') + const result = await authenticateV2ApiKey({ apiKey: 'secret', bearer: null }) expect(result).toEqual({ principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, @@ -130,13 +139,13 @@ describe('v2 API key authentication', () => { payerSubscription: null, }) - await expect(authenticateV2ApiKey('secret')).resolves.toMatchObject({ + await expect(authenticateV2ApiKey({ apiKey: 'secret', bearer: null })).resolves.toMatchObject({ principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, }) }) it('treats missing, banned, and expired credentials as unauthenticated', async () => { - await expect(authenticateV2ApiKey('missing')).rejects.toBeInstanceOf( + await expect(authenticateV2ApiKey({ apiKey: 'missing', bearer: null })).rejects.toBeInstanceOf( V2ApiKeyUnauthenticatedError ) @@ -150,7 +159,7 @@ describe('v2 API key authentication', () => { userBanned: true, }, ]) - await expect(authenticateV2ApiKey('banned')).rejects.toBeInstanceOf( + await expect(authenticateV2ApiKey({ apiKey: 'banned', bearer: null })).rejects.toBeInstanceOf( V2ApiKeyUnauthenticatedError ) @@ -164,7 +173,7 @@ describe('v2 API key authentication', () => { userBanned: false, }, ]) - await expect(authenticateV2ApiKey('expired')).rejects.toBeInstanceOf( + await expect(authenticateV2ApiKey({ apiKey: 'expired', bearer: null })).rejects.toBeInstanceOf( V2ApiKeyUnauthenticatedError ) }) @@ -173,6 +182,128 @@ describe('v2 API key authentication', () => { const failure = new Error('database unavailable') dbChainMockFns.limit.mockRejectedValueOnce(failure) - await expect(authenticateV2ApiKey('secret')).rejects.toBe(failure) + await expect(authenticateV2ApiKey({ apiKey: 'secret', bearer: null })).rejects.toBe(failure) + }) +}) + +describe('v2 bearer token authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getHighestPrioritySubscription.mockResolvedValue({ + plan: 'pro', + referenceId: 'user-1', + }) + }) + + function tokenRow(overrides: Record = {}) { + return { + id: 'token-1', + userId: 'user-1', + clientId: 'sim-cli', + scopes: ['openid', 'api:read', 'api:write'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + clientDisabled: false, + userBanned: false, + userExists: 'user-1', + ...overrides, + } + } + + it('reads the credential headers as a pair, ignoring other Authorization schemes', () => { + expect( + readV2CredentialHeaders(new Headers({ 'x-api-key': 'k', authorization: 'Bearer t' })) + ).toEqual({ apiKey: 'k', bearer: 't', malformedOAuthBearer: false }) + expect(readV2CredentialHeaders(new Headers({ authorization: 'Basic abc' }))).toEqual({ + apiKey: null, + bearer: null, + malformedOAuthBearer: false, + }) + expect(hasV2Credential(new Headers({ authorization: 'Bearer sim_oat_t' }))).toBe(true) + expect(hasV2Credential(new Headers())).toBe(false) + }) + + /** + * A public deployed workflow is routinely called by a gateway that forwards + * its own `Authorization` header. Counting that as a Sim credential would + * send an execution that used to run anonymously into a 401. + */ + it("does not treat somebody else's bearer token as a Sim credential", () => { + expect(hasV2Credential(new Headers({ authorization: 'Bearer ghp_something' }))).toBe(false) + expect(hasV2Credential(new Headers({ authorization: 'Bearer sim_oat_t' }))).toBe(true) + expect(hasV2Credential(new Headers({ authorization: 'Bearer sim_oat_t extra' }))).toBe(true) + }) + + it('rejects a malformed Sim bearer instead of treating optional auth as anonymous', async () => { + const refused = await authenticateV2ApiKey({ + apiKey: null, + bearer: null, + malformedOAuthBearer: true, + }).catch((error) => error) + + expect(refused).toBeInstanceOf(V2ApiKeyUnauthenticatedError) + expect(refused.challenge).toBe('bearer') + }) + + it('authenticates an OAuth token as its user, rate-limited on the user plan', async () => { + queueTableRows(schemaMock.oauthAccessToken, [tokenRow()]) + + const result = await authenticateV2ApiKey({ apiKey: null, bearer: 'sim_oat_secret' }) + + expect(result).toEqual({ + principal: { + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['openid', 'api:read', 'api:write'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }, + rateLimitSubjectIds: ['oauth-token:token-1', 'user:user-1'], + rateLimitSubscription: { plan: 'pro', referenceId: 'user-1' }, + keyType: 'oauth_access_token', + keyExpiresAt: new Date('2099-01-01T00:00:00.000Z'), + }) + expect(mocks.updateLastUsed).not.toHaveBeenCalled() + }) + + it('prefers the API key when both credentials are presented', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: null, + userBanned: false, + }, + ]) + + const result = await authenticateV2ApiKey({ apiKey: 'secret', bearer: 'sim_oat_ignored' }) + + expect(result.keyType).toBe('personal') + }) + + it('answers a refused bearer with the bearer challenge, and a missing one with the key challenge', async () => { + const refused = await authenticateV2ApiKey({ apiKey: null, bearer: 'sim_oat_unknown' }).catch( + (error) => error + ) + expect(refused).toBeInstanceOf(V2ApiKeyUnauthenticatedError) + expect(refused.challenge).toBe('bearer') + + const missing = await authenticateV2ApiKey({ apiKey: null, bearer: null }).catch( + (error) => error + ) + expect(missing).toBeInstanceOf(V2ApiKeyUnauthenticatedError) + expect(missing.challenge).toBe('api_key') + expect(missing.message).toBe('API key or OAuth access token required') + }) + + it('refuses a token whose client was disabled', async () => { + queueTableRows(schemaMock.oauthAccessToken, [tokenRow({ clientDisabled: true })]) + + await expect( + authenticateV2ApiKey({ apiKey: null, bearer: 'sim_oat_secret' }) + ).rejects.toBeInstanceOf(V2ApiKeyUnauthenticatedError) }) }) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts index e147c28dbab..2c6c72e4104 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -1,18 +1,39 @@ -import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import type { + OAuthAccessTokenPrincipal, + PersonalApiKeyPrincipal, + WorkspaceApiKeyPrincipal, +} from '@sim/auth/principal' import { db } from '@sim/db' import { apiKey, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import type { V2CredentialHeaders } from '@/lib/api/server/routes/v2-credential-headers' import { hashApiKey } from '@/lib/api-key/crypto' import { updateApiKeyLastUsed } from '@/lib/api-key/service' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { InvalidOAuthAccessTokenError, verifyOAuthAccessToken } from '@/lib/auth/oauth-access-token' import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { isAuthDisabled, isOAuthProviderEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('V2ApiKeyAuth') -export type V2ApiKeyPrincipal = PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal +/** + * The credentials v2 authenticates: an API key in `x-api-key`, or one of Sim's + * own OAuth access tokens as `Authorization: Bearer`. The module keeps its + * API-key name because the key path is unchanged and every route names its + * policy object; the bearer path is the addition. + */ +export type V2ApiKeyPrincipal = + | PersonalApiKeyPrincipal + | WorkspaceApiKeyPrincipal + | OAuthAccessTokenPrincipal + +/** Which credential the caller presented, as `/api/v2/meta` reports it. */ +export type V2CredentialType = 'personal' | 'workspace' | 'oauth_access_token' + +/** Which challenge a 401 should lead with: the scheme the caller tried, or the key when it tried nothing. */ +export type V2AuthChallenge = 'api_key' | 'bearer' interface RateLimitSubscription { plan: string @@ -23,18 +44,21 @@ export interface V2ApiKeyAuthContext { principal: V2ApiKeyPrincipal rateLimitSubjectIds: readonly [string, ...string[]] rateLimitSubscription: RateLimitSubscription | null - keyType: 'personal' | 'workspace' + keyType: V2CredentialType /** - * When the authenticated key expires, or `null` when it never does — read - * from the same row `requireValidRow` has just checked, so no surface has to - * go back to the API-key table for it. `/api/v2/meta` reports it, and the - * application layer must never query `api_key` itself to find it out. + * When the authenticated credential expires, or `null` when it never does — + * read from the same row the authenticator has just checked, so no surface + * has to go back to the credential table for it. `/api/v2/meta` reports it, + * and the application layer must never query `api_key` itself to find it out. */ keyExpiresAt: Date | null } export class V2ApiKeyUnauthenticatedError extends Error { - constructor(message = 'Invalid API key') { + constructor( + message = 'Invalid API key', + readonly challenge: V2AuthChallenge = 'api_key' + ) { super(message) this.name = 'V2ApiKeyUnauthenticatedError' } @@ -64,26 +88,12 @@ function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { throw new Error(`API key ${row.id} has an invalid persisted type/workspace combination`) } -export async function authenticateV2ApiKey( - apiKeyHeader: string | null -): Promise { - if (isAuthDisabled) { - return { - principal: { - kind: 'personal_api_key', - userId: ANONYMOUS_USER_ID, - keyId: 'auth-disabled', - }, - rateLimitSubjectIds: [`user:${ANONYMOUS_USER_ID}`], - rateLimitSubscription: null, - keyType: 'personal', - keyExpiresAt: null, - } - } - if (!apiKeyHeader) { - throw new V2ApiKeyUnauthenticatedError('API key required') - } +async function personalSubscription(userId: string): Promise { + const subscription = await getHighestPrioritySubscription(userId, { onError: 'throw' }) + return subscription ? { plan: subscription.plan, referenceId: subscription.referenceId } : null +} +async function authenticateApiKey(apiKeyHeader: string): Promise { const [candidate] = await db .select({ id: apiKey.id, @@ -103,13 +113,10 @@ export async function authenticateV2ApiKey( logger.debug('Authenticated v2 API key', { keyId: row.id, keyType: row.type }) if (row.type === 'personal') { - const subscription = await getHighestPrioritySubscription(row.userId, { onError: 'throw' }) return { principal: { kind: 'personal_api_key', userId: row.userId, keyId: row.id }, rateLimitSubjectIds: [`api-key:${row.id}`, `user:${row.userId}`], - rateLimitSubscription: subscription - ? { plan: subscription.plan, referenceId: subscription.referenceId } - : null, + rateLimitSubscription: await personalSubscription(row.userId), keyType: 'personal', keyExpiresAt: row.expiresAt, } @@ -136,3 +143,63 @@ export async function authenticateV2ApiKey( keyExpiresAt: row.expiresAt, } } + +/** + * An OAuth token is rate-limited like the personal key it stands in for: per + * token and per user, on the user's own plan. A client that holds many tokens + * for one user still shares that user's bucket. + */ +async function authenticateBearer(token: string): Promise { + if (!isOAuthProviderEnabled) { + throw new V2ApiKeyUnauthenticatedError('Bearer tokens are not accepted', 'bearer') + } + let principal: OAuthAccessTokenPrincipal + try { + principal = await verifyOAuthAccessToken(token) + } catch (error) { + if (error instanceof InvalidOAuthAccessTokenError) { + logger.warn('Invalid OAuth access token attempted', { reason: error.reason }) + throw new V2ApiKeyUnauthenticatedError('Invalid access token', 'bearer') + } + throw error + } + return { + principal, + rateLimitSubjectIds: [`oauth-token:${principal.tokenId}`, `user:${principal.userId}`], + rateLimitSubscription: await personalSubscription(principal.userId), + keyType: 'oauth_access_token', + keyExpiresAt: principal.expiresAt, + } +} + +/** + * Authenticates a v2 request from its credential headers. + * + * `x-api-key` wins when both are present, so a client that always sends a key + * and happens to also carry an `Authorization` header keeps the behavior it + * had before bearer tokens existed. A bearer token is only consulted when no + * key is offered. + */ +export async function authenticateV2ApiKey( + credential: V2CredentialHeaders +): Promise { + if (isAuthDisabled) { + return { + principal: { + kind: 'personal_api_key', + userId: ANONYMOUS_USER_ID, + keyId: 'auth-disabled', + }, + rateLimitSubjectIds: [`user:${ANONYMOUS_USER_ID}`], + rateLimitSubscription: null, + keyType: 'personal', + keyExpiresAt: null, + } + } + if (credential.apiKey) return authenticateApiKey(credential.apiKey) + if (credential.bearer) return authenticateBearer(credential.bearer) + if (credential.malformedOAuthBearer) { + throw new V2ApiKeyUnauthenticatedError('Invalid access token', 'bearer') + } + throw new V2ApiKeyUnauthenticatedError('API key or OAuth access token required') +} diff --git a/apps/sim/lib/api/server/routes/v2-credential-headers.ts b/apps/sim/lib/api/server/routes/v2-credential-headers.ts new file mode 100644 index 00000000000..aae2a5f46db --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-credential-headers.ts @@ -0,0 +1,48 @@ +import { parseBearerToken } from '@/lib/auth/oauth-access-token' +import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' + +export interface V2CredentialHeaders { + apiKey: string | null + bearer: string | null + malformedOAuthBearer?: boolean +} + +/** + * The credentials a v2 request presents, read from its headers. + * + * Separate from the verifier because reading a header is not authentication: + * the route builders ask this before authenticating, to tell an anonymous + * request from one carrying a credential, and every route test mocks the + * verifier module to keep the database out of reach. Leaving these there made + * the pure request-shape question unavailable to any of them. + */ +export function readV2CredentialHeaders(headers: Headers): V2CredentialHeaders { + const bearer = parseBearerToken(headers) + const authorization = headers.get('authorization') + const malformedOAuthBearer = + bearer === null && + authorization !== null && + /^Bearer[ ]+/i.test(authorization) && + authorization + .replace(/^Bearer[ ]+/i, '') + .trim() + .startsWith(OAUTH_ACCESS_TOKEN_PREFIX) + return { apiKey: headers.get('x-api-key'), bearer, malformedOAuthBearer } +} + +/** + * Whether the request presents any credential v2 knows how to read. + * + * A bearer counts only when it carries Sim's own access-token prefix. The + * optional-auth path uses this to tell an anonymous request from an + * authenticated one, and a public deployed workflow is routinely called by a + * gateway that forwards its own unrelated `Authorization` header — treating + * that as a Sim credential would turn a working anonymous execution into a + * 401. A bearer that is not one of ours was never a v2 credential. + */ +export function hasV2Credential(headers: Headers): boolean { + const credential = readV2CredentialHeaders(headers) + if (credential.apiKey !== null) return true + if (credential.malformedOAuthBearer) return true + return credential.bearer?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) === true +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index 80fec5aa912..0f797c5082d 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -30,6 +30,8 @@ vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' import { + admitOptionalV2Request, + admitV2Request, defineV2JsonRoute, type V2ErrorPolicy, v2ApiKeyAuth, @@ -148,7 +150,11 @@ describe('defineV2JsonRoute', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.preauthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt, + }) v2RouteMocks.operationRate.mockResolvedValue(allowedRate) }) @@ -226,14 +232,14 @@ describe('defineV2JsonRoute', () => { it('renders invalid credentials as 401 without continuing admission', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await createHandler()(request()) expect(response.status).toBe(401) await expect(response.json()).resolves.toEqual({ - error: { code: 'UNAUTHORIZED', message: 'API key required' }, + error: { code: 'UNAUTHORIZED', message: 'API key or OAuth access token required' }, }) expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() }) @@ -327,7 +333,12 @@ describe('defineV2JsonRoute', () => { const present = vi.fn<(result: Result) => { data: { value: string } }>() const onSuccess = vi.fn() const mapInput = vi.fn<(input: ParsedRequest) => Input>() - const response = await createHandler({ execute, present, onSuccess, mapInput })(request({})) + const response = await createHandler({ + execute, + present, + onSuccess, + mapInput, + })(request({})) expect(response.status).toBe(400) expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() @@ -384,7 +395,10 @@ describe('defineV2JsonRoute', () => { expect(response.status).toBe(499) await expect(response.json()).resolves.toEqual({ - error: { code: 'CLIENT_CLOSED_REQUEST', message: 'Client cancelled request' }, + error: { + code: 'CLIENT_CLOSED_REQUEST', + message: 'Client cancelled request', + }, }) expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') @@ -440,7 +454,10 @@ describe('defineV2JsonRoute', () => { expect(response.status).toBe(413) await expect(response.json()).resolves.toEqual({ - error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + error: { + code: 'PAYLOAD_TOO_LARGE', + message: 'Request body is too large', + }, }) expect(response.headers.get('Cache-Control')).toBe('private, no-store') }) @@ -452,7 +469,12 @@ describe('defineV2JsonRoute', () => { maxBodyBytes, payloadTooLargeResponse: () => NextResponse.json( - { error: { code: 'PAYLOAD_TOO_LARGE', message: 'Import archive is too large' } }, + { + error: { + code: 'PAYLOAD_TOO_LARGE', + message: 'Import archive is too large', + }, + }, { status: 413 } ), }, @@ -460,7 +482,10 @@ describe('defineV2JsonRoute', () => { expect(response.status).toBe(413) await expect(response.json()).resolves.toEqual({ - error: { code: 'PAYLOAD_TOO_LARGE', message: 'Import archive is too large' }, + error: { + code: 'PAYLOAD_TOO_LARGE', + message: 'Import archive is too large', + }, }) }) }) @@ -481,7 +506,11 @@ describe('defineV2JsonRoute unreadable body classification', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.preauthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt, + }) v2RouteMocks.operationRate.mockResolvedValue(allowedRate) }) @@ -526,7 +555,10 @@ describe('defineV2JsonRoute unreadable body classification', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toEqual({ - error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + error: { + code: 'BAD_REQUEST', + message: 'Request body must be valid JSON', + }, }) }) @@ -568,7 +600,12 @@ describe('defineV2JsonRoute unreadable body classification', () => { parseOptions: { invalidJsonResponse: () => NextResponse.json( - { error: { code: 'BAD_REQUEST', message: 'Import archive is not JSON' } }, + { + error: { + code: 'BAD_REQUEST', + message: 'Import archive is not JSON', + }, + }, { status: 400 } ), }, @@ -598,7 +635,10 @@ describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { path: '/api/v2/widgets/[widgetId]', params: z.object({ widgetId: z.string() }).strict(), query: z.object({ workspaceId: z.string().min(1) }).strict(), - response: { mode: 'json', schema: z.object({ data: z.object({ value: z.string() }) }) }, + response: { + mode: 'json', + schema: z.object({ data: z.object({ value: z.string() }) }), + }, }) type HeadInput = { widgetId: string; workspaceId: string } @@ -620,7 +660,10 @@ describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { headSafe: false, rateLimit: v2RateLimits.publicApi, errorPolicy: v2OrchestrationErrorPolicy, - mapInput: ({ params, query }) => ({ widgetId: params.widgetId, ...query }), + mapInput: ({ params, query }) => ({ + widgetId: params.widgetId, + ...query, + }), useCase, present: (result) => ({ data: result }), }) @@ -638,7 +681,11 @@ describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.preauthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt, + }) v2RouteMocks.operationRate.mockResolvedValue(allowedRate) }) @@ -738,7 +785,10 @@ const presenterContract = defineRouteContract({ response: { mode: 'json', status: 201, - schema: z.object({ data: z.object({ value: z.string() }), nextCursor: z.string() }), + schema: z.object({ + data: z.object({ value: z.string() }), + nextCursor: z.string(), + }), }, }) @@ -752,7 +802,11 @@ describe('defineV2JsonRoute presentation', () => { beforeEach(() => { vi.clearAllMocks() v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.preauthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt, + }) v2RouteMocks.operationRate.mockResolvedValue(allowedRate) }) @@ -776,7 +830,10 @@ describe('defineV2JsonRoute presentation', () => { const response = await handler( new NextRequest('http://localhost/api/v2/widgets/widget-1/pages?sort=asc&workspaceId=ws-1', { method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + headers: { + 'content-type': 'application/json', + 'x-api-key': 'secret', + }, body: JSON.stringify({ value: 'ok' }), }), { params: Promise.resolve({ widgetId: 'widget-1' }) } @@ -797,3 +854,168 @@ describe('defineV2JsonRoute presentation', () => { ) }) }) + +describe('defineV2JsonRoute OAuth scope admission', () => { + const oauthAuth = (scopes: readonly string[]) => + ({ + principal: { + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes, + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }, + rateLimitSubjectIds: ['oauth-token:token-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'oauth_access_token', + keyExpiresAt: null, + }) as unknown as V2ApiKeyAuthContext + + function bearerRequest(): NextRequest { + return new NextRequest('http://localhost/api/v2/widgets', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer sim_oat_x', + }, + body: JSON.stringify({ value: 'ok' }), + }) + } + + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.preauthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt, + }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + /** + * The whole point of a read-only grant. The scope is derived from the HTTP + * method rather than the operation's `minimumRole`, because several POST + * routes only read; a role-derived rule let a read-only token write. + */ + it('refuses a read-only token on an unsafe method before the use case runs', async () => { + v2RouteMocks.authenticate.mockResolvedValue(oauthAuth(['openid', 'api:read'])) + const execute = vi.fn() + + const response = await createHandler({ execute })(bearerRequest(), { + params: undefined, + }) + + expect(response.status).toBe(403) + expect(response.headers.get('www-authenticate')).toContain('insufficient_scope') + expect(response.headers.get('www-authenticate')).toContain('api:write') + expect(execute).not.toHaveBeenCalled() + }) + + it('admits the same token once the grant carries api:write', async () => { + v2RouteMocks.authenticate.mockResolvedValue(oauthAuth(['openid', 'api:read', 'api:write'])) + + const response = await createHandler()(bearerRequest(), { + params: undefined, + }) + + expect(response.status).toBe(201) + }) + + /** + * `readOnly` is how a POST that only reads — a search or a query whose filter + * is too large for a query string — declares itself, so a read-only token can + * still use it. + */ + it('admits a read-only token on a POST the route declares read-only', async () => { + v2RouteMocks.authenticate.mockResolvedValue(oauthAuth(['openid', 'api:read'])) + const handler = defineV2JsonRoute({ + contract, + auth: v2ApiKeyAuth, + operation, + readOnly: true, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: { operation, execute: async ({ input }) => input }, + present: (result) => ({ data: result }), + }) + + const response = await handler(bearerRequest(), { params: undefined }) + + expect(response.status).toBe(201) + }) + + it('leaves an API-key principal alone, which carries no scopes at all', async () => { + v2RouteMocks.authenticate.mockResolvedValue(auth) + + const response = await createHandler()(request(), { params: undefined }) + + expect(response.status).toBe(201) + }) + + it('enforces write scope through raw-route admission', async () => { + v2RouteMocks.authenticate.mockResolvedValue(oauthAuth(['openid', 'api:read'])) + + const admission = await admitV2Request( + bearerRequest(), + operation, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + + expect(admission.success).toBe(false) + if (admission.success) throw new Error('Expected admission to fail') + expect(admission.response.status).toBe(403) + expect(admission.response.headers.get('www-authenticate')).toContain('api:write') + }) + + it('enforces write scope through optional raw-route admission when a credential is present', async () => { + v2RouteMocks.authenticate.mockResolvedValue(oauthAuth(['openid', 'api:read'])) + + const admission = await admitOptionalV2Request( + bearerRequest(), + operation, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + + expect(admission.success).toBe(false) + if (admission.success) throw new Error('Expected admission to fail') + expect(admission.response.status).toBe(403) + expect(admission.response.headers.get('www-authenticate')).toContain('api:write') + }) + + it('allows an explicitly read-only raw POST with api:read', async () => { + v2RouteMocks.authenticate.mockResolvedValue(oauthAuth(['openid', 'api:read'])) + + const admission = await admitV2Request( + bearerRequest(), + operation, + v2ApiKeyAuth, + v2RateLimits.publicApi, + { readOnly: true } + ) + + expect(admission.success).toBe(true) + }) + + it('requires api:write for an effectful raw GET', async () => { + v2RouteMocks.authenticate.mockResolvedValue(oauthAuth(['openid', 'api:read'])) + const request = new NextRequest('http://localhost/api/v2/widgets', { + headers: { authorization: 'Bearer sim_oat_x' }, + }) + + const admission = await admitV2Request( + request, + operation, + v2ApiKeyAuth, + v2RateLimits.publicApi, + { write: true } + ) + + expect(admission.success).toBe(false) + if (admission.success) throw new Error('Expected admission to fail') + expect(admission.response.headers.get('www-authenticate')).toContain('api:write') + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index b02a5537fb7..729a5d26b4d 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -15,13 +15,27 @@ import { authenticateV2ApiKey, type V2ApiKeyAuthContext, V2ApiKeyUnauthenticatedError, + type V2CredentialType, } from '@/lib/api/server/routes/v2-api-key-auth' +import { + hasV2Credential, + readV2CredentialHeaders, +} from '@/lib/api/server/routes/v2-credential-headers' import { type ParsedRequest, type ParseRequestOptions, parseRequest, } from '@/lib/api/server/validation' -import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' +import { + OAUTH_API_READ_SCOPE, + OAUTH_API_WRITE_SCOPE, + oauthScopeSatisfies, +} from '@/lib/auth/oauth-provider' +import { + type ApplicationOperation, + InsufficientScopeError, + type OperationUseCase, +} from '@/lib/core/application' import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -30,6 +44,7 @@ import { v2Error, v2HeadNoEffect, v2HttpError, + v2InsufficientScope, v2RateLimitError, v2ValidationError, } from '@/app/api/v2/lib/response' @@ -48,9 +63,13 @@ export class V2RouteInfrastructureError extends Error { } } +/** + * The v2 credential policy: an API key in `x-api-key`, or a Sim OAuth access + * token as `Authorization: Bearer`. + */ export const v2ApiKeyAuth = { authenticate(request: NextRequest) { - return authenticateV2ApiKey(request.headers.get('x-api-key')) + return authenticateV2ApiKey(readV2CredentialHeaders(request.headers)) }, } as const @@ -231,6 +250,34 @@ export function requireHeadAuthorizableUseCase( ) } +/** + * The safe methods (RFC 9110 §9.2.1) this runtime serves — TRACE is the fourth + * and Next routes none. Every other method is treated as state-changing, which + * is the whole point: a request that may write is one by construction, + * whatever the handler beneath it turns out to do. + */ +const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) + +/** + * Refuses a `readOnly` declaration on a route whose method is already safe. + * + * `readOnly` exists only to say "this unsafe method does not write", so on a + * GET it changes nothing and its presence means the author read it as + * something else. Failing at definition time keeps the flag meaning one thing. + * Nothing here can tell whether an unsafe route that claims it is telling the + * truth — that stays a review question. + */ +export function requireUnsafeMethodForReadOnly( + contract: { method: string; path: string }, + readOnly: boolean | undefined +): void { + if (!readOnly) return + if (!SAFE_HTTP_METHODS.has(contract.method.toUpperCase())) return + throw new Error( + `V2 route ${contract.method} ${contract.path} declares readOnly, which only applies to a method that is not already safe. Remove it.` + ) +} + /** * The bodiless answer a `HEAD` gets on a route whose `GET` is not safe. * @@ -300,11 +347,59 @@ async function enforceV2PreAuthIpLimit(request: NextRequest): Promise { @@ -313,11 +408,19 @@ async function admitAuthenticatedV2Request( auth = await authPolicy.authenticate(request) } catch (error) { if (error instanceof V2ApiKeyUnauthenticatedError) { - return { success: false, response: v2Error('UNAUTHORIZED', error.message) } + return { + success: false, + response: v2Error('UNAUTHORIZED', error.message, { + authChallenge: error.challenge, + }), + } } throw new V2RouteInfrastructureError('authentication', error) } + const outOfScope = refuseOutOfScopeRequest(request, auth, scopePolicy) + if (outOfScope) return { success: false, response: outOfScope } + const limited = await rateLimitPolicy.enforce(request, auth, operation) return limited ? { success: false, response: limited } : { success: true, auth } } @@ -326,13 +429,14 @@ async function admitRateLimitedV2Request( request: NextRequest, operation: ApplicationOperation, authPolicy: typeof v2ApiKeyAuth, - rateLimitPolicy: V2RateLimitPolicy + rateLimitPolicy: V2RateLimitPolicy, + scopePolicy?: V2ScopePolicy ): Promise< { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } > { const preAuthResponse = await enforceV2PreAuthIpLimit(request) if (preAuthResponse) return { success: false, response: preAuthResponse } - return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) + return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy, scopePolicy) } /** Admission for a v2 route the builders do not cover, such as the resume leg. */ @@ -340,25 +444,27 @@ export async function admitV2Request( request: NextRequest, operation: ApplicationOperation, authPolicy: typeof v2ApiKeyAuth, - rateLimitPolicy: V2RateLimitPolicy + rateLimitPolicy: V2RateLimitPolicy, + scopePolicy?: V2ScopePolicy ): Promise< { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } > { - return admitRateLimitedV2Request(request, operation, authPolicy, rateLimitPolicy) + return admitRateLimitedV2Request(request, operation, authPolicy, rateLimitPolicy, scopePolicy) } export async function admitOptionalV2Request( request: NextRequest, operation: ApplicationOperation, authPolicy: typeof v2ApiKeyAuth, - rateLimitPolicy: V2RateLimitPolicy + rateLimitPolicy: V2RateLimitPolicy, + scopePolicy?: V2ScopePolicy ): Promise< { success: true; auth?: V2ApiKeyAuthContext } | { success: false; response: NextResponse } > { const preAuthResponse = await enforceV2PreAuthIpLimit(request) if (preAuthResponse) return { success: false, response: preAuthResponse } - if (!request.headers.has('x-api-key')) return { success: true } - return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) + if (!hasV2Credential(request.headers)) return { success: true } + return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy, scopePolicy) } /** @@ -372,12 +478,13 @@ export async function admitOptionalV2Request( * One route reads it: `GET /api/v2/meta`, whose resource *is* the calling key. */ export interface V2CredentialFacts { - readonly keyType: 'personal' | 'workspace' + readonly keyType: V2CredentialType readonly keyExpiresAt: Date | null } interface V2JsonRouteOptions - extends Omit, 'mapInput'> { + extends Omit, 'mapInput'>, + V2ScopePolicy { mapInput(input: ParsedRequest, credential: V2CredentialFacts): I auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy @@ -425,6 +532,12 @@ export function defineV2JsonRoute< options.useCase.operation ) requireHeadAuthorizableUseCase(options.contract, options.headSafe, options.useCase) + requireUnsafeMethodForReadOnly(options.contract, options.readOnly) + if (options.readOnly && options.write) { + throw new Error( + `V2 route ${options.contract.method} ${options.contract.path} cannot declare both readOnly and write.` + ) + } const wrapped = withRouteHandler( async (request, context) => { @@ -438,7 +551,8 @@ export function defineV2JsonRoute< request, options.operation, options.auth, - options.rateLimit + options.rateLimit, + { readOnly: options.readOnly, write: options.write } ) if (!admission.success) return admission.response const { auth } = admission @@ -446,7 +560,11 @@ export function defineV2JsonRoute< if (options.beforeParse) { const rawParams = context?.params ? await context.params : {} try { - await options.beforeParse({ request, principal: auth.principal, params: rawParams }) + await options.beforeParse({ + request, + principal: auth.principal, + params: rawParams, + }) } catch (error) { const response = options.errorPolicy.render(error) if (response) return response diff --git a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts index 142190a2612..ff4acb07438 100644 --- a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts +++ b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts @@ -1,10 +1,17 @@ -import type { Principal } from '@sim/auth/principal' +import { isUserCredentialPrincipal, type Principal } from '@sim/auth/principal' import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations' import { resolveDefaultAuditOrganization, resolveEnterpriseAuditAccess, } from '@/lib/audit-logs/authorization' -import { ForbiddenOperationError, type OperationUseCase } from '@/lib/core/application' +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' +import { + ForbiddenOperationError, + type OperationUseCase, + PersonalApiKeysDisabledError, +} from '@/lib/core/application' +import { refuseCapability } from '@/lib/permission-groups/capabilities' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' export interface AuthorizedAuditLogContext { organizationId: string @@ -72,6 +79,25 @@ export function defineAuthorizedAuditLogUseCase +export type AuditLogPrincipal = Extract< + Principal, + { kind: 'session' | 'personal_api_key' | 'oauth_access_token' } +> export interface AuditLogOperation extends ApplicationOperation { readonly authority: 'organization_admin' readonly organizationRoles: readonly ['admin', 'owner'] readonly workspaceApiKey: 'deny' - readonly principalKinds: readonly ['session', 'personal_api_key'] + readonly principalKinds: readonly ['session', 'personal_api_key', 'oauth_access_token'] } function defineAuditLogOperation( @@ -31,7 +34,7 @@ export const auditLogOperations = { authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), // permission-group-exempt: same organization-admin authority as the list it expands; no group key names the audit trail readDetail: defineAuditLogOperation({ @@ -40,6 +43,6 @@ export const auditLogOperations = { authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), } as const diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index 0f91980cb52..9801e513563 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -1,4 +1,5 @@ import { useContext } from 'react' +import { oauthProviderClient } from '@better-auth/oauth-provider/client' import { ssoClient } from '@better-auth/sso/client' import { stripeClient } from '@better-auth/stripe/client' import { @@ -24,6 +25,12 @@ export const client = createAuthClient({ adminClient(), emailOTPClient(), genericOAuthClient(), + /** + * Types the `/oauth2/*` endpoints and forwards the signed authorize query + * from the consent page's URL as `oauth_query` on the consent call. Inert on + * every other page, so it does not need the deployment gate. + */ + oauthProviderClient(), customSessionClient(), ...(isBillingEnabled ? [ diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index e01cc509be7..97af0db6a49 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1,4 +1,5 @@ import { cache } from 'react' +import { oauthProvider } from '@better-auth/oauth-provider' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' import { db } from '@sim/db' @@ -6,8 +7,13 @@ import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' -import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api' +import { + APIError, + createAuthMiddleware, + getOAuthState, + getSessionFromCtx, + setShouldSkipSessionRefresh, +} from 'better-auth/api' import { deleteSessionCookie, setSessionCookie } from 'better-auth/cookies' import { nextCookies } from 'better-auth/next-js' import { @@ -37,12 +43,23 @@ import { getRequestedSignInProviderId, isSignInProviderAllowed, } from '@/lib/auth/constants' +import { hashOAuthToken } from '@/lib/auth/oauth-access-token' +import { + consentRequestNamesClient, + OAUTH_ACCESS_TOKEN_PREFIX, + OAUTH_ACCESS_TOKEN_TTL_SECONDS, + OAUTH_CODE_TTL_SECONDS, + OAUTH_REFRESH_TOKEN_PREFIX, + OAUTH_REFRESH_TOKEN_TTL_SECONDS, + OAUTH_SCOPES, + SIM_CLI_CLIENT_ID, +} from '@/lib/auth/oauth-provider' import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy' import { clampExpiryForSession } from '@/lib/auth/session-policy' import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter' import { admitSsoUser } from '@/lib/auth/sso/application/admit-sso-user' import { resolveSsoCallbackProviderId } from '@/lib/auth/sso/callback-provider' -import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' import { sendPlanWelcomeEmail } from '@/lib/billing' import { assertPersonalCheckoutAllowed, @@ -91,6 +108,7 @@ import { isGoogleAuthDisabled, isHosted, isMicrosoftAuthDisabled, + isOAuthProviderEnabled, isOrganizationsEnabled, isRegistrationDisabled, isSignupMxValidationEnabled, @@ -130,6 +148,8 @@ import { import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { joinInstanceOrganization } from '@/lib/organizations/instance-org' +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' import { disableUserResources } from '@/lib/workflows/lifecycle' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' @@ -227,13 +247,7 @@ export const auth = betterAuth({ ...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []), ...additionalTrustedOrigins, ].filter(Boolean), - database: (options: BetterAuthOptions) => - guardSubscriptionPlanWrites( - drizzleAdapter(db, { - provider: 'pg', - schema, - })(options) - ), + database: (options: BetterAuthOptions) => createSimAuthAdapter(options), session: { cookieCache: { enabled: true, @@ -935,6 +949,14 @@ export const auth = betterAuth({ }, hooks: { before: createAuthMiddleware(async (ctx) => { + /** + * Better Auth 1.6.27 re-enters OAuth authorization when its own session + * refresh sets a cookie, issuing a second code that is never returned. + * Suppressing sliding renewal only for this request prevents the orphan; + * the next ordinary session request can still renew the same session. + */ + if (ctx.path === '/oauth2/authorize') await setShouldSkipSessionRefresh(true) + /** * Restrict the unauthenticated sign-in endpoints to first-party login * providers. Better Auth registers every generic-OAuth integration @@ -954,6 +976,32 @@ export const auth = betterAuth({ } } + /** + * A user consenting to the Sim CLI is the one moment a human is present + * in a CLI login, so `cli.use` is checked here to refuse the grant + * outright. `requireCliAccessAllowed` checks it again on every bearer + * request, because a consent already on file lets later authorizations + * skip this endpoint entirely — neither check makes the other redundant. + * + * The client id is read from the signed authorize query the consent page + * forwards. The gate fires if `sim-cli` appears anywhere in it, which is + * strictly more conservative than the plugin's own first-value read. + * + * permission-group-enforced: cli.use — gates OAuth consent for the + * first-party CLI client, which owns no workspace resource for the + * authorization funnel to authorize. + */ + if (ctx.path === '/oauth2/consent') { + if (consentRequestNamesClient(ctx.body?.oauth_query, SIM_CLI_CLIENT_ID)) { + const session = await getSessionFromCtx(ctx) + const userId = session?.user?.id + if (userId && (await isCapabilityWithheldForUser(userId, 'cli.use'))) { + logger.warn('CLI OAuth consent blocked by permission group', { userId }) + throw new APIError('FORBIDDEN', { message: capabilityRefusal('cli.use') }) + } + } + } + if (ctx.path === '/oauth2/link' && ctx.body?.providerId === MICROSOFT_DATAVERSE_PROVIDER_ID) { try { assertMicrosoftDataverseOAuthLinkRequest( @@ -1257,6 +1305,71 @@ export const auth = betterAuth({ genericOAuth({ config: buildConnectorProviders(), }), + /** + * Sim as an OAuth 2.0 authorization server (auth-code + PKCE, refresh + * rotation). Tokens are opaque and stored hashed, so revoking an app in + * settings takes effect on the next request. `sim logout` deletes the + * stable family for that login, including access tokens issued before an + * earlier rotation. This is an OAuth API-authorization surface, not an + * OpenID Connect identity provider; `disableJwtPlugin` keeps JWT/JWKS and + * ID-token semantics out of the advertised protocol. Clients are DB rows + * only (the CLI is seeded by migration, the rest are admin-created), so + * both registration paths stay closed. + */ + ...(isOAuthProviderEnabled + ? [ + oauthProvider({ + loginPage: '/oauth/sign-in', + consentPage: '/oauth/consent', + scopes: [...OAUTH_SCOPES], + grantTypes: ['authorization_code', 'refresh_token'], + /** + * Lets the consent page resolve the display-safe client metadata + * through the plugin's signed-query endpoint. The endpoint remains + * unusable for handwritten or expired authorization URLs because + * Better Auth verifies `oauth_query` before reading the client. + */ + allowPublicClientPrelogin: true, + allowDynamicClientRegistration: false, + allowUnauthenticatedClientRegistration: false, + /** + * No endpoint may read or change a client row. Clients are created + * by an operator running `create-oauth-client.ts`, so every one of + * the plugin's client CRUD endpoints — create, read, list, update, + * delete, rotate — is refused at the source. The route-level POST + * blocklist stays as defence in depth, but this is what closes the + * `GET` readers it cannot see, and what keeps a future plugin + * version from mounting a seventh endpoint into an open door. + * + * The consent page's client lookup is unaffected: + * `public-client-prelogin` does not consult this hook and instead + * requires the signed authorization query. + */ + clientPrivileges: () => false, + /** + * Opaque access tokens let Settings revoke every token for an app + * on the next request and let `sim logout` revoke one independent + * login family, including access tokens from earlier rotations. A + * JWT would remain valid until it lapsed regardless of the delete. + * + * Better Auth requires reversibly encrypted client secrets in its + * disabled-JWT mode; selecting `hashed` is refused at provider + * construction. `storeClientSecret` therefore stays at the + * plugin's `encrypted` default, under `BETTER_AUTH_SECRET`, and + * `create-oauth-client.ts` writes secrets the same way. + */ + disableJwtPlugin: true, + storeTokens: { hash: hashOAuthToken }, + prefix: { + opaqueAccessToken: OAUTH_ACCESS_TOKEN_PREFIX, + refreshToken: OAUTH_REFRESH_TOKEN_PREFIX, + }, + accessTokenExpiresIn: OAUTH_ACCESS_TOKEN_TTL_SECONDS, + refreshTokenExpiresIn: OAUTH_REFRESH_TOKEN_TTL_SECONDS, + codeExpiresIn: OAUTH_CODE_TTL_SECONDS, + }), + ] + : []), /** * Include SSO plugin when enabled. Resolved through `isSsoEnabled` rather * than the raw env var so the `ENTERPRISE_ENABLED` suite switch registers diff --git a/apps/sim/lib/auth/oauth-access-token.test.ts b/apps/sim/lib/auth/oauth-access-token.test.ts new file mode 100644 index 00000000000..9f51b181be0 --- /dev/null +++ b/apps/sim/lib/auth/oauth-access-token.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/auth/oauth-provider', () => ({ OAUTH_ACCESS_TOKEN_PREFIX: 'sim_oat_' })) +vi.mock('@sim/security/hash', () => ({ sha256Hex: (value: string) => `hash:${value}` })) + +import { + InvalidOAuthAccessTokenError, + parseBearerToken, + verifyOAuthAccessToken, +} from '@/lib/auth/oauth-access-token' + +function row(overrides: Record = {}) { + return { + id: 'token-1', + userId: 'user-1', + clientId: 'sim-cli', + scopes: ['offline_access', 'api:read'], + expiresAt: new Date(Date.now() + 60_000), + clientDisabled: false, + userBanned: false, + userBanExpires: null, + userExists: 'user-1', + ...overrides, + } +} + +async function reason(token: string): Promise { + const failure = await verifyOAuthAccessToken(token).catch((error) => error) + expect(failure).toBeInstanceOf(InvalidOAuthAccessTokenError) + return failure.reason +} + +describe('parseBearerToken', () => { + it('reads exactly one bearer credential and nothing else', () => { + expect(parseBearerToken(new Headers({ authorization: 'Bearer sim_oat_abc' }))).toBe( + 'sim_oat_abc' + ) + expect(parseBearerToken(new Headers({ authorization: 'Bearer sim_oat_abc ' }))).toBe( + 'sim_oat_abc' + ) + expect(parseBearerToken(new Headers())).toBeNull() + expect(parseBearerToken(new Headers({ authorization: 'Basic abc' }))).toBeNull() + expect(parseBearerToken(new Headers({ authorization: 'Bearer ' }))).toBeNull() + expect(parseBearerToken(new Headers({ authorization: 'Bearer a b' }))).toBeNull() + }) + + /** + * RFC 7235 §2.1 defines the scheme as case-insensitive, so `bearer` is a + * real credential. Reading it as no credential would let it past the + * optional-auth path as an anonymous request instead of being refused. + */ + it('matches the scheme case-insensitively', () => { + expect(parseBearerToken(new Headers({ authorization: 'bearer sim_oat_abc' }))).toBe( + 'sim_oat_abc' + ) + expect(parseBearerToken(new Headers({ authorization: 'BEARER sim_oat_abc' }))).toBe( + 'sim_oat_abc' + ) + }) +}) + +describe('verifyOAuthAccessToken', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('looks the token up by its hash and returns the principal it stands for', async () => { + queueTableRows(schemaMock.oauthAccessToken, [row()]) + + const principal = await verifyOAuthAccessToken('sim_oat_secret') + + expect(principal).toEqual({ + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['offline_access', 'api:read'], + expiresAt: expect.any(Date), + }) + expect(dbChainMockFns.where).toHaveBeenCalledOnce() + expect(JSON.stringify(dbChainMockFns.where.mock.calls[0])).toContain('hash:secret') + }) + + it('refuses a credential that is not one of ours without a database read', async () => { + expect(await reason('sim_abc')).toBe('malformed') + expect(await reason('sim_oat_')).toBe('malformed') + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('refuses an unknown, expired, disabled-client, orphaned, or banned token', async () => { + expect(await reason('sim_oat_unknown')).toBe('unknown') + + queueTableRows(schemaMock.oauthAccessToken, [row({ expiresAt: new Date(Date.now() - 1) })]) + expect(await reason('sim_oat_x')).toBe('expired') + + queueTableRows(schemaMock.oauthAccessToken, [row({ clientDisabled: true })]) + expect(await reason('sim_oat_x')).toBe('client_disabled') + + queueTableRows(schemaMock.oauthAccessToken, [row({ userId: null, userExists: null })]) + expect(await reason('sim_oat_x')).toBe('user_missing') + + queueTableRows(schemaMock.oauthAccessToken, [row({ userBanned: true })]) + expect(await reason('sim_oat_x')).toBe('user_banned') + + queueTableRows(schemaMock.oauthAccessToken, [ + row({ userBanned: true, userBanExpires: new Date(Date.now() - 1) }), + ]) + await expect(verifyOAuthAccessToken('sim_oat_x')).resolves.toMatchObject({ userId: 'user-1' }) + }) + + it('propagates a store failure rather than reporting an invalid token', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + + await expect(verifyOAuthAccessToken('sim_oat_x')).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/auth/oauth-access-token.ts b/apps/sim/lib/auth/oauth-access-token.ts new file mode 100644 index 00000000000..f38e3881e6a --- /dev/null +++ b/apps/sim/lib/auth/oauth-access-token.ts @@ -0,0 +1,118 @@ +import type { OAuthAccessTokenPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { oauthAccessToken, oauthClient, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { eq } from 'drizzle-orm' +import { isBanActive } from '@/lib/auth/ban' +import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' + +const logger = createLogger('OAuthAccessToken') + +const BEARER_SCHEME = /^Bearer[ ]+/i + +/** + * Hashes an issued token for storage and lookup. Handed to the plugin as + * `storeTokens.hash`, so the row the plugin writes and the row this file reads + * are computed by one function. The tokens are 32 random alphanumerics (190 + * bits), so a fast digest is the right construction — see the note on + * {@link sha256Hex}. + * + * It lives here rather than beside the other OAuth vocabulary because + * `sha256Hex` pulls in `node:crypto`: the consent card and the authorized-apps + * settings page both import that module for their scope wording, so anything + * server-only in it would follow them into the browser bundle. + */ +export function hashOAuthToken(token: string): string { + return sha256Hex(token) +} + +export type InvalidOAuthAccessTokenReason = + | 'malformed' + | 'unknown' + | 'expired' + | 'client_disabled' + | 'user_missing' + | 'user_banned' + +export class InvalidOAuthAccessTokenError extends Error { + constructor(readonly reason: InvalidOAuthAccessTokenReason) { + super('Invalid access token') + this.name = 'InvalidOAuthAccessTokenError' + } +} + +/** + * The single token in an `Authorization: Bearer` header, or `null` when the + * header is absent or carries another scheme. + * + * The scheme is matched case-insensitively because RFC 7235 §2.1 defines it + * that way — `bearer foo` is a well-formed credential, and treating it as no + * credential at all would let it through the optional-auth path as an + * anonymous request instead of refusing it. The credential itself is still + * strict: a header carrying two tokens or an empty value is refused rather + * than guessed at. + */ +export function parseBearerToken(headers: Headers): string | null { + const header = headers.get('authorization') + if (!header || !BEARER_SCHEME.test(header)) return null + const token = header.replace(BEARER_SCHEME, '').trim() + return token && !/\s/.test(token) ? token : null +} + +/** Whether a bearer credential is one of Sim's own OAuth access tokens, by its prefix. */ +function looksLikeOAuthAccessToken(token: string): boolean { + return token.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) +} + +/** + * Resolves an opaque OAuth access token to the principal it stands for. + * + * One indexed read: the token is hashed the same way the provider stored it and + * joined to its client and user, so the checks the API-key path makes about a + * key row — not expired, owner still exists, owner not banned — are made here + * about the token row, plus the one that is new: the client has not been + * disabled. Nothing about the token is cached; that is what makes revoking an + * app in settings, or `sim logout`, take effect on the very next request. + */ +export async function verifyOAuthAccessToken(token: string): Promise { + if (!looksLikeOAuthAccessToken(token)) throw new InvalidOAuthAccessTokenError('malformed') + const raw = token.slice(OAUTH_ACCESS_TOKEN_PREFIX.length) + if (!raw) throw new InvalidOAuthAccessTokenError('malformed') + + const [row] = await db + .select({ + id: oauthAccessToken.id, + userId: oauthAccessToken.userId, + clientId: oauthAccessToken.clientId, + scopes: oauthAccessToken.scopes, + expiresAt: oauthAccessToken.expiresAt, + clientDisabled: oauthClient.disabled, + userBanned: user.banned, + userBanExpires: user.banExpires, + userExists: user.id, + }) + .from(oauthAccessToken) + .innerJoin(oauthClient, eq(oauthAccessToken.clientId, oauthClient.clientId)) + .leftJoin(user, eq(oauthAccessToken.userId, user.id)) + .where(eq(oauthAccessToken.token, hashOAuthToken(raw))) + .limit(1) + + if (!row) throw new InvalidOAuthAccessTokenError('unknown') + if (row.expiresAt <= new Date()) throw new InvalidOAuthAccessTokenError('expired') + if (row.clientDisabled) throw new InvalidOAuthAccessTokenError('client_disabled') + if (!row.userId || !row.userExists) throw new InvalidOAuthAccessTokenError('user_missing') + if (isBanActive({ banned: row.userBanned, banExpires: row.userBanExpires })) { + throw new InvalidOAuthAccessTokenError('user_banned') + } + + logger.debug('Authenticated OAuth access token', { tokenId: row.id, clientId: row.clientId }) + return { + kind: 'oauth_access_token', + userId: row.userId, + clientId: row.clientId, + tokenId: row.id, + scopes: row.scopes, + expiresAt: row.expiresAt, + } +} diff --git a/apps/sim/lib/auth/oauth-authorization-error.test.ts b/apps/sim/lib/auth/oauth-authorization-error.test.ts new file mode 100644 index 00000000000..9c968797e71 --- /dev/null +++ b/apps/sim/lib/auth/oauth-authorization-error.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it } from 'vitest' +import { + oauthAuthorizationErrorResponse, + oauthRedirectUriMatches, +} from '@/lib/auth/oauth-authorization-error' + +function request(entries: [string, string][]): NextRequest { + const url = new URL('https://sim.test/api/auth/oauth2/authorize') + for (const [key, value] of entries) url.searchParams.append(key, value) + return new NextRequest(url) +} + +describe('oauthRedirectUriMatches', () => { + it('matches exact callbacks and permits only loopback port variance', () => { + expect(oauthRedirectUriMatches('https://app.test/callback', 'https://app.test/callback')).toBe( + true + ) + expect( + oauthRedirectUriMatches('http://127.0.0.1/callback', 'http://127.0.0.1:43123/callback') + ).toBe(true) + expect( + oauthRedirectUriMatches('http://127.2.3.4/callback', 'http://127.2.3.4:43123/callback') + ).toBe(true) + expect(oauthRedirectUriMatches('http://[::1]/callback', 'http://[::1]:43123/callback')).toBe( + true + ) + expect(oauthRedirectUriMatches('https://app.test/callback', 'https://evil.test/callback')).toBe( + false + ) + expect( + oauthRedirectUriMatches( + 'https://127.attacker.example/callback', + 'https://127.attacker.example:43123/callback' + ) + ).toBe(false) + expect(oauthRedirectUriMatches('http://127.0.0.1/callback', 'http://127.0.0.1/other')).toBe( + false + ) + }) +}) + +describe('oauthAuthorizationErrorResponse', () => { + beforeEach(resetDbChainMock) + + it('redirects a registered callback with the original state', async () => { + queueTableRows(schemaMock.oauthClient, [ + { disabled: false, redirectUris: ['http://127.0.0.1/callback'] }, + ]) + + const response = await oauthAuthorizationErrorResponse( + request([ + ['client_id', 'sim-cli'], + ['redirect_uri', 'http://127.0.0.1:43123/callback'], + ['state', 'state-1'], + ]), + 'invalid_request', + 'Code challenge is invalid.' + ) + const location = new URL(response.headers.get('location') ?? '') + + expect(response.status).toBe(302) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(location.origin).toBe('http://127.0.0.1:43123') + expect(location.searchParams.get('error')).toBe('invalid_request') + expect(location.searchParams.get('error_description')).toBe('Code challenge is invalid.') + expect(location.searchParams.get('state')).toBe('state-1') + expect(location.searchParams.get('iss')).toMatch(/\/api\/auth$/) + }) + + it.each([ + ['missing client', undefined], + ['disabled client', { disabled: true, redirectUris: ['https://app.test/callback'] }], + ['unregistered callback', { disabled: false, redirectUris: ['https://app.test/other'] }], + ])('does not redirect a %s', async (_case, client) => { + if (client) queueTableRows(schemaMock.oauthClient, [client]) + + const response = await oauthAuthorizationErrorResponse( + request([ + ['client_id', 'client-1'], + ['redirect_uri', 'https://app.test/callback'], + ['state', 'state-1'], + ]), + 'invalid_request', + 'Request is invalid.' + ) + + expect(response.status).toBe(400) + expect(response.headers.has('location')).toBe(false) + }) + + it.each(['client_id', 'redirect_uri'])( + 'does not choose between repeated %s values', + async (repeated) => { + const entries: [string, string][] = [ + ['client_id', 'client-1'], + ['redirect_uri', 'https://app.test/callback'], + ] + entries.push([repeated, repeated === 'client_id' ? 'client-2' : 'https://evil.test/callback']) + + const response = await oauthAuthorizationErrorResponse( + request(entries), + 'invalid_request', + 'Request is invalid.' + ) + + expect(response.status).toBe(400) + expect(response.headers.has('location')).toBe(false) + } + ) + + it('omits an ambiguous repeated state from an otherwise safe redirect', async () => { + queueTableRows(schemaMock.oauthClient, [ + { disabled: false, redirectUris: ['https://app.test/callback'] }, + ]) + + const response = await oauthAuthorizationErrorResponse( + request([ + ['client_id', 'client-1'], + ['redirect_uri', 'https://app.test/callback'], + ['state', 'one'], + ['state', 'two'], + ]), + 'invalid_request', + 'OAuth parameter state appears more than once.' + ) + + expect(new URL(response.headers.get('location') ?? '').searchParams.has('state')).toBe(false) + }) + + it('returns a sanitized protocol error when callback validation fails', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + + const response = await oauthAuthorizationErrorResponse( + request([ + ['client_id', 'client-1'], + ['redirect_uri', 'https://app.test/callback'], + ]), + 'invalid_request', + 'Request is invalid.' + ) + + expect(response.status).toBe(500) + expect(response.headers.has('location')).toBe(false) + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Authorization request failed.', + }) + }) +}) diff --git a/apps/sim/lib/auth/oauth-authorization-error.ts b/apps/sim/lib/auth/oauth-authorization-error.ts new file mode 100644 index 00000000000..22697374532 --- /dev/null +++ b/apps/sim/lib/auth/oauth-authorization-error.ts @@ -0,0 +1,92 @@ +import { isIP } from 'node:net' +import { db } from '@sim/db' +import { oauthClient } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { oauthErrorResponse } from '@/lib/auth/oauth-protocol-request' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('OAuthAuthorizationError') + +export type OAuthAuthorizationErrorCode = 'invalid_request' | 'unsupported_response_type' + +function isLoopbackIp(hostname: string): boolean { + const address = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname + return (isIP(address) === 4 && address.startsWith('127.')) || address === '::1' +} + +/** Matches Better Auth's exact redirect rule, including RFC 8252 loopback port variance. */ +export function oauthRedirectUriMatches(registeredUri: string, requestedUri: string): boolean { + if (registeredUri === requestedUri) return true + + try { + const registered = new URL(registeredUri) + const requested = new URL(requestedUri) + return ( + isLoopbackIp(registered.hostname) && + registered.hostname === requested.hostname && + registered.protocol === requested.protocol && + registered.pathname === requested.pathname && + registered.search === requested.search + ) + } catch { + return false + } +} + +/** + * Redirects an authorization error only after proving the callback belongs to + * the single registered, enabled client named by the request. + */ +export async function oauthAuthorizationErrorResponse( + request: NextRequest, + error: OAuthAuthorizationErrorCode, + description: string +): Promise { + const params = request.nextUrl.searchParams + const clientIds = params.getAll('client_id') + const redirectUris = params.getAll('redirect_uri') + if (clientIds.length !== 1 || redirectUris.length !== 1) { + return oauthErrorResponse(error, description) + } + + const clientId = clientIds[0] + const redirectUri = redirectUris[0] + let client: { disabled: boolean; redirectUris: string[] } | undefined + let issuer: string + try { + const clients = await db + .select({ disabled: oauthClient.disabled, redirectUris: oauthClient.redirectUris }) + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .limit(1) + client = clients[0] + issuer = `${getBaseUrl()}/api/auth` + } catch (caught) { + logger.error('Failed to validate an OAuth authorization error redirect', { + error: toError(caught), + }) + return oauthErrorResponse('server_error', 'Authorization request failed.', 500) + } + + if ( + !client || + client.disabled || + !client.redirectUris.some((registered) => oauthRedirectUriMatches(registered, redirectUri)) + ) { + return oauthErrorResponse(error, description) + } + + const location = new URL(redirectUri) + location.searchParams.set('error', error) + location.searchParams.set('error_description', description) + const states = params.getAll('state') + if (states.length === 1) location.searchParams.set('state', states[0]) + location.searchParams.set('iss', issuer) + return NextResponse.redirect(location, { + status: 302, + headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' }, + }) +} diff --git a/apps/sim/lib/auth/oauth-principal.test.ts b/apps/sim/lib/auth/oauth-principal.test.ts new file mode 100644 index 00000000000..de3ef929e68 --- /dev/null +++ b/apps/sim/lib/auth/oauth-principal.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { + isUserCredentialPrincipal, + type OAuthAccessTokenPrincipal, + parsePrincipal, + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, + resolvePrincipalSubject, + serializePrincipal, + toPrincipalActor, +} from '@sim/auth/principal' +import { describe, expect, it } from 'vitest' + +const principal: OAuthAccessTokenPrincipal = { + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['offline_access', 'api:read'], + expiresAt: new Date('2027-01-01T00:00:00.000Z'), +} + +describe('oauth_access_token principal', () => { + it('stands for the person, like a personal API key', () => { + expect(resolvePrincipalSubject(principal)).toEqual({ kind: 'sim_user', userId: 'user-1' }) + expect(isUserCredentialPrincipal(principal)).toBe(true) + expect(resolvePrincipalAttribution(principal).attributedUserId).toBe('user-1') + expect(resolvePrincipalAuditAttribution(principal)).toEqual({ + actor: { + kind: 'oauth_access_token', + tokenId: 'token-1', + clientId: 'sim-cli', + userId: 'user-1', + }, + actorId: 'user-1', + }) + expect(toPrincipalActor(principal)).not.toHaveProperty('scopes') + }) + + it('round-trips through the execution serialization carrying only the token id', () => { + const serialized = serializePrincipal(principal) + /** Exact keys ensure no additional principal state crosses the execution boundary. */ + expect(Object.keys(serialized.principal as object).sort()).toEqual([ + 'clientId', + 'expiresAt', + 'kind', + 'scopes', + 'tokenId', + 'userId', + ]) + expect(serialized.principal).toMatchObject({ expiresAt: '2027-01-01T00:00:00.000Z' }) + expect(parsePrincipal(structuredClone(serialized))).toEqual(principal) + }) + + it('refuses a serialized form with extra or malformed fields', () => { + const serialized = serializePrincipal(principal) + expect(() => + parsePrincipal({ ...serialized, principal: { ...serialized.principal, accessToken: 'x' } }) + ).toThrow('unsupported field accessToken') + expect(() => + parsePrincipal({ ...serialized, principal: { ...serialized.principal, scopes: 'api:read' } }) + ).toThrow('scopes must be an array') + expect(() => + parsePrincipal({ ...serialized, principal: { ...serialized.principal, expiresAt: 'soon' } }) + ).toThrow('expiresAt must be an ISO timestamp') + }) +}) diff --git a/apps/sim/lib/auth/oauth-protocol-request.test.ts b/apps/sim/lib/auth/oauth-protocol-request.test.ts new file mode 100644 index 00000000000..95d14e3b255 --- /dev/null +++ b/apps/sim/lib/auth/oauth-protocol-request.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { + buildDelegatedOAuthRequest, + isValidOAuthCodeVerifier, + parseOAuthFormRequest, + validateOAuthPkceAuthorizationRequest, +} from '@/lib/auth/oauth-protocol-request' + +function request(body: string, headers: HeadersInit = {}): NextRequest { + return new NextRequest('https://sim.test/api/auth/oauth2/token', { + method: 'POST', + body, + headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers }, + }) +} + +describe('parseOAuthFormRequest', () => { + it('parses public and client-secret-post credentials', async () => { + const publicResult = await parseOAuthFormRequest(request('client_id=sim-cli')) + expect(publicResult.success && publicResult.value.credentials).toEqual({ + clientId: 'sim-cli', + method: 'none', + }) + + const confidentialResult = await parseOAuthFormRequest( + request('client_id=web&client_secret=secret') + ) + expect(confidentialResult.success && confidentialResult.value.credentials).toEqual({ + clientId: 'web', + clientSecret: 'secret', + method: 'client_secret_post', + }) + }) + + it('accepts a case-insensitive Basic scheme and decodes form-escaped credentials', async () => { + const encoded = Buffer.from('client%2Bid:s%3Aecret').toString('base64') + const result = await parseOAuthFormRequest( + request('grant_type=refresh_token', { authorization: `basic ${encoded}` }) + ) + expect(result.success && result.value.credentials).toEqual({ + clientId: 'client+id', + clientSecret: 's:ecret', + method: 'client_secret_basic', + }) + }) + + it('adapts decoded Basic credentials to Better Auth without changing grant fields', async () => { + const encoded = Buffer.from('client%2Bid:s%3Aecret').toString('base64') + const original = request('grant_type=authorization_code&code=code', { + authorization: `basic ${encoded}`, + }) + const parsed = await parseOAuthFormRequest(original) + if (!parsed.success) throw new Error('request should parse') + const delegated = buildDelegatedOAuthRequest(original, parsed.value) + const delegatedForm = new URLSearchParams(await delegated.text()) + + expect(delegated.headers.has('authorization')).toBe(false) + expect(delegatedForm.get('grant_type')).toBe('authorization_code') + expect(delegatedForm.get('code')).toBe('code') + expect(delegatedForm.get('client_id')).toBe('client+id') + expect(delegatedForm.get('client_secret')).toBe('s:ecret') + }) + + it('rejects repeated fields and mixed client authentication', async () => { + const repeated = await parseOAuthFormRequest(request('client_id=a&client_id=b')) + expect(repeated.success).toBe(false) + if (!repeated.success) expect(repeated.response.status).toBe(400) + + const encoded = Buffer.from('client:secret').toString('base64') + const mixed = await parseOAuthFormRequest( + request('client_secret=other', { authorization: `Basic ${encoded}` }) + ) + expect(mixed.success).toBe(false) + if (!mixed.success) { + await expect(mixed.response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + } + }) + + it('rejects malformed authorization, content types, and oversized bodies', async () => { + const malformed = await parseOAuthFormRequest( + request('client_id=a', { authorization: 'Basic not base64!' }) + ) + expect(malformed.success).toBe(false) + if (!malformed.success) { + expect(malformed.response.status).toBe(401) + expect(malformed.response.headers.get('www-authenticate')).toContain('Basic') + } + + const json = await parseOAuthFormRequest( + new NextRequest('https://sim.test/api/auth/oauth2/token', { + method: 'POST', + body: '{}', + headers: { 'content-type': 'application/json' }, + }) + ) + expect(json.success).toBe(false) + + const misleadingContentType = await parseOAuthFormRequest( + request('client_id=a', { 'content-type': 'application/x-www-form-urlencoded-json' }) + ) + expect(misleadingContentType.success).toBe(false) + + const oversized = await parseOAuthFormRequest(request(`scope=${'a'.repeat(16_385)}`)) + expect(oversized.success).toBe(false) + + const oversizedMultibyte = await parseOAuthFormRequest(request(`scope=${'é'.repeat(8_193)}`)) + expect(oversizedMultibyte.success).toBe(false) + }) + + it('reports invalid UTF-8 separately from an oversized body', async () => { + const invalidUtf8 = await parseOAuthFormRequest( + new NextRequest('https://sim.test/api/auth/oauth2/token', { + method: 'POST', + body: new Uint8Array([0xc3, 0x28]), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) + ) + expect(invalidUtf8.success).toBe(false) + if (!invalidUtf8.success) { + await expect(invalidUtf8.response.json()).resolves.toMatchObject({ + error: 'invalid_request', + error_description: 'OAuth request body must be valid UTF-8.', + }) + } + }) +}) + +describe('OAuth PKCE validation', () => { + it('accepts the RFC 7636 verifier alphabet and length bounds', () => { + expect(isValidOAuthCodeVerifier('a'.repeat(43))).toBe(true) + expect(isValidOAuthCodeVerifier(`${'a'.repeat(124)}._~-`)).toBe(true) + expect(isValidOAuthCodeVerifier('a'.repeat(42))).toBe(false) + expect(isValidOAuthCodeVerifier('a'.repeat(129))).toBe(false) + expect(isValidOAuthCodeVerifier(`${'a'.repeat(42)}=`)).toBe(false) + }) + + it('accepts only paired, canonical S256 authorization parameters', async () => { + expect( + validateOAuthPkceAuthorizationRequest( + new URLSearchParams({ + code_challenge: 'a'.repeat(43), + code_challenge_method: 'S256', + }) + ) + ).toBeNull() + + for (const params of [ + new URLSearchParams({ code_challenge: 'a'.repeat(43) }), + new URLSearchParams({ code_challenge_method: 'S256' }), + new URLSearchParams({ code_challenge: 'a'.repeat(42), code_challenge_method: 'S256' }), + new URLSearchParams({ code_challenge: 'a'.repeat(43), code_challenge_method: 'plain' }), + ]) { + expect(validateOAuthPkceAuthorizationRequest(params)).toEqual(expect.any(String)) + } + }) +}) diff --git a/apps/sim/lib/auth/oauth-protocol-request.ts b/apps/sim/lib/auth/oauth-protocol-request.ts new file mode 100644 index 00000000000..4c517a73d2c --- /dev/null +++ b/apps/sim/lib/auth/oauth-protocol-request.ts @@ -0,0 +1,364 @@ +import { truncate } from '@sim/utils/string' +import { NextRequest, NextResponse } from 'next/server' +import type { OAuthClientCredentials, OAuthProtocolErrorCode } from '@/lib/auth/oauth-token-family' + +export interface ParsedOAuthForm { + form: URLSearchParams + credentials: OAuthClientCredentials | null + rawBody: string +} + +const MAX_OAUTH_FORM_BYTES = 16_384 +const PKCE_CODE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/ +const PKCE_S256_CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/ + +export type OAuthFormParseResult = + | { success: true; value: ParsedOAuthForm } + | { success: false; response: NextResponse } + +export function oauthErrorResponse( + error: + | OAuthProtocolErrorCode + | 'invalid_request' + | 'invalid_token' + | 'insufficient_scope' + | 'server_error' + | 'unsupported_grant_type' + | 'unsupported_response_type', + description: string, + status = 400, + challenge = false +): NextResponse { + return NextResponse.json( + { error, error_description: description }, + { + status, + headers: { + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + ...(challenge && { 'WWW-Authenticate': 'Basic realm="oauth2"' }), + }, + } + ) +} + +/** Formats an OAuth protocol error without exposing credentials or token lookup details. */ +export function oauthProtocolErrorResponse( + error: OAuthProtocolErrorCode, + description: string, + method: OAuthClientCredentials['method'] +): NextResponse { + const basicFailure = error === 'invalid_client' && method === 'client_secret_basic' + return oauthErrorResponse(error, description, basicFailure ? 401 : 400, basicFailure) +} + +const DELEGATED_INVALID_GRANT_DESCRIPTIONS = new Set([ + 'PKCE is required for this client', + 'code_verifier required because PKCE was used in authorization', + 'code_verifier provided but PKCE was not used in authorization', + 'code verification failed', + 'Either code_verifier or client_secret is required', + 'invalid client_id', + 'redirect_uri mismatch', + 'missing user, user may have been deleted', + 'session no longer exists', +]) + +/** Enforces RFC 7636 syntax before Better Auth stores an authorization request. */ +export function validateOAuthPkceAuthorizationRequest( + searchParams: URLSearchParams +): string | null { + const hasChallenge = searchParams.has('code_challenge') + const hasMethod = searchParams.has('code_challenge_method') + if (hasChallenge !== hasMethod) { + return 'code_challenge and code_challenge_method must both be provided.' + } + if (!hasChallenge) return null + + if (searchParams.get('code_challenge_method') !== 'S256') { + return 'Only the S256 code challenge method is supported.' + } + const challenge = searchParams.get('code_challenge') ?? '' + if (!PKCE_S256_CODE_CHALLENGE_PATTERN.test(challenge)) { + return 'Code challenge is invalid.' + } + return null +} + +/** Whether a token request carries an RFC 7636 code verifier. */ +export function isValidOAuthCodeVerifier(value: string): boolean { + return PKCE_CODE_VERIFIER_PATTERN.test(value) +} + +const DELEGATED_OAUTH_ERROR_CODES = new Set([ + 'invalid_client', + 'invalid_grant', + 'invalid_request', + 'invalid_scope', + 'unauthorized_client', + 'unsupported_grant_type', +]) + +/** Normalizes Better Auth 1.6 token errors to RFC 6749 and RFC 7636 semantics. */ +export async function normalizeDelegatedOAuthTokenResponse( + response: Response, + method: OAuthClientCredentials['method'] +): Promise { + const headers = new Headers(response.headers) + headers.set('Cache-Control', 'no-store') + headers.set('Pragma', 'no-cache') + headers.delete('content-length') + if (response.ok) { + return new NextResponse(response.body, { status: response.status, headers }) + } + + if (response.status >= 500) { + headers.set('content-type', 'application/json') + return new NextResponse( + JSON.stringify({ + error: 'server_error', + error_description: 'Token exchange failed.', + }), + { status: response.status, headers } + ) + } + + let payload: Record | null = null + try { + const parsed = JSON.parse(await response.text()) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + payload = parsed as Record + } + } catch {} + + const delegatedDescription = payload?.error_description + const error = + typeof delegatedDescription === 'string' && + DELEGATED_INVALID_GRANT_DESCRIPTIONS.has(delegatedDescription) + ? 'invalid_grant' + : typeof payload?.error === 'string' && DELEGATED_OAUTH_ERROR_CODES.has(payload.error) + ? payload.error + : 'invalid_request' + const errorDescription = + typeof payload?.error_description === 'string' + ? truncate(payload.error_description, 512) + : 'Token request is invalid.' + const status = error === 'invalid_client' && method === 'client_secret_basic' ? 401 : 400 + if (status === 401) headers.set('WWW-Authenticate', 'Basic realm="oauth2"') + else headers.delete('www-authenticate') + headers.set('content-type', 'application/json') + + return new NextResponse(JSON.stringify({ error, error_description: errorDescription }), { + status, + headers, + }) +} + +/** A successful RFC 7009 response intentionally has no body. */ +export function oauthRevocationSuccessResponse(): NextResponse { + return new NextResponse(null, { + status: 200, + headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' }, + }) +} + +function parseBasicCredentials(authorization: string): OAuthClientCredentials | null { + const match = /^Basic[ ]+([A-Za-z0-9+/]+={0,2})$/i.exec(authorization) + if (!match?.[1]) return null + + let decoded: string + try { + const encoded = match[1] + const bytes = Buffer.from(encoded, 'base64') + const canonicalInput = encoded.replace(/=+$/, '') + const canonicalDecoded = bytes.toString('base64').replace(/=+$/, '') + if (canonicalInput !== canonicalDecoded) return null + decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return null + } + const separator = decoded.indexOf(':') + if (separator < 1 || separator === decoded.length - 1) return null + + try { + const clientId = decodeURIComponent(decoded.slice(0, separator).replace(/\+/g, ' ')) + const clientSecret = decodeURIComponent(decoded.slice(separator + 1).replace(/\+/g, ' ')) + if (!clientId || !clientSecret) return null + return { clientId, clientSecret, method: 'client_secret_basic' } + } catch { + return null + } +} + +type OAuthBodyReadResult = + | { success: true; body: string } + | { success: false; reason: 'invalid_encoding' | 'too_large' } + +async function readBoundedOAuthBody(request: Request): Promise { + if (!request.body) return { success: true, body: '' } + + const reader = request.body.getReader() + const chunks: Uint8Array[] = [] + let byteLength = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + byteLength += value.byteLength + if (byteLength > MAX_OAUTH_FORM_BYTES) { + await reader.cancel() + return { success: false, reason: 'too_large' } + } + chunks.push(value) + } + } finally { + reader.releaseLock() + } + + const body = new Uint8Array(byteLength) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + try { + return { success: true, body: new TextDecoder('utf-8', { fatal: true }).decode(body) } + } catch { + return { success: false, reason: 'invalid_encoding' } + } +} + +/** + * Rebuilds the consumed request for Better Auth 1.6. + * Its Basic parser omits RFC 6749 form-decoding, so already-authenticated Basic + * credentials are passed through its body path using their decoded values. + */ +export function buildDelegatedOAuthRequest( + request: NextRequest, + parsed: ParsedOAuthForm +): NextRequest { + const headers = new Headers(request.headers) + let body = parsed.rawBody + if (parsed.credentials?.method === 'client_secret_basic') { + const form = new URLSearchParams(parsed.rawBody) + form.set('client_id', parsed.credentials.clientId) + form.set('client_secret', parsed.credentials.clientSecret ?? '') + body = form.toString() + headers.delete('authorization') + } + headers.delete('content-length') + return new NextRequest(request.url, { + method: request.method, + headers, + body, + signal: request.signal, + }) +} + +/** + * Parses a form-encoded OAuth request once and rejects parameter ambiguity. + * The bounded raw body is retained so delegated Better Auth grants can receive + * an equivalent request after this function consumes the original stream. + */ +export async function parseOAuthFormRequest(request: NextRequest): Promise { + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/x-www-form-urlencoded') { + return { + success: false, + response: oauthErrorResponse( + 'invalid_request', + 'Content-Type must be application/x-www-form-urlencoded.' + ), + } + } + + const declaredLength = Number(request.headers.get('content-length') ?? 0) + if (Number.isFinite(declaredLength) && declaredLength > MAX_OAUTH_FORM_BYTES) { + return { + success: false, + response: oauthErrorResponse('invalid_request', 'OAuth request body is too large.'), + } + } + const bodyResult = await readBoundedOAuthBody(request) + if (!bodyResult.success) { + return { + success: false, + response: oauthErrorResponse( + 'invalid_request', + bodyResult.reason === 'too_large' + ? 'OAuth request body is too large.' + : 'OAuth request body must be valid UTF-8.' + ), + } + } + const body = bodyResult.body + const form = new URLSearchParams(body) + const seen = new Set() + for (const [name] of form) { + if (seen.has(name)) { + return { + success: false, + response: oauthErrorResponse( + 'invalid_request', + `OAuth parameter ${name} appears more than once.` + ), + } + } + seen.add(name) + } + + const authorization = request.headers.get('authorization') + if (authorization) { + const basic = parseBasicCredentials(authorization) + if (!basic) { + return { + success: false, + response: oauthErrorResponse( + 'invalid_client', + 'Authorization header is invalid.', + 401, + true + ), + } + } + if (form.has('client_secret')) { + return { + success: false, + response: oauthErrorResponse( + 'invalid_request', + 'Use exactly one client authentication method.' + ), + } + } + return { success: true, value: { form, credentials: basic, rawBody: body } } + } + + const clientId = form.get('client_id') + if (!clientId) return { success: true, value: { form, credentials: null, rawBody: body } } + const clientSecret = form.get('client_secret') + return { + success: true, + value: { + form, + rawBody: body, + credentials: clientSecret + ? { clientId, clientSecret, method: 'client_secret_post' } + : { clientId, method: 'none' }, + }, + } +} + +/** Reads an optional OAuth scope parameter as a de-duplicated ordered list. */ +export function parseRequestedScopes(form: URLSearchParams): string[] | undefined { + const scope = form.get('scope')?.trim() + if (!scope) return undefined + return [...new Set(scope.split(/\s+/))] +} + +export function missingOAuthParameterResponse(name: string): NextResponse { + return oauthErrorResponse('invalid_request', `Missing required OAuth parameter ${name}.`) +} + +export function unsupportedGrantResponse(grantType: string): NextResponse { + return oauthErrorResponse('unsupported_grant_type', `Unsupported grant type ${grantType}.`) +} diff --git a/apps/sim/lib/auth/oauth-provider-adapter-guard.test.ts b/apps/sim/lib/auth/oauth-provider-adapter-guard.test.ts new file mode 100644 index 00000000000..445d43faa7e --- /dev/null +++ b/apps/sim/lib/auth/oauth-provider-adapter-guard.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + delete: vi.fn(), + insert: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ db: { delete: mocks.delete, insert: mocks.insert } })) + +import { + guardOAuthProviderWrites, + withOAuthProviderIssuanceCompensation, +} from '@/lib/auth/oauth-provider-adapter-guard' + +function adapter() { + return { + create: vi.fn(async () => ({ id: 'delegated' })), + } as Parameters[0] +} + +function consentData() { + return { + clientId: 'sim-cli', + userId: null, + referenceId: null, + scopes: ['api:read'], + createdAt: new Date('2026-09-04T00:00:00Z'), + updatedAt: new Date('2026-09-04T00:00:00Z'), + } +} + +function mockUpsert(rows: Record[]) { + const returning = vi.fn(async () => rows) + const onConflictDoUpdate = vi.fn(() => ({ returning })) + const values = vi.fn(() => ({ onConflictDoUpdate })) + mocks.insert.mockReturnValue({ values }) + return { values, onConflictDoUpdate } +} + +describe('guardOAuthProviderWrites', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.delete.mockReturnValue({ where: vi.fn(async () => undefined) }) + }) + + it('atomically upserts consent with a generated id and the nullable natural key', async () => { + const persisted = { id: 'persisted', ...consentData() } + const chain = mockUpsert([persisted]) + const guarded = guardOAuthProviderWrites(adapter()) + + await expect(guarded.create({ model: 'oauthConsent', data: consentData() })).resolves.toEqual( + persisted + ) + + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + clientId: 'sim-cli', + userId: null, + referenceId: null, + }) + ) + expect(chain.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + target: ['oauthConsent.userId', 'oauthConsent.clientId', 'oauthConsent.referenceId'], + set: { scopes: ['api:read'], updatedAt: consentData().updatedAt }, + }) + ) + }) + + it('preserves requested projection and refuses an empty RETURNING result', async () => { + mockUpsert([{ id: 'persisted', ...consentData() }]) + const guarded = guardOAuthProviderWrites(adapter()) + await expect( + guarded.create({ model: 'oauthConsent', data: consentData(), select: ['id', 'scopes'] }) + ).resolves.toEqual({ id: 'persisted', scopes: ['api:read'] }) + + mockUpsert([]) + await expect(guarded.create({ model: 'oauthConsent', data: consentData() })).rejects.toThrow( + 'upsert returned no row' + ) + }) + + it('passes every non-consent model through unchanged', async () => { + const base = adapter() + const guarded = guardOAuthProviderWrites(base) + await expect(guarded.create({ model: 'user', data: { name: 'Ada' } })).resolves.toEqual({ + id: 'delegated', + }) + expect(base.create).toHaveBeenCalledOnce() + expect(mocks.insert).not.toHaveBeenCalled() + }) + + it('deletes a refresh family when delegated token issuance fails', async () => { + const base = adapter() + const guarded = guardOAuthProviderWrites(base) + + const response = await withOAuthProviderIssuanceCompensation(async () => { + await guarded.create({ model: 'oauthRefreshToken', data: { token: 'hashed' } }) + return new Response('failed', { status: 500 }) + }) + + expect(response.status).toBe(500) + expect(mocks.delete).toHaveBeenCalledOnce() + }) + + it('retains a refresh family after successful delegated token issuance', async () => { + const guarded = guardOAuthProviderWrites(adapter()) + + await withOAuthProviderIssuanceCompensation(async () => { + await guarded.create({ model: 'oauthRefreshToken', data: { token: 'hashed' } }) + return new Response(null, { status: 200 }) + }) + + expect(mocks.delete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/auth/oauth-provider-adapter-guard.ts b/apps/sim/lib/auth/oauth-provider-adapter-guard.ts new file mode 100644 index 00000000000..32fcf16663d --- /dev/null +++ b/apps/sim/lib/auth/oauth-provider-adapter-guard.ts @@ -0,0 +1,131 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { db } from '@sim/db' +import { oauthConsent, oauthTokenFamily } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import type { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { inArray } from 'drizzle-orm' + +type BetterAuthAdapter = ReturnType> +export type AuthDatabase = typeof db | Parameters[0]>[0] +const issuedFamilyIds = new AsyncLocalStorage>() + +interface OAuthConsentInsert { + id?: string + clientId: string + userId: string | null + referenceId: string | null + scopes: string[] + createdAt: Date + updatedAt: Date +} + +async function deleteTrackedFamilies( + database: AuthDatabase, + familyIds: Set +): Promise { + if (familyIds.size === 0) return + await database.delete(oauthTokenFamily).where(inArray(oauthTokenFamily.id, [...familyIds])) +} + +/** + * Compensates for Better Auth 1.6's non-transactional authorization-code token issuance. + * A failed response cannot expose the refresh secret, so its newly inserted family is deleted. + */ +export async function withOAuthProviderIssuanceCompensation( + work: () => Promise, + database: AuthDatabase = db +): Promise { + const familyIds = new Set() + try { + const response = await issuedFamilyIds.run(familyIds, work) + if (!response.ok) await deleteTrackedFamilies(database, familyIds) + return response + } catch (error) { + await deleteTrackedFamilies(database, familyIds) + throw error + } +} + +function requireConsentInsert(data: Record): OAuthConsentInsert { + if ( + typeof data.clientId !== 'string' || + !Array.isArray(data.scopes) || + !data.scopes.every((scope) => typeof scope === 'string') || + !(data.createdAt instanceof Date) || + !(data.updatedAt instanceof Date) || + (data.id !== undefined && typeof data.id !== 'string') || + (data.userId !== undefined && data.userId !== null && typeof data.userId !== 'string') || + (data.referenceId !== undefined && + data.referenceId !== null && + typeof data.referenceId !== 'string') + ) { + throw new Error('Better Auth supplied an invalid OAuth consent record') + } + return { + id: data.id, + clientId: data.clientId, + userId: typeof data.userId === 'string' ? data.userId : null, + referenceId: typeof data.referenceId === 'string' ? data.referenceId : null, + scopes: data.scopes, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + } +} + +/** + * Makes the provider's read-then-create consent path atomic. + * + * Better Auth 1.6.27 first looks for a grant and then inserts one. Two consent + * submissions can therefore race. The database uniqueness constraint is the + * integrity backstop; this adapter seam turns the losing insert into the same + * scope update the provider would have made had its preceding read seen the + * row, and returns the real persisted record expected by the adapter contract. + */ +export function guardOAuthProviderWrites( + adapter: BetterAuthAdapter, + database: AuthDatabase = db +): BetterAuthAdapter { + return { + ...adapter, + create: async (input) => { + if (input.model !== 'oauthConsent') { + const created = await adapter.create(input) + if (input.model === 'oauthRefreshToken') { + const id = (created as { id?: unknown }).id + if (typeof id !== 'string' || !id) { + throw new Error('OAuth refresh-token insert returned no family id') + } + issuedFamilyIds.getStore()?.add(id) + } + return created as never + } + + const values = requireConsentInsert(input.data) + const [consent] = await database + .insert(oauthConsent) + .values({ + id: input.forceAllowId && values.id ? values.id : generateId(), + clientId: values.clientId, + userId: values.userId ?? null, + referenceId: values.referenceId ?? null, + scopes: values.scopes, + createdAt: values.createdAt, + updatedAt: values.updatedAt, + }) + .onConflictDoUpdate({ + target: [oauthConsent.userId, oauthConsent.clientId, oauthConsent.referenceId], + set: { + scopes: values.scopes, + updatedAt: values.updatedAt, + }, + }) + .returning() + + if (!consent) throw new Error('OAuth consent upsert returned no row') + if (!input.select?.length) return consent as never + return Object.fromEntries( + input.select.map((field) => [field, consent[field as keyof typeof consent]]) + ) as never + }, + } +} diff --git a/apps/sim/lib/auth/oauth-provider-metadata.test.ts b/apps/sim/lib/auth/oauth-provider-metadata.test.ts new file mode 100644 index 00000000000..80ec6cea602 --- /dev/null +++ b/apps/sim/lib/auth/oauth-provider-metadata.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getOAuthServerConfig: vi.fn(), +})) + +vi.mock('@/lib/auth/auth', () => ({ + auth: { api: { getOAuthServerConfig: mocks.getOAuthServerConfig } }, +})) + +import { GET as getIssuerDerivedMetadata } from '@/app/.well-known/oauth-authorization-server/api/auth/route' +import { GET as getRootMetadata } from '@/app/.well-known/oauth-authorization-server/route' +import { GET as getIssuerPrefixedMetadata } from '@/app/api/auth/.well-known/oauth-authorization-server/route' + +const routes = [ + ['root', getRootMetadata, '/.well-known/oauth-authorization-server'], + ['issuer-derived', getIssuerDerivedMetadata, '/.well-known/oauth-authorization-server/api/auth'], + [ + 'issuer-prefixed', + getIssuerPrefixedMetadata, + '/api/auth/.well-known/oauth-authorization-server', + ], +] as const + +async function callRoute( + route: ( + request: NextRequest, + context?: { params?: Promise> } + ) => Promise, + path: string +): Promise { + return route(new NextRequest(`https://sim.test${path}`), { params: undefined }) +} + +describe('OAuth provider metadata', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isOAuthProviderEnabled: true }) + mocks.getOAuthServerConfig.mockResolvedValue({ + issuer: 'https://sim.test/api/auth', + authorization_endpoint: 'https://sim.test/api/auth/oauth2/authorize', + token_endpoint: 'https://sim.test/api/auth/oauth2/token', + revocation_endpoint: 'https://sim.test/api/auth/oauth2/revoke', + introspection_endpoint: 'https://sim.test/api/auth/oauth2/introspect', + introspection_endpoint_auth_methods_supported: ['client_secret_post'], + token_endpoint_auth_methods_supported: ['client_secret_post'], + revocation_endpoint_auth_methods_supported: ['none', 'client_secret_post'], + scopes_supported: ['offline_access', 'api:read', 'api:write'], + }) + }) + + afterAll(resetEnvFlagsMock) + + it.each(routes)('serves equivalent metadata from the %s alias', async (_name, route, path) => { + const response = await callRoute(route, path) + const metadata = await response.json() + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + expect(response.headers.get('cache-control')).toBe('public, max-age=300') + expect(metadata).toMatchObject({ + issuer: 'https://sim.test/api/auth', + authorization_endpoint: 'https://sim.test/api/auth/oauth2/authorize', + token_endpoint: 'https://sim.test/api/auth/oauth2/token', + revocation_endpoint: 'https://sim.test/api/auth/oauth2/revoke', + }) + expect(metadata.token_endpoint_auth_methods_supported).toEqual(['client_secret_post', 'none']) + expect(metadata.revocation_endpoint_auth_methods_supported).toEqual([ + 'none', + 'client_secret_post', + ]) + expect(metadata.introspection_endpoint).toBeUndefined() + expect(metadata.introspection_endpoint_auth_methods_supported).toBeUndefined() + expect(metadata.scopes_supported).toEqual(['offline_access', 'api:read', 'api:write']) + expect(metadata.userinfo_endpoint).toBeUndefined() + expect(metadata.jwks_uri).toBeUndefined() + }) + + it.each(routes)( + 'returns 404 from the %s alias when the provider is disabled', + async (_name, route, path) => { + setEnvFlags({ isOAuthProviderEnabled: false }) + + const response = await callRoute(route, path) + + expect(response.status).toBe(404) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + expect(mocks.getOAuthServerConfig).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/auth/oauth-provider-metadata.ts b/apps/sim/lib/auth/oauth-provider-metadata.ts new file mode 100644 index 00000000000..ea33e797fe0 --- /dev/null +++ b/apps/sim/lib/auth/oauth-provider-metadata.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth/auth' +import { isOAuthProviderEnabled } from '@/lib/core/config/env-flags' + +const DISCOVERY_CACHE_SECONDS = 300 + +const DISCOVERY_HEADERS = { + 'Cache-Control': `public, max-age=${DISCOVERY_CACHE_SECONDS}`, + 'Access-Control-Allow-Origin': '*', +} as const + +/** + * OAuth authorization-server metadata with Sim's registered public-client + * authentication method included. + * + * Better Auth 1.6.27 advertises `none` only when unauthenticated dynamic + * registration is enabled. Sim deliberately keeps registration closed while + * still provisioning public clients out of band, so the raw metadata would + * otherwise contradict the clients the token endpoint accepts. + */ +export async function getOAuthProviderMetadata() { + const metadata = await auth.api.getOAuthServerConfig() + const { + introspection_endpoint: _introspectionEndpoint, + introspection_endpoint_auth_methods_supported: _introspectionAuthMethods, + ...supportedMetadata + } = metadata + const publicClientAuthMethods = (methods: string[] | undefined) => [ + ...new Set([...(methods ?? []), 'none']), + ] + return { + ...supportedMetadata, + token_endpoint_auth_methods_supported: publicClientAuthMethods( + metadata.token_endpoint_auth_methods_supported + ), + revocation_endpoint_auth_methods_supported: publicClientAuthMethods( + metadata.revocation_endpoint_auth_methods_supported + ), + } +} + +/** One response contract for every RFC 8414 discovery alias Sim exposes. */ +export async function getOAuthProviderMetadataResponse(): Promise { + if (!isOAuthProviderEnabled) { + return NextResponse.json( + { error: 'OAuth provider is not enabled' }, + { status: 404, headers: DISCOVERY_HEADERS } + ) + } + return NextResponse.json(await getOAuthProviderMetadata(), { headers: DISCOVERY_HEADERS }) +} diff --git a/apps/sim/lib/auth/oauth-provider.test.ts b/apps/sim/lib/auth/oauth-provider.test.ts new file mode 100644 index 00000000000..cd1f21efa1b --- /dev/null +++ b/apps/sim/lib/auth/oauth-provider.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + consentRequestNamesClient, + OAUTH_API_READ_SCOPE, + OAUTH_API_WRITE_SCOPE, + OAUTH_SCOPES, + oauthScopeSatisfies, + SIM_CLI_CLIENT_ID, + summarizeOAuthAccess, + visibleOAuthScopes, +} from '@/lib/auth/oauth-provider' + +it('exposes only OAuth API authorization scopes', () => { + expect(OAUTH_SCOPES).toEqual(['offline_access', 'api:read', 'api:write']) +}) + +describe('oauthScopeSatisfies', () => { + it('treats api:write as a superset of api:read, but never the reverse', () => { + expect(oauthScopeSatisfies([OAUTH_API_WRITE_SCOPE], OAUTH_API_READ_SCOPE)).toBe(true) + expect(oauthScopeSatisfies([OAUTH_API_READ_SCOPE], OAUTH_API_WRITE_SCOPE)).toBe(false) + expect(oauthScopeSatisfies(['offline_access'], OAUTH_API_READ_SCOPE)).toBe(false) + }) +}) + +describe('visibleOAuthScopes', () => { + it('drops the read scope a granted write scope already implies', () => { + expect( + visibleOAuthScopes(['offline_access', OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE]) + ).not.toContain(OAUTH_API_READ_SCOPE) + }) + + it('ignores a scope the provider does not issue', () => { + expect(visibleOAuthScopes(['offline_access', 'admin:everything'])).toEqual(['offline_access']) + }) +}) + +describe('summarizeOAuthAccess', () => { + it('names the widest access the grant carries', () => { + expect(summarizeOAuthAccess([OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE])).toBe( + 'Full access to your workspaces' + ) + expect(summarizeOAuthAccess([OAUTH_API_READ_SCOPE])).toBe('Read-only access to your workspaces') + expect(summarizeOAuthAccess(['offline_access'])).toBe('No API access') + }) +}) + +describe('consentRequestNamesClient', () => { + it('matches the client the signed authorize query names', () => { + expect( + consentRequestNamesClient( + `client_id=${SIM_CLI_CLIENT_ID}&scope=offline_access`, + SIM_CLI_CLIENT_ID + ) + ).toBe(true) + expect(consentRequestNamesClient('client_id=partner-app', SIM_CLI_CLIENT_ID)).toBe(false) + }) + + /** + * The gate must not be escapable by putting a decoy first: `get` answers with + * the first occurrence, so a repeated parameter would otherwise let a consent + * for the CLI skip the `cli.use` check. + */ + it('matches a repeated client_id in any position', () => { + expect( + consentRequestNamesClient( + `client_id=partner-app&client_id=${SIM_CLI_CLIENT_ID}`, + SIM_CLI_CLIENT_ID + ) + ).toBe(true) + }) + + it('answers false for a body that carries no query at all', () => { + expect(consentRequestNamesClient(undefined, SIM_CLI_CLIENT_ID)).toBe(false) + expect(consentRequestNamesClient({ client_id: SIM_CLI_CLIENT_ID }, SIM_CLI_CLIENT_ID)).toBe( + false + ) + }) +}) diff --git a/apps/sim/lib/auth/oauth-provider.ts b/apps/sim/lib/auth/oauth-provider.ts new file mode 100644 index 00000000000..1783dc1b1e1 --- /dev/null +++ b/apps/sim/lib/auth/oauth-provider.ts @@ -0,0 +1,91 @@ +/** + * Constants shared by every side of Sim's OAuth 2.0 provider: the Better Auth + * plugin configuration, the consent page, the bearer-token verifier, and the + * "Authorized apps" settings surface. + */ + +/** The first-party Sim CLI, seeded by migration `0322_oauth_provider` as a public client. */ +export const SIM_CLI_CLIENT_ID = 'sim-cli' + +/** + * Prefixes returned on issued tokens (never stored). They make a leaked token + * recognizable to secret scanners and to a human reading a log, the same way + * `sim_` marks an API key. + */ +export const OAUTH_ACCESS_TOKEN_PREFIX = 'sim_oat_' +export const OAUTH_REFRESH_TOKEN_PREFIX = 'sim_ort_' + +/** Grants the Sim API: `api:write` implies `api:read`. */ +export const OAUTH_API_READ_SCOPE = 'api:read' +export const OAUTH_API_WRITE_SCOPE = 'api:write' + +export const OAUTH_SCOPES = ['offline_access', OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE] as const + +export type OAuthScope = (typeof OAUTH_SCOPES)[number] + +/** + * Token lifetimes, in seconds, as the plugin takes them. + * + * An hour of access matches what gcloud and the AWS CLI issue and limits the + * lifetime of a copied token that is not otherwise revoked. Thirty days of + * refresh is the plugin's own default and means a daily user signs in roughly + * monthly. + */ +export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 +export const OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60 +/** Bounds replay evidence for one login even if a client refreshes excessively. */ +export const OAUTH_TOKEN_FAMILY_MAX_GENERATION = 1_000 +/** How long an authorization code stays redeemable — the plugin's default. */ +export const OAUTH_CODE_TTL_SECONDS = 10 * 60 +export type OAuthApiScope = typeof OAUTH_API_READ_SCOPE | typeof OAUTH_API_WRITE_SCOPE + +/** One plain-English line per scope, rendered on the consent page. */ +export const OAUTH_SCOPE_DESCRIPTIONS: Record = { + offline_access: 'Stay signed in without asking again', + [OAUTH_API_READ_SCOPE]: 'Read your workspaces, workflows, files, tables, and logs', + [OAUTH_API_WRITE_SCOPE]: 'Read, create, change, run, and delete resources in your workspaces', +} + +/** + * The scopes worth showing a person, in declaration order. + * + * `api:write` implies `api:read`, so a client that asked for both is granted + * both — and listing them together reads as two permissions when it is one. + * Dropping the implied scope keeps the consent page honest about how much it + * is actually asking for. + */ +export function visibleOAuthScopes(granted: readonly string[]): OAuthScope[] { + const implied = granted.includes(OAUTH_API_WRITE_SCOPE) ? OAUTH_API_READ_SCOPE : null + return OAUTH_SCOPES.filter((scope) => scope !== implied && granted.includes(scope)) +} + +/** What a grant lets an app reach, as one line for a settings row. */ +export function summarizeOAuthAccess(granted: readonly string[]): string { + if (granted.includes(OAUTH_API_WRITE_SCOPE)) return 'Full access to your workspaces' + if (granted.includes(OAUTH_API_READ_SCOPE)) return 'Read-only access to your workspaces' + return 'No API access' +} + +/** + * Whether a granted scope set satisfies a required API scope. `api:write` is a + * superset of `api:read`, so a write-capable token never has to also carry the + * read scope explicitly. + */ +export function oauthScopeSatisfies(granted: readonly string[], required: OAuthApiScope): boolean { + if (granted.includes(required)) return true + return required === OAUTH_API_READ_SCOPE && granted.includes(OAUTH_API_WRITE_SCOPE) +} + +/** + * Whether a consent request names the client given, read from the signed + * authorize query the consent page forwards. + * + * Every occurrence is checked, not just the first. The plugin reads the same + * query with `.get()`, so on a well-formed request the two always agree; on a + * query carrying `client_id` twice this answers true where `.get()` would not, + * which errs toward running the gate rather than skipping it. + */ +export function consentRequestNamesClient(oauthQuery: unknown, clientId: string): boolean { + if (typeof oauthQuery !== 'string') return false + return new URLSearchParams(oauthQuery).getAll('client_id').includes(clientId) +} diff --git a/apps/sim/lib/auth/oauth-token-family.postgres.test.ts b/apps/sim/lib/auth/oauth-token-family.postgres.test.ts new file mode 100644 index 00000000000..fd4735a90ae --- /dev/null +++ b/apps/sim/lib/auth/oauth-token-family.postgres.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + */ +import { randomBytes } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('@sim/db') +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') +vi.unmock('@/lib/core/config/env') + +const databaseUrl = process.env.OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL + +describe.skipIf(!databaseUrl)('OAuth token families in PostgreSQL', () => { + it('rotates, contains replay, revokes one login, and follows consent changes', async () => { + process.env.DATABASE_URL = databaseUrl + process.env.BETTER_AUTH_SECRET ||= 'oauth-token-family-integration-test-secret' + + const [{ db }, schema, { eq, sql }, provider, tokenStore] = await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('@/lib/auth/oauth-provider'), + import('@/lib/auth/oauth-access-token'), + ]) + const { revokeOAuthToken, rotateOAuthRefreshToken } = await import( + '@/lib/auth/oauth-token-family' + ) + const testId = randomBytes(8).toString('hex') + const userId = `oauth-family-test-user-${testId}` + const sessionId = `oauth-family-test-session-${testId}` + const consentId = `oauth-family-test-consent-${testId}` + const email = `oauth-family-${testId}@example.com` + const fullScopes = ['offline_access', 'api:read', 'api:write'] + const credentials = { clientId: 'sim-cli', method: 'none' as const } + + const createInitialFamily = async (label: string): Promise => { + const refreshId = `oauth-family-test-refresh-${testId}-${label}` + const tokenBody = randomBytes(32).toString('base64url') + await db.execute(sql` + INSERT INTO "oauth_refresh_token" ( + "id", "token", "client_id", "session_id", "user_id", "reference_id", + "expires_at", "created_at", "revoked", "auth_time", "scopes", + "family_id", "generation" + ) VALUES ( + ${refreshId}, ${tokenStore.hashOAuthToken(tokenBody)}, 'sim-cli', ${sessionId}, + ${userId}, NULL, now() + interval '30 days', now(), NULL, now(), + ARRAY['offline_access', 'api:read', 'api:write']::text[], NULL, NULL + ) + `) + return `${provider.OAUTH_REFRESH_TOKEN_PREFIX}${tokenBody}` + } + + await db.insert(schema.user).values({ + id: userId, + name: 'OAuth family integration test', + email, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(schema.session).values({ + id: sessionId, + token: `oauth-family-test-session-token-${testId}`, + userId, + expiresAt: new Date(Date.now() + 86_400_000), + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(schema.oauthConsent).values({ + id: consentId, + clientId: 'sim-cli', + userId, + referenceId: null, + scopes: fullScopes, + createdAt: new Date(), + updatedAt: new Date(), + }) + + try { + const familyAToken = await createInitialFamily('a') + const familyAId = `oauth-family-test-refresh-${testId}-a` + const [familyABefore] = await db + .select({ expiresAt: schema.oauthTokenFamily.expiresAt }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyAId)) + const rotatedA = await rotateOAuthRefreshToken({ + credentials, + refreshToken: familyAToken, + requestedScopes: ['offline_access', 'api:read'], + }) + expect(rotatedA.success).toBe(true) + if (!rotatedA.success) throw new Error(rotatedA.description) + expect(rotatedA.value.scope).toBe('offline_access api:read') + + const familyARows = await db + .select({ + generation: schema.oauthRefreshToken.generation, + scopes: schema.oauthRefreshToken.scopes, + }) + .from(schema.oauthRefreshToken) + .where(eq(schema.oauthRefreshToken.familyId, familyAId)) + .orderBy(schema.oauthRefreshToken.generation) + expect(familyARows).toEqual([ + { generation: 0, scopes: fullScopes }, + { generation: 1, scopes: fullScopes }, + ]) + const [familyAAfter] = await db + .select({ expiresAt: schema.oauthTokenFamily.expiresAt }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyAId)) + expect(familyAAfter?.expiresAt).toEqual(familyABefore?.expiresAt) + + const familyBToken = await createInitialFamily('b') + const familyBId = `oauth-family-test-refresh-${testId}-b` + const replayA = await rotateOAuthRefreshToken({ + credentials, + refreshToken: familyAToken, + }) + expect(replayA).toMatchObject({ success: false, error: 'invalid_grant' }) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyAId)) + ).toHaveLength(0) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyBId)) + ).toHaveLength(1) + + const concurrent = await Promise.all([ + rotateOAuthRefreshToken({ credentials, refreshToken: familyBToken }), + rotateOAuthRefreshToken({ credentials, refreshToken: familyBToken }), + ]) + expect(concurrent.filter((result) => result.success)).toHaveLength(1) + expect(concurrent.filter((result) => !result.success)).toHaveLength(1) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyBId)) + ).toHaveLength(0) + + const familyCToken = await createInitialFamily('c') + const familyCId = `oauth-family-test-refresh-${testId}-c` + const rotatedC = await rotateOAuthRefreshToken({ credentials, refreshToken: familyCToken }) + expect(rotatedC.success).toBe(true) + if (!rotatedC.success) throw new Error(rotatedC.description) + expect(await revokeOAuthToken({ credentials, token: rotatedC.value.refreshToken })).toEqual({ + success: true, + value: undefined, + }) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyCId)) + ).toHaveLength(0) + + const familyDToken = await createInitialFamily('d') + const familyDId = `oauth-family-test-refresh-${testId}-d` + expect( + await revokeOAuthToken({ + credentials, + token: `${provider.OAUTH_REFRESH_TOKEN_PREFIX}unknown-token`, + }) + ).toEqual({ success: true, value: undefined }) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyDId)) + ).toHaveLength(1) + + await db + .update(schema.oauthConsent) + .set({ scopes: ['offline_access', 'api:read'], updatedAt: new Date() }) + .where(eq(schema.oauthConsent.id, consentId)) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, familyDId)) + ).toHaveLength(0) + expect(familyDToken).toMatch(/^sim_ort_/) + + await db + .update(schema.oauthConsent) + .set({ scopes: fullScopes, updatedAt: new Date() }) + .where(eq(schema.oauthConsent.id, consentId)) + await db + .update(schema.user) + .set({ banned: true, banExpires: new Date(Date.now() - 1_000) }) + .where(eq(schema.user.id, userId)) + const familyEToken = await createInitialFamily('e') + await expect( + rotateOAuthRefreshToken({ credentials, refreshToken: familyEToken }) + ).resolves.toMatchObject({ success: true }) + + await db + .update(schema.user) + .set({ banned: true, banExpires: new Date(Date.now() + 60_000) }) + .where(eq(schema.user.id, userId)) + const familyFToken = await createInitialFamily('f') + await expect( + rotateOAuthRefreshToken({ credentials, refreshToken: familyFToken }) + ).resolves.toMatchObject({ success: false, error: 'invalid_grant' }) + } finally { + await db.delete(schema.user).where(eq(schema.user.id, userId)) + } + }, 30_000) +}) diff --git a/apps/sim/lib/auth/oauth-token-family.ts b/apps/sim/lib/auth/oauth-token-family.ts new file mode 100644 index 00000000000..6a6aa452894 --- /dev/null +++ b/apps/sim/lib/auth/oauth-token-family.ts @@ -0,0 +1,532 @@ +import { db } from '@sim/db' +import { + oauthAccessToken, + oauthClient, + oauthConsent, + oauthRefreshToken, + oauthTokenFamily, + session, + user, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { generateSecureToken } from '@sim/security/tokens' +import { generateId } from '@sim/utils/id' +import { symmetricDecrypt } from 'better-auth/crypto' +import { and, eq, isNull } from 'drizzle-orm' +import { isBanActive } from '@/lib/auth/ban' +import { hashOAuthToken } from '@/lib/auth/oauth-access-token' +import { + OAUTH_ACCESS_TOKEN_PREFIX, + OAUTH_ACCESS_TOKEN_TTL_SECONDS, + OAUTH_REFRESH_TOKEN_PREFIX, + OAUTH_TOKEN_FAMILY_MAX_GENERATION, +} from '@/lib/auth/oauth-provider' +import { env } from '@/lib/core/config/env' + +const logger = createLogger('OAuthTokenFamily') + +type OAuthDatabase = typeof db +type OAuthReadDatabase = OAuthDatabase | Parameters[0]>[0] +type OAuthClientAuthenticationMethod = 'none' | 'client_secret_basic' | 'client_secret_post' + +export interface OAuthClientCredentials { + clientId: string + clientSecret?: string + method: OAuthClientAuthenticationMethod +} + +export interface RotateOAuthRefreshTokenInput { + credentials: OAuthClientCredentials + refreshToken: string + requestedScopes?: string[] +} + +export type OAuthProtocolErrorCode = + | 'invalid_client' + | 'invalid_grant' + | 'invalid_scope' + | 'unauthorized_client' + +export type OAuthProtocolResult = + | { success: true; value: T } + | { success: false; error: OAuthProtocolErrorCode; description: string } + +export interface OAuthTokenPair { + accessToken: string + refreshToken: string + expiresIn: number + expiresAt: number + scope: string +} + +interface OAuthClientRow { + clientId: string + clientSecret: string | null + disabled: boolean + public: boolean | null + tokenEndpointAuthMethod: string | null + grantTypes: string[] | null + scopes: string[] | null + skipConsent: boolean | null +} + +interface RefreshTokenRow { + id: string + clientId: string + sessionId: string | null + userId: string + referenceId: string | null + expiresAt: Date + revoked: Date | null + authTime: Date | null + scopes: string[] + familyId: string + familyConsentId: string | null + generation: number +} + +function protocolError( + error: OAuthProtocolErrorCode, + description: string +): OAuthProtocolResult { + return { success: false, error, description } +} + +function stripTokenPrefix(token: string, prefix: string): string | null { + if (!token.startsWith(prefix)) return null + const raw = token.slice(prefix.length) + return raw || null +} + +function clientAllowsRefresh(client: OAuthClientRow): boolean { + const grants = client.grantTypes?.length ? client.grantTypes : ['authorization_code'] + return grants.includes('refresh_token') || grants.includes('authorization_code') +} + +async function authenticateClient( + client: OAuthClientRow | undefined, + credentials: OAuthClientCredentials +): Promise> { + if (!client || client.disabled) { + return protocolError('invalid_client', 'Client authentication failed.') + } + + const registeredMethod = client.tokenEndpointAuthMethod ?? 'client_secret_basic' + if ( + registeredMethod !== 'none' && + registeredMethod !== 'client_secret_basic' && + registeredMethod !== 'client_secret_post' + ) { + logger.error('OAuth client has an unsupported token authentication method', { + clientId: client.clientId, + registeredMethod, + }) + return protocolError('invalid_client', 'Client authentication failed.') + } + if (credentials.method !== registeredMethod) { + return protocolError( + 'invalid_client', + 'Client authentication method does not match registration.' + ) + } + + if (registeredMethod === 'none') { + if (!client.public || credentials.clientSecret) { + return protocolError('invalid_client', 'Client authentication failed.') + } + return { success: true, value: client } + } + + if (client.public || !client.clientSecret || !credentials.clientSecret) { + return protocolError('invalid_client', 'Client authentication failed.') + } + + try { + const expected = await symmetricDecrypt({ + key: env.BETTER_AUTH_SECRET, + data: client.clientSecret, + }) + if (!safeCompare(expected, credentials.clientSecret)) { + return protocolError('invalid_client', 'Client authentication failed.') + } + } catch (error) { + logger.error('Failed to decrypt an OAuth client secret', { clientId: client.clientId, error }) + return protocolError('invalid_client', 'Client authentication failed.') + } + + return { success: true, value: client } +} + +async function readClient( + database: OAuthDatabase, + clientId: string +): Promise { + const [client] = await database + .select({ + clientId: oauthClient.clientId, + clientSecret: oauthClient.clientSecret, + disabled: oauthClient.disabled, + public: oauthClient.public, + tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod, + grantTypes: oauthClient.grantTypes, + scopes: oauthClient.scopes, + skipConsent: oauthClient.skipConsent, + }) + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .limit(1) + return client +} + +/** Validates one token-endpoint client authentication attempt against its registered method. */ +export async function validateOAuthClientCredentials( + credentials: OAuthClientCredentials, + database: OAuthDatabase = db +): Promise> { + const authenticated = await authenticateClient( + await readClient(database, credentials.clientId), + credentials + ) + return authenticated.success + ? { success: true, value: undefined } + : protocolError(authenticated.error, authenticated.description) +} + +async function readRefreshToken( + database: OAuthReadDatabase, + tokenHash: string +): Promise { + const [token] = await database + .select({ + id: oauthRefreshToken.id, + clientId: oauthRefreshToken.clientId, + sessionId: oauthRefreshToken.sessionId, + userId: oauthRefreshToken.userId, + referenceId: oauthRefreshToken.referenceId, + expiresAt: oauthRefreshToken.expiresAt, + revoked: oauthRefreshToken.revoked, + authTime: oauthRefreshToken.authTime, + scopes: oauthRefreshToken.scopes, + familyId: oauthRefreshToken.familyId, + familyConsentId: oauthTokenFamily.consentId, + generation: oauthRefreshToken.generation, + }) + .from(oauthRefreshToken) + .innerJoin(oauthTokenFamily, eq(oauthRefreshToken.familyId, oauthTokenFamily.id)) + .where(eq(oauthRefreshToken.token, tokenHash)) + .limit(1) + return token +} + +function validateScopes( + tokenScopes: string[], + clientScopes: string[] | null, + requestedScopes?: string[] +): OAuthProtocolResult { + const scopes = requestedScopes ?? tokenScopes + const tokenScopeSet = new Set(tokenScopes) + const clientScopeSet = clientScopes ? new Set(clientScopes) : null + for (const scope of scopes) { + if (!tokenScopeSet.has(scope) || (clientScopeSet && !clientScopeSet.has(scope))) { + return protocolError('invalid_scope', `The client cannot refresh scope ${scope}.`) + } + } + return { success: true, value: scopes } +} + +/** + * Rotates one refresh token under the stable family lock. + * + * Replay is intentionally fail-closed: after the first rotation commits, a + * second presentation of any consumed generation deletes the family parent. + * Cascades remove every refresh token and every access token issued by this + * login while independent logins for the same user and client remain intact. + */ +export async function rotateOAuthRefreshToken( + input: RotateOAuthRefreshTokenInput, + database: OAuthDatabase = db +): Promise> { + const rawRefreshToken = stripTokenPrefix(input.refreshToken, OAUTH_REFRESH_TOKEN_PREFIX) + if (!rawRefreshToken) return protocolError('invalid_grant', 'Refresh token is invalid.') + + const clientAuthentication = await authenticateClient( + await readClient(database, input.credentials.clientId), + input.credentials + ) + if (!clientAuthentication.success) return clientAuthentication + if (!clientAllowsRefresh(clientAuthentication.value)) { + return protocolError('unauthorized_client', 'Client is not allowed to use refresh tokens.') + } + + const tokenHash = hashOAuthToken(rawRefreshToken) + const provisionalToken = await readRefreshToken(database, tokenHash) + if (!provisionalToken) return protocolError('invalid_grant', 'Refresh token is invalid.') + if (provisionalToken.clientId !== input.credentials.clientId) { + return protocolError('invalid_grant', 'Refresh token is invalid.') + } + + const nextRefreshBody = generateSecureToken(32) + const nextAccessBody = generateSecureToken(32) + const nextRefreshId = generateId() + const nextAccessId = generateId() + + return database.transaction(async (tx) => { + const [activeUser] = await tx + .select({ id: user.id, banned: user.banned, banExpires: user.banExpires }) + .from(user) + .where(eq(user.id, provisionalToken.userId)) + .for('share') + .limit(1) + if (!activeUser || isBanActive(activeUser)) { + return protocolError('invalid_grant', 'Refresh token is invalid.') + } + + if (provisionalToken.sessionId) { + const [activeSession] = await tx + .select({ id: session.id }) + .from(session) + .where(eq(session.id, provisionalToken.sessionId)) + .for('share') + .limit(1) + if (!activeSession) return protocolError('invalid_grant', 'Refresh token is invalid.') + } + + const [lockedClient] = await tx + .select({ + clientId: oauthClient.clientId, + clientSecret: oauthClient.clientSecret, + disabled: oauthClient.disabled, + public: oauthClient.public, + tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod, + grantTypes: oauthClient.grantTypes, + scopes: oauthClient.scopes, + skipConsent: oauthClient.skipConsent, + }) + .from(oauthClient) + .where(eq(oauthClient.clientId, input.credentials.clientId)) + .for('share') + .limit(1) + const lockedAuthentication = await authenticateClient(lockedClient, input.credentials) + if (!lockedAuthentication.success) return lockedAuthentication + if (!clientAllowsRefresh(lockedAuthentication.value)) { + return protocolError('unauthorized_client', 'Client is not allowed to use refresh tokens.') + } + + let consentScopes: string[] | null = null + if (!lockedClient?.skipConsent) { + if (!provisionalToken.familyConsentId) { + return protocolError('invalid_grant', 'Refresh token is invalid.') + } + const [consent] = await tx + .select({ id: oauthConsent.id, scopes: oauthConsent.scopes }) + .from(oauthConsent) + .where(eq(oauthConsent.id, provisionalToken.familyConsentId)) + .for('share') + .limit(1) + if (!consent) return protocolError('invalid_grant', 'Refresh token is invalid.') + consentScopes = consent.scopes + } + + const [family] = await tx + .select({ + id: oauthTokenFamily.id, + clientId: oauthTokenFamily.clientId, + userId: oauthTokenFamily.userId, + sessionId: oauthTokenFamily.sessionId, + referenceId: oauthTokenFamily.referenceId, + currentGeneration: oauthTokenFamily.currentGeneration, + expiresAt: oauthTokenFamily.expiresAt, + }) + .from(oauthTokenFamily) + .where(eq(oauthTokenFamily.id, provisionalToken.familyId)) + .for('update') + .limit(1) + if (!family) return protocolError('invalid_grant', 'Refresh token is invalid.') + + const rotationTime = new Date() + const currentToken = await readRefreshToken(tx, tokenHash) + if ( + !currentToken || + currentToken.familyId !== family.id || + currentToken.clientId !== family.clientId || + currentToken.userId !== family.userId || + currentToken.sessionId !== family.sessionId || + currentToken.referenceId !== family.referenceId || + currentToken.revoked || + currentToken.expiresAt <= rotationTime || + family.expiresAt <= rotationTime || + currentToken.generation !== family.currentGeneration + ) { + await tx.delete(oauthTokenFamily).where(eq(oauthTokenFamily.id, family.id)) + return protocolError('invalid_grant', 'Refresh token is invalid or has already been used.') + } + + const originalScopesAllowedByClient = + !lockedClient.scopes || + currentToken.scopes.every((scope) => lockedClient.scopes?.includes(scope)) + const originalScopesStillConsented = + lockedClient.skipConsent || + (consentScopes !== null && + currentToken.scopes.every((scope) => consentScopes.includes(scope))) + if (!originalScopesAllowedByClient || !originalScopesStillConsented) { + await tx.delete(oauthTokenFamily).where(eq(oauthTokenFamily.id, family.id)) + return protocolError('invalid_grant', 'Refresh token grant is no longer active.') + } + if (family.currentGeneration >= OAUTH_TOKEN_FAMILY_MAX_GENERATION) { + await tx.delete(oauthTokenFamily).where(eq(oauthTokenFamily.id, family.id)) + return protocolError('invalid_grant', 'Refresh token grant reached its rotation limit.') + } + + const scopes = validateScopes(currentToken.scopes, lockedClient.scopes, input.requestedScopes) + if (!scopes.success) return scopes + + const [consumed] = await tx + .update(oauthRefreshToken) + .set({ revoked: rotationTime }) + .where(and(eq(oauthRefreshToken.id, currentToken.id), isNull(oauthRefreshToken.revoked))) + .returning({ id: oauthRefreshToken.id }) + if (!consumed) { + await tx.delete(oauthTokenFamily).where(eq(oauthTokenFamily.id, family.id)) + return protocolError('invalid_grant', 'Refresh token is invalid or has already been used.') + } + + const nextGeneration = family.currentGeneration + 1 + await tx + .update(oauthTokenFamily) + .set({ currentGeneration: nextGeneration }) + .where(eq(oauthTokenFamily.id, family.id)) + + const accessExpiresAt = new Date(rotationTime.getTime() + OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000) + + await tx.insert(oauthRefreshToken).values({ + id: nextRefreshId, + token: hashOAuthToken(nextRefreshBody), + clientId: currentToken.clientId, + sessionId: currentToken.sessionId, + userId: currentToken.userId, + referenceId: currentToken.referenceId, + expiresAt: family.expiresAt, + createdAt: rotationTime, + revoked: null, + authTime: currentToken.authTime, + scopes: currentToken.scopes, + familyId: family.id, + generation: nextGeneration, + }) + await tx.insert(oauthAccessToken).values({ + id: nextAccessId, + token: hashOAuthToken(nextAccessBody), + clientId: currentToken.clientId, + sessionId: currentToken.sessionId, + userId: currentToken.userId, + referenceId: currentToken.referenceId, + refreshId: nextRefreshId, + expiresAt: accessExpiresAt, + createdAt: rotationTime, + scopes: scopes.value, + }) + + return { + success: true, + value: { + accessToken: `${OAUTH_ACCESS_TOKEN_PREFIX}${nextAccessBody}`, + refreshToken: `${OAUTH_REFRESH_TOKEN_PREFIX}${nextRefreshBody}`, + expiresIn: OAUTH_ACCESS_TOKEN_TTL_SECONDS, + expiresAt: Math.floor(accessExpiresAt.getTime() / 1000), + scope: scopes.value.join(' '), + }, + } + }) +} + +/** Revokes one refresh family or one opaque access token. Unknown tokens are a successful no-op. */ +export async function revokeOAuthToken( + input: { credentials: OAuthClientCredentials; token: string }, + database: OAuthDatabase = db +): Promise> { + const clientAuthentication = await authenticateClient( + await readClient(database, input.credentials.clientId), + input.credentials + ) + if (!clientAuthentication.success) return clientAuthentication + + const rawRefreshToken = stripTokenPrefix(input.token, OAUTH_REFRESH_TOKEN_PREFIX) + if (rawRefreshToken) { + const provisionalToken = await readRefreshToken(database, hashOAuthToken(rawRefreshToken)) + if (!provisionalToken || provisionalToken.clientId !== input.credentials.clientId) { + return { success: true, value: undefined } + } + + return database.transaction(async (tx) => { + await tx + .select({ id: user.id }) + .from(user) + .where(eq(user.id, provisionalToken.userId)) + .for('share') + .limit(1) + if (provisionalToken.sessionId) { + await tx + .select({ id: session.id }) + .from(session) + .where(eq(session.id, provisionalToken.sessionId)) + .for('share') + .limit(1) + } + const [lockedClient] = await tx + .select({ + clientId: oauthClient.clientId, + clientSecret: oauthClient.clientSecret, + disabled: oauthClient.disabled, + public: oauthClient.public, + tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod, + grantTypes: oauthClient.grantTypes, + scopes: oauthClient.scopes, + skipConsent: oauthClient.skipConsent, + }) + .from(oauthClient) + .where(eq(oauthClient.clientId, input.credentials.clientId)) + .for('share') + .limit(1) + const lockedAuthentication = await authenticateClient(lockedClient, input.credentials) + if (!lockedAuthentication.success) return lockedAuthentication + + const [family] = await tx + .select({ id: oauthTokenFamily.id, consentId: oauthTokenFamily.consentId }) + .from(oauthTokenFamily) + .where(eq(oauthTokenFamily.id, provisionalToken.familyId)) + .limit(1) + if (!family) return { success: true, value: undefined } + + if (family.consentId) { + await tx + .select({ id: oauthConsent.id }) + .from(oauthConsent) + .where(eq(oauthConsent.id, family.consentId)) + .for('share') + .limit(1) + } + await tx + .select({ id: oauthTokenFamily.id }) + .from(oauthTokenFamily) + .where(eq(oauthTokenFamily.id, family.id)) + .for('update') + .limit(1) + await tx.delete(oauthTokenFamily).where(eq(oauthTokenFamily.id, family.id)) + return { success: true, value: undefined } + }) + } + + const rawAccessToken = stripTokenPrefix(input.token, OAUTH_ACCESS_TOKEN_PREFIX) + if (rawAccessToken) { + await database + .delete(oauthAccessToken) + .where( + and( + eq(oauthAccessToken.token, hashOAuthToken(rawAccessToken)), + eq(oauthAccessToken.clientId, input.credentials.clientId) + ) + ) + } + return { success: true, value: undefined } +} diff --git a/apps/sim/lib/auth/sim-auth-adapter.ts b/apps/sim/lib/auth/sim-auth-adapter.ts new file mode 100644 index 00000000000..ece76dfa152 --- /dev/null +++ b/apps/sim/lib/auth/sim-auth-adapter.ts @@ -0,0 +1,37 @@ +import { db } from '@sim/db' +import * as schema from '@sim/db/schema' +import type { BetterAuthOptions } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { + type AuthDatabase, + guardOAuthProviderWrites, +} from '@/lib/auth/oauth-provider-adapter-guard' +import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' + +type BetterAuthAdapter = ReturnType> + +/** + * Builds every Better Auth adapter surface, including transactional callbacks, + * with Sim's write invariants applied to the actual Drizzle connection in use. + */ +export function createSimAuthAdapter( + options: BetterAuthOptions, + database: AuthDatabase = db, + inTransaction = false +): BetterAuthAdapter { + const base = drizzleAdapter(database, { + provider: 'pg', + schema, + transaction: false, + })(options) + const guarded = guardSubscriptionPlanWrites(guardOAuthProviderWrites(base, database)) + if (inTransaction) return guarded + + guarded.transaction = (callback) => + database.transaction(async (tx) => { + const transactionAdapter = createSimAuthAdapter(options, tx, true) + const { transaction: _transaction, ...surface } = transactionAdapter + return callback(surface) + }) + return guarded +} diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts index 5a6105d6cf1..07c036fea12 100644 --- a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -1,13 +1,14 @@ -import type { Principal } from '@sim/auth/principal' +import { isUserCredentialPrincipal, type Principal } from '@sim/auth/principal' import { permissionSatisfies, resolveEffectiveWorkspacePermission, } from '@sim/platform-authz/workspace' +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' import type { BillingReadOperation, BillingReadPrincipal, } from '@/lib/billing/application/operations' -import { type OperationUseCase, requirePersonalApiKeysAllowed } from '@/lib/core/application' +import { type OperationUseCase, requireUserCredentialCapabilities } from '@/lib/core/application' import { InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, @@ -16,6 +17,7 @@ import { WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { refuseCapability } from '@/lib/permission-groups/capabilities' import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { type ActiveWorkspaceApplicationContext, @@ -82,11 +84,22 @@ async function resolveBillingReadScope( * the same one the personal-API-key and CLI mint paths use. A no-op when the * caller is in no organization or no group governs them. */ - if ( - principal.kind === 'personal_api_key' && - (await isCapabilityWithheldForUser(principal.userId, 'personal_api_key.use')) - ) { - throw new PersonalApiKeysDisabledError() + if (isUserCredentialPrincipal(principal)) { + if (await isCapabilityWithheldForUser(principal.userId, 'personal_api_key.use')) { + throw new PersonalApiKeysDisabledError() + } + /** + * permission-group-enforced: cli.use — a CLI token reads the account's + * plan, balance and usage here without naming a workspace, so the + * workspace-scoped check in the funnel never sees it. + */ + if ( + principal.kind === 'oauth_access_token' && + principal.clientId === SIM_CLI_CLIENT_ID && + (await isCapabilityWithheldForUser(principal.userId, 'cli.use')) + ) { + refuseCapability('cli.use') + } } return { kind: 'account', userId: principal.userId } } @@ -98,7 +111,7 @@ async function resolveBillingReadScope( const workspace = await loadActiveWorkspaceApplicationContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - if (principal.kind === 'personal_api_key') { + if (isUserCredentialPrincipal(principal)) { if (!workspace.allowPersonalApiKeys) { throw new PersonalApiKeysDisabledError() } @@ -112,17 +125,18 @@ async function resolveBillingReadScope( throw new InsufficientWorkspacePermissionsError() } /** - * permission-group-enforced: personal_api_key.use — this path resolves its - * own workspace scope instead of running through - * `authorizeWorkspaceOperation`, so the funnel's personal-key refusal has to - * be repeated here or the same key the funnel refuses still reads billing. + * permission-group-enforced: personal_api_key.use, cli.use — this path + * resolves its own workspace scope instead of running through + * `authorizeWorkspaceOperation`, so the funnel's capability refusals have + * to be repeated here or the same credential the funnel refuses still + * reads billing. * * After the role check, like the funnel: it answers with a 403 naming how an * organization configured one cohort, and running it ahead of the concealed * no-access refusal would hand that to a caller with no reach into the * workspace at all. */ - await requirePersonalApiKeysAllowed(principal.userId, workspace) + await requireUserCredentialCapabilities(principal, workspace) } return { kind: 'workspace', workspace } diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts index 4ff70ea0fc3..0429676f2f2 100644 --- a/apps/sim/lib/billing/application/get-billing-status.ts +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' import { type BillingReadPrincipal, billingOperations } from '@/lib/billing/application/operations' import { @@ -104,7 +105,7 @@ async function canReadPayerPool( principal: BillingReadPrincipal, workspace: WorkspaceBillingAuthorityContext ): Promise { - if (principal.kind !== 'personal_api_key') return false + if (!isUserCredentialPrincipal(principal)) return false return canUserManageWorkspaceBilling(workspace, principal.userId) } @@ -157,7 +158,7 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ execute: async ({ principal, scope }): Promise => { if (scope.kind === 'workspace') { const [attribution, canViewPayerPool] = await Promise.all([ - principal.kind === 'personal_api_key' + isUserCredentialPrincipal(principal) ? resolveBillingAttribution({ actorUserId: principal.userId, workspaceId: scope.workspace.workspaceId, diff --git a/apps/sim/lib/billing/application/list-billing-logs.ts b/apps/sim/lib/billing/application/list-billing-logs.ts index 63820fd49ba..fa6ca6eb2ff 100644 --- a/apps/sim/lib/billing/application/list-billing-logs.ts +++ b/apps/sim/lib/billing/application/list-billing-logs.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' import { billingOperations } from '@/lib/billing/application/operations' import { @@ -38,14 +39,14 @@ function apportionLogCredits(usage: ListBillingLogsResult['usage']): Record export interface BillingReadOperation extends ApplicationOperation { readonly accountScope: 'personal_self' readonly workspaceMinimumRole: 'read' readonly workspaceApiKey: 'workspace_only' - readonly principalKinds: readonly ['personal_api_key', 'workspace_api_key'] + readonly principalKinds: readonly ['personal_api_key', 'oauth_access_token', 'workspace_api_key'] } function defineBillingReadOperation( @@ -33,7 +33,7 @@ export const billingOperations = { accountScope: 'personal_self', workspaceMinimumRole: 'read', workspaceApiKey: 'workspace_only', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), // permission-group-exempt: the same personal billing account reading its own usage records; no group key names it listLogs: defineBillingReadOperation({ @@ -42,6 +42,6 @@ export const billingOperations = { accountScope: 'personal_self', workspaceMinimumRole: 'read', workspaceApiKey: 'workspace_only', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), } as const diff --git a/apps/sim/lib/catalog/application/operations.test.ts b/apps/sim/lib/catalog/application/operations.test.ts index 7b1ce4d4421..c7e16079d30 100644 --- a/apps/sim/lib/catalog/application/operations.test.ts +++ b/apps/sim/lib/catalog/application/operations.test.ts @@ -50,6 +50,7 @@ describe('catalogOperations', () => { expect(operation.minimumRole, operation.id).toBe('read') expect(operation.workspaceApiKey, operation.id).toBe('allow') expect([...operation.principalKinds].sort(), operation.id).toEqual([ + 'oauth_access_token', 'personal_api_key', 'session', 'workspace_api_key', diff --git a/apps/sim/lib/catalog/application/operations.ts b/apps/sim/lib/catalog/application/operations.ts index 8ee9e1453d5..0be5610200d 100644 --- a/apps/sim/lib/catalog/application/operations.ts +++ b/apps/sim/lib/catalog/application/operations.ts @@ -28,7 +28,7 @@ export const catalogOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'none', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), // permission-group-exempt: one entry of the same catalog listBlocks returns, so it cannot be governed differently readBlock: defineWorkspaceOperation({ @@ -36,7 +36,7 @@ export const catalogOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'none', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), // permission-group-exempt: describes which tools exist; whether a member may call one is decided on that tool's own operation listTools: defineWorkspaceOperation({ @@ -44,7 +44,7 @@ export const catalogOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'none', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), // permission-group-exempt: one entry of the same catalog listTools returns, so it cannot be governed differently readTool: defineWorkspaceOperation({ @@ -52,7 +52,7 @@ export const catalogOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'none', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), /** * The only catalog with a capability: it enumerates knowledge-base connector @@ -64,6 +64,6 @@ export const catalogOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'knowledge.use', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), } as const diff --git a/apps/sim/lib/chat-deployments/application/operations.ts b/apps/sim/lib/chat-deployments/application/operations.ts index 0ea237e3977..1466e615006 100644 --- a/apps/sim/lib/chat-deployments/application/operations.ts +++ b/apps/sim/lib/chat-deployments/application/operations.ts @@ -21,7 +21,13 @@ import { defineWorkspaceOperation } from '@/lib/core/application' * deploying rather than a chat surface the caller is configuring. */ const CHAT_DEPLOYMENT_LIST_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const @@ -37,7 +43,7 @@ const CHAT_DEPLOYMENT_LIST_POLICY = { * workspace API keys, which cannot exceed the write ceiling. */ const CHAT_DEPLOYMENT_ADMIN_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], } as const diff --git a/apps/sim/lib/copilot/application/operations.ts b/apps/sim/lib/copilot/application/operations.ts index 7a910261cb5..ea08bf03a7e 100644 --- a/apps/sim/lib/copilot/application/operations.ts +++ b/apps/sim/lib/copilot/application/operations.ts @@ -12,6 +12,6 @@ export const chatOperations = { minimumRole: 'read', workspaceApiKey: 'deny', capability: 'copilot.use', - principalKinds: ['personal_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token'], }), } as const diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 766b0891c69..8dae46aff49 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -12,9 +12,8 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' * * The set is closed and exhaustive over the refusals a caller can act on, for * two reasons. A union type makes an unlisted spelling a compile error rather - * than a new undocumented value on the wire, and the OpenAPI 403 description is - * generated from these same members, so a code cannot be emitted without being - * published. + * than a new undocumented value on the wire, and the OpenAPI details schema + * publishes these same members as an enum. * * Deliberately absent: the cross-tenant refusals (`NoWorkspaceAccessError`, * `WorkspaceApiKeyScopeAuthorizationError`, @@ -64,57 +63,12 @@ export const FORBIDDEN_DETAIL_CODES = [ 'PERMISSION_GROUP_CAPABILITY_BLOCKED', /** The workspace does not permit the integration the request names. */ 'INTEGRATION_NOT_ALLOWED', + /** The OAuth access token was not granted the scope this operation needs. */ + 'INSUFFICIENT_SCOPE', ] as const export type ForbiddenDetailCode = (typeof FORBIDDEN_DETAIL_CODES)[number] -/** - * What each code means to a caller, in the words the generated OpenAPI 403 - * description publishes. - * - * The `Record` is the completeness gate: adding a member to - * {@link FORBIDDEN_DETAIL_CODES} fails to compile until it is documented here, - * so a code cannot reach the wire undocumented. - */ -export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record = { - INSUFFICIENT_WORKSPACE_ROLE: - 'The caller has access to the workspace but its role is below the one this operation requires.', - PERSONAL_API_KEYS_DISABLED: - "The workspace's organization does not allow personal API keys. Use a workspace API key.", - WORKSPACE_KEY_OPERATION_NOT_PERMITTED: - 'This operation is not available to a workspace-scoped API key. Use a personal API key.', - PRINCIPAL_KIND_NOT_PERMITTED: 'This operation does not accept the caller’s kind of API key.', - ORGANIZATION_MEMBERSHIP_REQUIRED: 'The caller is not a member of the organization it named.', - ORGANIZATION_ADMIN_REQUIRED: - 'The caller is a member of the organization but not an admin or owner.', - ENTERPRISE_PLAN_REQUIRED: 'The organization has no active enterprise subscription.', - ORGANIZATION_PLAN_REQUIRED: - 'The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).', - AUDIT_LOGS_DISABLED: 'Audit logging is not enabled for this deployment.', - SKILL_EDITOR_ACCESS_REQUIRED: - 'The caller can write in the workspace but is not an editor of this skill.', - SECRET_ADMIN_ACCESS_REQUIRED: - 'The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.', - WORKSPACE_RESOURCE_LIMIT_REACHED: - 'The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.', - PUBLIC_SHARING_NOT_ALLOWED: - "The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.", - CREDENTIAL_ADMIN_ACCESS_REQUIRED: - 'The caller can reach the workspace but cannot administer this credential.', - MCP_SERVER_URL_NOT_ALLOWED: - 'The supplied MCP server URL is outside the allowed domains or resolves to an internal address.', - WORKSPACE_PLAN_CAPABILITY_REQUIRED: - "The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.", - CHAT_AUTH_MODE_NOT_PERMITTED: - "The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.", - CONNECTOR_MANAGED_RESOURCE_READ_ONLY: - 'This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.', - PERMISSION_GROUP_CAPABILITY_BLOCKED: - "The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.", - INTEGRATION_NOT_ALLOWED: - "The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS.", -} - /** * A `forbidden` orchestration failure that names its cause. * diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 75d6ba3ce2d..7572070dfc9 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -9,7 +9,6 @@ export { type WorkspaceUseCaseAuditEntry, } from '@/lib/core/application/authorized-workspace-use-case' export { - FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, FORBIDDEN_DETAIL_CODES, type ForbiddenDetailCode, ForbiddenOperationError, @@ -37,13 +36,16 @@ export { capabilityGovernedPrincipalUserId, DelegatedServiceAuthorizationError, DelegatedWorkspaceAuthorizationError, + InsufficientScopeError, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, + OAuthAccessTokenExpiredError, PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, requireCurrentHumanRole, requirePersonalApiKeysAllowed, + requireUserCredentialCapabilities, WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index b19260d860b..741c90cfff0 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -3,6 +3,7 @@ */ import type { DelegatedPrincipal, + OAuthAccessTokenPrincipal, PersonalApiKeyPrincipal, SessionPrincipal, WorkspaceApiKeyPrincipal, @@ -30,8 +31,10 @@ import { authorizeWorkspaceOperation, capabilityGovernedPrincipalUserId, defineWorkspaceOperation, + InsufficientScopeError, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, + OAuthAccessTokenExpiredError, PermissionGroupCapabilityError, PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, @@ -625,3 +628,164 @@ describe('capabilityGovernedPrincipalUserId', () => { ).toBe('user-3') }) }) + +describe('authorizeWorkspaceOperation OAuth access token policy', () => { + const readOperation = defineWorkspaceOperation({ + id: 'test.oauth-read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + capability: 'none', + }) + const oauthWriteOperation = defineWorkspaceOperation({ + id: 'test.oauth-write', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + capability: 'none', + }) + + function token(overrides: Partial = {}): OAuthAccessTokenPrincipal { + return { + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['offline_access', 'api:read', 'api:write'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + ...overrides, + } + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + }) + + it('walks the personal-key sequence for a token with the right scope', async () => { + await expect( + authorizeWorkspaceOperation(token(), oauthWriteOperation, context) + ).resolves.toBeUndefined() + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(resolveGroupConfigMock).toHaveBeenCalled() + }) + + it('refuses a token carrying neither API scope before touching the workspace', async () => { + const failure = await authorizeWorkspaceOperation( + token({ scopes: ['offline_access'] }), + readOperation, + context + ).catch((error) => error) + + expect(failure).toBeInstanceOf(InsufficientScopeError) + expect(failure.requiredScope).toBe('api:read') + expect(failure.detailCode).toBe('INSUFFICIENT_SCOPE') + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) + + /** + * The funnel enforces only the read floor. Which requests count as writes is + * decided from the HTTP method at v2 admission, because `minimumRole` does + * not answer it: several POST routes only read (search, query, count), so + * deriving the scope from the role let a read-only token perform them. + */ + it('leaves the write decision to the surface, admitting a read-only token on a write operation', async () => { + await expect( + authorizeWorkspaceOperation( + token({ scopes: ['offline_access', 'api:read'] }), + oauthWriteOperation, + context + ) + ).resolves.toBeUndefined() + }) + + it('lets api:write satisfy a read operation', async () => { + await expect( + authorizeWorkspaceOperation( + token({ scopes: ['offline_access', 'api:write'] }), + readOperation, + context + ) + ).resolves.toBeUndefined() + }) + + /** + * The request-time half of the `cli.use` gate. The consent page enforces it + * when the grant is made, but a consent already on file lets every later + * authorization skip that endpoint — so withdrawing the capability has to + * stop the token already in the user's hands. + */ + it('refuses a Sim CLI token once the group withholds cli.use', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableCliAccess: true, + }) + + await expect( + authorizeWorkspaceOperation(token(), readOperation, context) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + it('leaves a third-party client alone, which cli.use does not govern', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableCliAccess: true, + }) + + await expect( + authorizeWorkspaceOperation(token({ clientId: 'partner-app' }), readOperation, context) + ).resolves.toBeUndefined() + }) + + it('refuses a lapsed token as unauthorized rather than forbidden', async () => { + const failure = await authorizeWorkspaceOperation( + token({ expiresAt: new Date('2000-01-01T00:00:00.000Z') }), + readOperation, + context + ).catch((error) => error) + + expect(failure).toBeInstanceOf(OAuthAccessTokenExpiredError) + expect(failure.code).toBe('unauthorized') + }) + + it('is governed by the workspace personal-key column like a personal key', async () => { + await expect( + authorizeWorkspaceOperation(token(), readOperation, { + ...context, + allowPersonalApiKeys: false, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) + + it('is governed by the group personal-key setting, after the role check', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + authorizeWorkspaceOperation(token(), readOperation, context) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(mocks.resolvePermission).toHaveBeenCalled() + }) + + it('conceals a workspace the person cannot reach', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + authorizeWorkspaceOperation(token(), readOperation, context) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + }) + + it('names the person for capability purposes', () => { + expect(capabilityGovernedPrincipalUserId(token())).toBe('user-1') + }) +}) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 2b834162911..88b4f8416d1 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -1,5 +1,7 @@ import { type DelegatedPrincipal, + type OAuthAccessTokenPrincipal, + type PersonalApiKeyPrincipal, type Principal, resolvePrincipalSubject, } from '@sim/auth/principal' @@ -9,6 +11,12 @@ import { permissionSatisfies, resolveEffectiveWorkspacePermission, } from '@sim/platform-authz/workspace' +import { + OAUTH_API_READ_SCOPE, + type OAuthApiScope, + oauthScopeSatisfies, + SIM_CLI_CLIENT_ID, +} from '@/lib/auth/oauth-provider' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import type { PrincipalForOperation, @@ -41,6 +49,7 @@ export function capabilityGovernedPrincipalUserId(principal: Principal): string switch (principal.kind) { case 'session': case 'personal_api_key': + case 'oauth_access_token': return principal.userId case 'workspace_api_key': case 'system': @@ -124,6 +133,25 @@ export class DelegatedWorkspaceAuthorizationError extends OrchestrationError { } } +export class InsufficientScopeError extends ForbiddenOperationError { + constructor(readonly requiredScope: OAuthApiScope) { + super('INSUFFICIENT_SCOPE', `This operation requires the ${requiredScope} scope`) + this.name = 'InsufficientScopeError' + } +} + +/** + * Concealed as a `401` by the surface: an expired token is no credential at + * all, and the verifier already refuses it, so reaching this means the token + * lapsed between authentication and authorization. + */ +export class OAuthAccessTokenExpiredError extends OrchestrationError { + constructor() { + super('unauthorized', 'OAuth access token has expired') + this.name = 'OAuthAccessTokenExpiredError' + } +} + export class PrincipalKindAuthorizationError extends ForbiddenOperationError { constructor(principalKind: Principal['kind'], operationId: string) { super( @@ -211,6 +239,40 @@ async function requireCapability( ) } +/** + * Refuses a token the Sim CLI holds when the caller's group withholds CLI use. + * + * permission-group-enforced: cli.use — the consent page enforces it when the + * grant is first made, but a consent already on file lets every later + * authorization skip the consent endpoint, and a refresh token keeps minting + * access tokens for a month. Withdrawing the capability has to stop the + * credential in use, not only the next fresh grant, so it is asked again here, + * on the request. The group config is request-cached and the personal-key check + * that runs just before this one has already read it, so it costs no extra + * query. + * + * Three surfaces authorize themselves instead of entering through the funnel. + * Billing and audit-log reads repeat this check at their own call sites, since + * both return data a withdrawn capability is meant to cut off. `/api/v2/meta` + * does not, and should not: it answers only with facts about the credential + * the caller already holds, so there is nothing there to withhold. + */ +export async function requireCliAccessAllowed( + clientId: string, + userId: string, + context: WorkspaceAuthorizationContext +): Promise { + if (clientId !== SIM_CLI_CLIENT_ID) return + if (context.workspaceOrganizationId === null) return + + await assertWorkspaceCapability( + userId, + context.workspaceId, + 'cli.use', + context.workspaceOrganizationId + ) +} + /** * Refuses a personal API key the caller's permission group withholds. * @@ -223,6 +285,24 @@ async function requireCapability( * own workspace scope. One copy, or the same key the funnel refuses keeps * working somewhere. */ +/** + * Both capability gates a user-held credential passes, in one call. + * + * The funnel runs these as part of its sequence, but three surfaces authorize + * themselves — billing reads, audit-log reads, and `/api/v2/meta` — and each + * has to repeat them. Repeating two separate calls is how one of them ends up + * with only the first: `cli.use` was missing from all three until this existed. + */ +export async function requireUserCredentialCapabilities( + principal: PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal, + context: WorkspaceAuthorizationContext +): Promise { + await requirePersonalApiKeysAllowed(principal.userId, context) + if (principal.kind === 'oauth_access_token') { + await requireCliAccessAllowed(principal.clientId, principal.userId, context) + } +} + export async function requirePersonalApiKeysAllowed( userId: string, context: WorkspaceAuthorizationContext @@ -325,6 +405,48 @@ export async function authorizeWorkspaceOperation { dataDrains: false, dataRetention: false, inbox: true, + oauthProvider: true, sandboxes: true, sessionPolicies: true, sso: true, diff --git a/apps/sim/lib/core/config/deployment-shape.ts b/apps/sim/lib/core/config/deployment-shape.ts index a00429c6fce..7ce34a9a897 100644 --- a/apps/sim/lib/core/config/deployment-shape.ts +++ b/apps/sim/lib/core/config/deployment-shape.ts @@ -13,6 +13,7 @@ import { isDataRetentionEnabled, isHosted, isInboxEnabled, + isOAuthProviderEnabled, isSandboxesEnabled, isSessionPoliciesEnabled, isSsoEnabled, @@ -93,6 +94,7 @@ export function resolveDeploymentShape(): DeploymentShape { dataDrains: isDataDrainsEnabled, dataRetention: isDataRetentionEnabled, inbox: isInboxEnabled, + oauthProvider: isOAuthProviderEnabled, sandboxes: isSandboxesEnabled, sessionPolicies: isSessionPoliciesEnabled, sso: isSsoEnabled, diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 9fe590a0a8a..de933a695b8 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -315,6 +315,18 @@ function enterpriseFeatureEnabled( }) } +/** + * Is Sim acting as an OAuth 2.0 authorization server: the `/oauth2/*` Better + * Auth routes, the consent page, and bearer-token acceptance on `/api/v2`. + * Explicit opt-in keeps a rolling deployment from issuing a family token on a + * new instance and refreshing it through an old instance that lacks the + * transactional family implementation. Auth-disabled deployments cannot complete Better Auth's + * session-bound authorization flow, so they use the CLI's pairing handoff + * instead. Authorized-app history remains visible while issuance is off so + * historical grants can still be revoked. + */ +export const isOAuthProviderEnabled = !isAuthDisabled && isTruthy(env.OAUTH_PROVIDER_ENABLED) + /** * Is SSO enabled for enterprise authentication */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5970bf44db1..82db943e5ab 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -621,6 +621,9 @@ export const env = createEnv({ /** Comma-separated proxy IPs/CIDRs skipped while resolving the forwarded client chain. */ AUTH_TRUSTED_PROXIES: z.string().optional(), + /** Sim's OAuth provider remains an explicit opt-in for rollout safety. */ + OAUTH_PROVIDER_ENABLED: z.boolean().optional(), + // SSO Configuration (for script-based registration) SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality USAGE_MONITORING_ENABLED: z.boolean().optional(), // Enable organization usage monitoring on self-hosted (bypasses hosted requirements) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts index 975e3b14668..e46754913fd 100644 --- a/apps/sim/lib/credentials/application/operations.test.ts +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -15,7 +15,7 @@ describe('credential operations', () => { minimumRole: 'read', minimumCredentialRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], }) expect(Object.isFrozen(credentialOperations.delete)).toBe(true) @@ -32,7 +32,7 @@ describe('credential operations', () => { minimumRole: 'read', minimumCredentialRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], }) expect(credentialOperations.update.principalKinds).not.toContain('workspace_api_key') diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index df3e52834c6..c510fee77c2 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -26,7 +26,7 @@ export function defineCredentialOperation< } const HUMAN_AND_COPILOT_PRINCIPALS = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], } as const @@ -43,14 +43,14 @@ export const credentialOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'integrations.manage', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', capability: 'integrations.manage', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), /** * `integrations.manage`, like every other credential operation — these three @@ -68,7 +68,7 @@ export const credentialOperations = { minimumRole: 'write', workspaceApiKey: 'deny', capability: 'integrations.manage', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), prepareConnection: defineWorkspaceOperation({ id: 'credentials.connections.prepare', @@ -83,7 +83,7 @@ export const credentialOperations = { minimumRole: 'write', workspaceApiKey: 'deny', capability: 'integrations.manage', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), read: defineCredentialOperation( defineWorkspaceOperation({ diff --git a/apps/sim/lib/custom-tools/application/operations.ts b/apps/sim/lib/custom-tools/application/operations.ts index 1de8b666ec4..12a49c37fd2 100644 --- a/apps/sim/lib/custom-tools/application/operations.ts +++ b/apps/sim/lib/custom-tools/application/operations.ts @@ -1,11 +1,17 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const ALL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const HUMAN_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], } as const diff --git a/apps/sim/lib/integrations/principal-scope.server.ts b/apps/sim/lib/integrations/principal-scope.server.ts index 3e239092b15..6762cc7783b 100644 --- a/apps/sim/lib/integrations/principal-scope.server.ts +++ b/apps/sim/lib/integrations/principal-scope.server.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import { isUserCredentialPrincipal, type Principal } from '@sim/auth/principal' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integration-allowlist' @@ -27,7 +27,7 @@ import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integr * one would apply a bystander's permission groups to every caller of that key. */ export function principalUserId(principal: Principal): string | undefined { - if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + if (principal.kind === 'session' || isUserCredentialPrincipal(principal)) { return principal.userId } if (principal.kind === 'delegated') return principal.subjectUserId diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 0228b0d2fd8..f3198156c64 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -2,7 +2,13 @@ import type { ApplicationOperation } from '@/lib/core/application' import { assertOperationCapability, defineWorkspaceOperation } from '@/lib/core/application' const ALL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const COPILOT_PRINCIPAL_POLICY = { @@ -11,13 +17,29 @@ const COPILOT_PRINCIPAL_POLICY = { } as const const ALL_PRINCIPAL_WITH_EXECUTOR_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot', 'executor'], } as const -const HTTP_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'workspace_api_key'] as const +const HTTP_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', +] as const -const HUMAN_AND_DELEGATED_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const +const HUMAN_AND_DELEGATED_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'delegated', +] as const const HUMAN_AND_COPILOT_PRINCIPAL_POLICY = { principalKinds: HUMAN_AND_DELEGATED_PRINCIPAL_KINDS, diff --git a/apps/sim/lib/logs/application/operations.ts b/apps/sim/lib/logs/application/operations.ts index e0b760eb506..5e9a802a303 100644 --- a/apps/sim/lib/logs/application/operations.ts +++ b/apps/sim/lib/logs/application/operations.ts @@ -1,8 +1,18 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const +const PUBLIC_API_PRINCIPAL_KINDS = [ + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', +] as const const LOG_READER_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot', 'executor'], } as const diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 4238e5c4b83..8ddaa4fd428 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -26,7 +26,7 @@ describe('MCP server operation registry', () => { it('requires a human subject for tool discovery', () => { expect(mcpServerOperations.discoverTools).toMatchObject({ workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot', 'executor'], }) }) @@ -109,6 +109,7 @@ describe('MCP server operation registry', () => { expect(operation.principalKinds, operation.id).toEqual([ 'session', 'personal_api_key', + 'oauth_access_token', 'delegated', ]) expect(operation.delegatedServices, operation.id).toEqual(['copilot']) diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index e9346e82078..ed47622b1eb 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -1,15 +1,21 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const ALL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const HUMAN_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], } as const const DISCOVERY_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot', 'executor'], } as const const EXECUTION_PRINCIPAL_POLICY = { diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index df0d117c819..f6060c6590c 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -130,9 +130,8 @@ function allowlistDenies(allowed: readonly string[] | null, member: string): boo * What each capability means in terms of the stored config. * * `satisfies` rather than an annotation, so adding a capability still fails to - * compile until it is given a rule — the same completeness gate - * `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` uses — while each entry keeps its own - * `kind`. Annotating would widen every entry to `CapabilityRule`, and + * compile until it is given a rule while each entry keeps its own `kind`. + * Annotating would widen every entry to `CapabilityRule`, and * {@link StaticPermissionGroupCapability} would then resolve to `never`, * silently rejecting every capability an operation tried to declare. */ diff --git a/apps/sim/lib/sandboxes/application/operations.test.ts b/apps/sim/lib/sandboxes/application/operations.test.ts index 45840fde188..326df943310 100644 --- a/apps/sim/lib/sandboxes/application/operations.test.ts +++ b/apps/sim/lib/sandboxes/application/operations.test.ts @@ -30,7 +30,13 @@ describe('sandbox operation registry', () => { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'sandboxes.use', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], }) } @@ -52,7 +58,7 @@ describe('sandbox operation registry', () => { minimumRole: 'admin', workspaceApiKey: 'deny', capability: 'sandboxes.use', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], }) } diff --git a/apps/sim/lib/sandboxes/application/operations.ts b/apps/sim/lib/sandboxes/application/operations.ts index 7b871e6bab8..caedf7fb1f7 100644 --- a/apps/sim/lib/sandboxes/application/operations.ts +++ b/apps/sim/lib/sandboxes/application/operations.ts @@ -1,11 +1,17 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const ALL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const HUMAN_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], } as const diff --git a/apps/sim/lib/secrets/application/operations.ts b/apps/sim/lib/secrets/application/operations.ts index b629a1014dd..494b07288bc 100644 --- a/apps/sim/lib/secrets/application/operations.ts +++ b/apps/sim/lib/secrets/application/operations.ts @@ -1,6 +1,6 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const HUMAN_API_PRINCIPAL_KINDS = ['session', 'personal_api_key'] as const +const HUMAN_API_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'oauth_access_token'] as const export const secretOperations = { list: defineWorkspaceOperation({ diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index f4150e16c87..9fc1ed1f7a8 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -44,7 +44,7 @@ async function resolveWorkspaceContext(workspaceId: string): Promise + principal: Extract ): string { return principal.userId } diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index a9152130d80..eeb390247e3 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -37,7 +37,7 @@ describe('skill operation registry', () => { expect(skillOperations.create).toMatchObject({ minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], }) }) @@ -77,7 +77,7 @@ describe('skill operation registry', () => { /** `read`, not `write` — the editor row is the authority, not the role. */ minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], }) } }) @@ -102,13 +102,13 @@ describe('skill operation registry', () => { expect(skillOperations.listEditors).toMatchObject({ minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }) for (const operation of [skillOperations.grantEditor, skillOperations.revokeEditor]) { expect(operation).toMatchObject({ minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }) } }) diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index 06cb71eb7fd..70f49364de7 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -1,18 +1,24 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const ALL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const HUMAN_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], } as const const HTTP_SKILL_EDITOR_READ_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], } as const const HUMAN_HTTP_SKILL_EDITOR_POLICY = { - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], } as const /** diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 2f9aaa110aa..e8355131b67 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -2,7 +2,13 @@ import { defineWorkspaceOperation } from '@/lib/core/application' import type { OperationDeclarableCapability } from '@/lib/core/application/operation' const ALL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const COPILOT_PRINCIPAL_POLICY = { @@ -11,12 +17,24 @@ const COPILOT_PRINCIPAL_POLICY = { } as const const ALL_TABLE_TOOL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot', 'executor'], } as const const INTERNAL_EXECUTOR_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['executor'], } as const diff --git a/apps/sim/lib/tool-execution/application/operations.ts b/apps/sim/lib/tool-execution/application/operations.ts index e657c89abaa..c4cb144aa09 100644 --- a/apps/sim/lib/tool-execution/application/operations.ts +++ b/apps/sim/lib/tool-execution/application/operations.ts @@ -33,7 +33,7 @@ export const toolExecutionOperations = { id: 'tools.execute', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], capability: 'none', }), } as const diff --git a/apps/sim/lib/uploads/upload-session/application.ts b/apps/sim/lib/uploads/upload-session/application.ts index 9fb85149417..61929b1dd77 100644 --- a/apps/sim/lib/uploads/upload-session/application.ts +++ b/apps/sim/lib/uploads/upload-session/application.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import { isUserCredentialPrincipal, type Principal } from '@sim/auth/principal' import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -384,7 +384,7 @@ export const abortWorkspaceFileUploadOperation = { } as const function principalUserId(principal: Principal): string { - if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + if (principal.kind === 'session' || isUserCredentialPrincipal(principal)) { return principal.userId } throw new Error('Workspace upload attribution must be resolved from the current workspace owner') diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index d7746abdfaf..558b1fca924 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -224,6 +224,33 @@ describe('upload sessions', () => { } ) + it('keeps an OAuth upload bound across access-token rotation for the same client', () => { + const original: Principal = { + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['api:write'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + } + const session = sessionRecord({ + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + metadata: { authBinding: createUploadSessionAuthBinding(original, WORKSPACE_ID) }, + }) + const rotated: Principal = { ...original, tokenId: 'token-2' } + const otherClient: Principal = { ...original, clientId: 'other-client', tokenId: 'token-3' } + const otherUser: Principal = { ...original, userId: 'user-2', tokenId: 'token-4' } + + expect(() => assertUploadSessionAuthBinding(session, rotated)).not.toThrow() + expect(() => assertUploadSessionAuthBinding(session, otherClient)).toThrow( + 'Upload session not found' + ) + expect(() => assertUploadSessionAuthBinding(session, otherUser)).toThrow( + 'Upload session not found' + ) + }) + it('allocates distinct keys for same-named execution attachments', async () => { dbChainMockFns.returning .mockResolvedValueOnce([ diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index a766af1c680..ad290bce5c4 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,5 +1,6 @@ import { type BoundWorkflowExecutionDelegatedPrincipal, + isUserCredentialPrincipal, type Principal, requirePrincipalSubjectUserId, } from '@sim/auth/principal' @@ -117,6 +118,7 @@ export interface UploadSessionAuthBinding { principal: | { kind: 'session'; userId: string; sessionId: string } | { kind: 'personal_api_key'; userId: string; keyId: string } + | { kind: 'oauth_access_token'; userId: string; clientId: string } | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } | { kind: 'delegated' @@ -436,6 +438,12 @@ export function createUploadSessionAuthBinding( workspaceId, principal: { kind: principal.kind, userId: principal.userId, keyId: principal.keyId }, } + case 'oauth_access_token': + return { + version: 1, + workspaceId, + principal: { kind: principal.kind, userId: principal.userId, clientId: principal.clientId }, + } case 'workspace_api_key': if (principal.workspaceId !== workspaceId) { throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') @@ -504,16 +512,20 @@ export function assertUploadSessionAuthBinding( ? principal.kind === 'personal_api_key' && bound.userId === principal.userId && bound.keyId === principal.keyId - : bound.kind === 'workspace_api_key' - ? principal.kind === 'workspace_api_key' && - bound.workspaceId === principal.workspaceId && - bound.keyId === principal.keyId - : isExecutorWorkflowExecutionPrincipal(principal) && - principal.workspaceId === session.workspaceId && - principal.subjectUserId === bound.subjectUserId && - principal.audience === bound.audience && - principal.delegationContext.workflowId === bound.workflowId && - principal.delegationContext.executionId === bound.executionId) + : bound.kind === 'oauth_access_token' + ? principal.kind === 'oauth_access_token' && + bound.userId === principal.userId && + bound.clientId === principal.clientId + : bound.kind === 'workspace_api_key' + ? principal.kind === 'workspace_api_key' && + bound.workspaceId === principal.workspaceId && + bound.keyId === principal.keyId + : isExecutorWorkflowExecutionPrincipal(principal) && + principal.workspaceId === session.workspaceId && + principal.subjectUserId === bound.subjectUserId && + principal.audience === bound.audience && + principal.delegationContext.workflowId === bound.workflowId && + principal.delegationContext.executionId === bound.executionId) if (!matches) throw uploadNotFound() } @@ -528,7 +540,7 @@ function assertLegacyUploadSessionOwner(session: UploadSessionRecord, principal: const matches = principal.kind === 'workspace_api_key' ? principal.workspaceId === session.workspaceId - : (principal.kind === 'session' || principal.kind === 'personal_api_key') && + : (principal.kind === 'session' || isUserCredentialPrincipal(principal)) && principal.userId === session.userId if (!matches) throw uploadNotFound() } @@ -1288,6 +1300,9 @@ function isUploadSessionAuthBinding(value: unknown): value is UploadSessionAuthB if (principal.kind === 'personal_api_key') { return typeof principal.userId === 'string' && typeof principal.keyId === 'string' } + if (principal.kind === 'oauth_access_token') { + return typeof principal.userId === 'string' && typeof principal.clientId === 'string' + } if (principal.kind === 'delegated') { return ( principal.serviceId === 'executor' && diff --git a/apps/sim/lib/users/application/authorized-apps.test.ts b/apps/sim/lib/users/application/authorized-apps.test.ts new file mode 100644 index 00000000000..27a655d4828 --- /dev/null +++ b/apps/sim/lib/users/application/authorized-apps.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import type { PersonalApiKeyPrincipal, SessionPrincipal } from '@sim/auth/principal' +import { schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + transaction: vi.fn(), + select: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mocks.recordAudit, + AuditAction: { OAUTH_APP_REVOKED: 'oauth_app.revoked' }, + AuditResourceType: { OAUTH_CLIENT: 'oauth_client' }, +})) + +vi.mock('@sim/db/schema', () => schemaMock) + +vi.mock('@sim/db', () => ({ + db: { + transaction: mocks.transaction, + select: mocks.select, + }, +})) + +import { ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + listAuthorizedAppsUseCase, + revokeAuthorizedAppUseCase, +} from '@/lib/users/application/authorized-apps' + +const session: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } +const personalKey: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', +} + +/** A drizzle select chain that answers `rows` whenever it is finally awaited. */ +function selectChain(rows: unknown[]) { + const chain: Record = {} + for (const method of ['from', 'innerJoin', 'where', 'orderBy', 'limit']) { + chain[method] = vi.fn(() => chain) + } + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve) + return chain +} + +describe('authorized apps', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('refuses a principal that is not the account holder in session', async () => { + await expect( + listAuthorizedAppsUseCase.execute({ principal: personalKey, input: {} }) + ).rejects.toBeInstanceOf(ForbiddenOperationError) + await expect( + revokeAuthorizedAppUseCase.execute({ + principal: personalKey, + input: { clientId: 'sim-cli' }, + }) + ).rejects.toBeInstanceOf(ForbiddenOperationError) + expect(mocks.transaction).not.toHaveBeenCalled() + }) + + it('presents each grant by the client name, falling back to its id', async () => { + mocks.select.mockReturnValue( + selectChain([ + { + clientId: 'sim-cli', + name: 'Sim CLI', + scopes: ['openid', 'api:write'], + authorizedAt: new Date('2026-09-01T00:00:00.000Z'), + }, + { + clientId: 'partner-app', + name: null, + scopes: ['openid'], + authorizedAt: new Date('2026-08-01T00:00:00.000Z'), + }, + ]) + ) + + await expect( + listAuthorizedAppsUseCase.execute({ principal: session, input: {} }) + ).resolves.toEqual([ + { + clientId: 'sim-cli', + name: 'Sim CLI', + scopes: ['openid', 'api:write'], + authorizedAt: '2026-09-01T00:00:00.000Z', + }, + { + clientId: 'partner-app', + name: 'partner-app', + scopes: ['openid'], + authorizedAt: '2026-08-01T00:00:00.000Z', + }, + ]) + }) + + it('removes the consent and both token kinds in one transaction, and records the audit', async () => { + const deleted: unknown[] = [] + const tx = { + select: () => selectChain([{ id: 'consent-1', name: 'Sim CLI' }]), + delete: (table: unknown) => ({ where: (clause: unknown) => deleted.push([table, clause]) }), + } + mocks.transaction.mockImplementation(async (run: (t: unknown) => unknown) => run(tx)) + + await expect( + revokeAuthorizedAppUseCase.execute({ principal: session, input: { clientId: 'sim-cli' } }) + ).resolves.toEqual({ clientId: 'sim-cli', name: 'Sim CLI' }) + + /** + * The tables are asserted, not just the call counts: swapping the access + * and refresh token tables leaves the counts identical while deleting the + * rows whose revocation is what makes a replayed token detectable. + */ + expect(deleted.map(([table]) => table)).toEqual([ + schemaMock.oauthConsent, + schemaMock.oauthAccessToken, + ]) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + action: 'oauth_app.revoked', + resourceId: 'sim-cli', + resourceName: 'Sim CLI', + }) + ) + }) + + it('reports a grant this account does not hold as not found, changing nothing', async () => { + const tx = { + select: () => selectChain([]), + delete: () => { + throw new Error('must not delete') + }, + update: () => { + throw new Error('must not update') + }, + } + mocks.transaction.mockImplementation(async (run: (t: unknown) => unknown) => run(tx)) + + const failure = await revokeAuthorizedAppUseCase + .execute({ principal: session, input: { clientId: 'someone-elses' } }) + .catch((error) => error) + + expect(failure).toBeInstanceOf(OrchestrationError) + expect(failure.code).toBe('not_found') + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/users/application/authorized-apps.ts b/apps/sim/lib/users/application/authorized-apps.ts new file mode 100644 index 00000000000..2fea72e0caa --- /dev/null +++ b/apps/sim/lib/users/application/authorized-apps.ts @@ -0,0 +1,103 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { oauthAccessToken, oauthClient, oauthConsent } from '@sim/db/schema' +import { and, desc, eq } from 'drizzle-orm' +import type { AuthorizedApp } from '@/lib/api/contracts/user' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireUserAccountPrincipal } from '@/lib/users/application/authorization' +import { userAccountOperations } from '@/lib/users/application/operations' + +/** + * The OAuth clients this account has consented to, newest grant first. + * + * A consent row is the grant, so it is the whole answer: it survives every + * token the client has rotated through, and its age is what a person weighs + * when deciding whether an app should still have access. + */ +export const listAuthorizedAppsUseCase: OperationUseCase< + typeof userAccountOperations.readAuthorizedApps, + Record, + AuthorizedApp[] +> = { + operation: userAccountOperations.readAuthorizedApps, + async execute({ principal }) { + requireUserAccountPrincipal(principal, userAccountOperations.readAuthorizedApps) + + const rows = await db + .select({ + clientId: oauthConsent.clientId, + name: oauthClient.name, + scopes: oauthConsent.scopes, + authorizedAt: oauthConsent.createdAt, + }) + .from(oauthConsent) + .innerJoin(oauthClient, eq(oauthConsent.clientId, oauthClient.clientId)) + .where(eq(oauthConsent.userId, principal.userId)) + .orderBy(desc(oauthConsent.createdAt)) + + return rows.map((row) => ({ + clientId: row.clientId, + name: row.name ?? row.clientId, + scopes: row.scopes, + authorizedAt: row.authorizedAt.toISOString(), + })) + }, +} + +export interface RevokeAuthorizedAppInput { + clientId: string +} + +/** + * Withdraws an app's access to the account in one transaction: the consent + * (so the next authorize asks again), every live refresh token (so the app + * cannot mint another access token), and every access token (so the ones it + * holds stop working on the next request). The plugin's own delete-consent + * endpoint removes only the first, which is why this lives here. + */ +export const revokeAuthorizedAppUseCase: OperationUseCase< + typeof userAccountOperations.revokeAuthorizedApp, + RevokeAuthorizedAppInput, + { clientId: string; name: string } +> = { + operation: userAccountOperations.revokeAuthorizedApp, + async execute({ principal, input }) { + requireUserAccountPrincipal(principal, userAccountOperations.revokeAuthorizedApp) + const userId = principal.userId + const clientId = input.clientId + + const revoked = await db.transaction(async (tx) => { + const [consent] = await tx + .select({ id: oauthConsent.id, name: oauthClient.name }) + .from(oauthConsent) + .innerJoin(oauthClient, eq(oauthConsent.clientId, oauthClient.clientId)) + .where(and(eq(oauthConsent.userId, userId), eq(oauthConsent.clientId, clientId))) + .limit(1) + if (!consent) return null + + await tx + .delete(oauthConsent) + .where(and(eq(oauthConsent.userId, userId), eq(oauthConsent.clientId, clientId))) + await tx + .delete(oauthAccessToken) + .where(and(eq(oauthAccessToken.userId, userId), eq(oauthAccessToken.clientId, clientId))) + + return { clientId, name: consent.name ?? clientId } + }) + + if (!revoked) throw new OrchestrationError('not_found', 'Authorized app not found') + + recordAudit({ + workspaceId: null, + actorId: userId, + action: AuditAction.OAUTH_APP_REVOKED, + resourceType: AuditResourceType.OAUTH_CLIENT, + resourceId: revoked.clientId, + resourceName: revoked.name, + description: `Revoked ${revoked.name}'s access to the account`, + }) + + return revoked + }, +} diff --git a/apps/sim/lib/users/application/operations.ts b/apps/sim/lib/users/application/operations.ts index 9277501e969..41fe02136b6 100644 --- a/apps/sim/lib/users/application/operations.ts +++ b/apps/sim/lib/users/application/operations.ts @@ -40,4 +40,14 @@ export const userAccountOperations = { }), // permission-group-exempt: deleting your own account is not a workspace act, so no group key names it delete: defineUserAccountOperation({ id: 'users.account.delete', capability: 'none' }), + // permission-group-exempt: the apps an account has authorized belong to the account, not to any workspace a group governs + readAuthorizedApps: defineUserAccountOperation({ + id: 'users.account.authorized_apps.read', + capability: 'none', + }), + // permission-group-exempt: revoking an app's access to your own account is not a workspace act, so no group key names it + revokeAuthorizedApp: defineUserAccountOperation({ + id: 'users.account.authorized_apps.revoke', + capability: 'none', + }), } as const satisfies Record diff --git a/apps/sim/lib/workflows/application/operations.test.ts b/apps/sim/lib/workflows/application/operations.test.ts index b978a3e3bcd..a2bfeaec7a6 100644 --- a/apps/sim/lib/workflows/application/operations.test.ts +++ b/apps/sim/lib/workflows/application/operations.test.ts @@ -39,7 +39,13 @@ describe('workflow operation registry', () => { id: 'workflows.variables.apply_operations', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], }) expect(Object.isFrozen(workflowOperations.applyVariableOperations)).toBe(true) @@ -50,7 +56,13 @@ describe('workflow operation registry', () => { id: 'workflows.bulk.move', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], }) expect(Object.isFrozen(workflowOperations.moveBulk)).toBe(true) @@ -65,7 +77,7 @@ describe('workflow operation registry', () => { expect(operation).toMatchObject({ minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot', 'executor'], }) } @@ -74,7 +86,13 @@ describe('workflow operation registry', () => { expect(operation).toMatchObject({ minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot', 'executor'], }) } @@ -95,7 +113,7 @@ describe('workflow operation registry', () => { id: 'workflows.public_api.update', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }) expect(workflowOperations.updatePublicApi.principalKinds).not.toContain('workspace_api_key') expect(workflowOperations.updatePublicApi.principalKinds).not.toContain('delegated') @@ -115,7 +133,7 @@ describe('workflow operation registry', () => { id: 'workflows.operations.apply', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], }) expect(workflowOperations.applyOperations.principalKinds).not.toContain('workspace_api_key') @@ -129,7 +147,7 @@ describe('workflow operation registry', () => { expect(operation).toMatchObject({ minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token'], }) expect(operation.id).toMatch(/^workflows\.manual\.execute/) } @@ -140,7 +158,13 @@ describe('workflow operation registry', () => { id: 'workflows.paused_executions.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], }) }) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 3d83968be1b..fdfe5c82451 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -1,22 +1,34 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const ALL_WORKFLOW_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const WORKFLOW_READ_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot', 'executor'], } as const const HUMAN_WORKFLOW_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], } as const const WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot', 'executor'], } as const @@ -335,7 +347,7 @@ export const workflowOperations = { minimumRole: 'admin', workspaceApiKey: 'deny', capability: 'none', - principalKinds: ['session', 'personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', @@ -414,7 +426,7 @@ export const workflowOperations = { minimumRole: 'write', workspaceApiKey: 'deny', capability: 'none', - principalKinds: ['personal_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token'], }), // permission-group-exempt: a manual run is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation executeManualFromBlock: defineWorkspaceOperation({ @@ -422,7 +434,7 @@ export const workflowOperations = { minimumRole: 'write', workspaceApiKey: 'deny', capability: 'none', - principalKinds: ['personal_api_key'], + principalKinds: ['personal_api_key', 'oauth_access_token'], }), // permission-group-exempt: execution history is governed by workspace role; logs.cost and logs.trace_spans withhold fields inside a run, not the right to read one listRuns: defineWorkspaceOperation({ diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index f3462161392..b6b4732e618 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -1442,6 +1442,14 @@ function parseOptionalPrincipalActor(value: unknown): PrincipalActor | undefined userId: parseRequiredString(record.userId, 'actor.userId'), } } + if (kind === 'oauth_access_token') { + return { + kind, + tokenId: parseRequiredString(record.tokenId, 'actor.tokenId'), + clientId: parseRequiredString(record.clientId, 'actor.clientId'), + userId: parseRequiredString(record.userId, 'actor.userId'), + } + } if (kind === 'workspace_api_key') { return { kind, diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index f2b8104d777..6c45f034445 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -65,6 +65,7 @@ describe('file operation registry', () => { expect(fileOperations.updateShare.principalKinds).toEqual([ 'session', 'personal_api_key', + 'oauth_access_token', 'delegated', ]) expect(fileOperations.updateShare.delegatedServices).toEqual(['copilot', 'executor']) @@ -77,7 +78,12 @@ describe('file operation registry', () => { fileOperations.uploadComplete, fileOperations.uploadCancel, ]) { - expect(operation.principalKinds).toEqual(['session', 'personal_api_key', 'workspace_api_key']) + expect(operation.principalKinds).toEqual([ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + ]) expect(operation.delegatedServices).toBeUndefined() } }) @@ -93,7 +99,7 @@ describe('file operation registry', () => { id: 'files.extract_archive', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }) expect(fileOperations.extractArchive.principalKinds).not.toContain('delegated') expect(fileOperations.extractArchive.delegatedServices).toBeUndefined() diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index bcd73e62010..7338dda67dd 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -1,19 +1,31 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const ALL_COPILOT_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot'], } as const const ALL_FILE_TOOL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + principalKinds: [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', + 'delegated', + ], delegatedServices: ['copilot', 'executor'], } as const const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot', 'executor'], } as const const UPLOAD_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], } as const export const fileOperations = { @@ -90,7 +102,7 @@ export const fileOperations = { minimumRole: 'write', workspaceApiKey: 'allow', capability: 'files.use', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), updateContent: defineWorkspaceOperation({ id: 'files.update_content', diff --git a/apps/sim/lib/workspaces/application/list-public-workspaces.test.ts b/apps/sim/lib/workspaces/application/list-public-workspaces.test.ts index 879d2d78803..b49cd6ea3b7 100644 --- a/apps/sim/lib/workspaces/application/list-public-workspaces.test.ts +++ b/apps/sim/lib/workspaces/application/list-public-workspaces.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -20,12 +21,15 @@ vi.mock('@/lib/workspaces/public-queries', () => ({ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.loadContext, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { listPublicWorkspaces } from '@/lib/workspaces/application/list-public-workspaces' const workspace = (id: string, name: string, allowPersonalApiKeys: boolean, day: number) => ({ id, name, + organizationId: 'org-1', 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`), @@ -34,6 +38,7 @@ const workspace = (id: string, name: string, allowPersonalApiKeys: boolean, day: describe('listPublicWorkspaces', () => { beforeEach(() => { vi.clearAllMocks() + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) mocks.getDetail.mockImplementation(async (id: string) => ({ id, name: id, @@ -119,6 +124,70 @@ describe('listPublicWorkspaces', () => { expect(mocks.getDetails).toHaveBeenCalledWith(['workspace-c', 'workspace-b']) }) + it('filters workspace-specific personal-credential restrictions before pagination', async () => { + mocks.listAccessible.mockResolvedValue([ + { + workspace: workspace('workspace-a', 'Alpha', true, 1), + permissionType: 'read', + viaOrgAdmin: false, + }, + { + workspace: workspace('workspace-b', 'Beta', true, 2), + permissionType: 'read', + viaOrgAdmin: false, + }, + ]) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockImplementation( + async (_userId: string, workspaceId: string) => + workspaceId === 'workspace-a' + ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, disablePersonalApiKeys: true } + : DEFAULT_PERMISSION_GROUP_CONFIG + ) + + 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-b']) + expect(result.hasMore).toBe(false) + }) + + it('filters the Sim CLI by each workspace cli capability', async () => { + mocks.listAccessible.mockResolvedValue([ + { + workspace: workspace('workspace-a', 'Alpha', true, 1), + permissionType: 'read', + viaOrgAdmin: false, + }, + { + workspace: workspace('workspace-b', 'Beta', true, 2), + permissionType: 'read', + viaOrgAdmin: false, + }, + ]) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockImplementation( + async (_userId: string, workspaceId: string) => + workspaceId === 'workspace-a' + ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, disableCliAccess: true } + : DEFAULT_PERMISSION_GROUP_CONFIG + ) + + const result = await listPublicWorkspaces.execute({ + principal: { + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['api:read'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }, + input: { sortBy: 'name', sortOrder: 'asc', limit: 10, offset: 0 }, + }) + + expect(result.workspaces.map(({ id }) => id)).toEqual(['workspace-b']) + }) + it('fails when an accessible workspace disappears during batch hydration', async () => { mocks.listAccessible.mockResolvedValue([ { @@ -131,7 +200,11 @@ describe('listPublicWorkspaces', () => { await expect( listPublicWorkspaces.execute({ - principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + principal: { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + }, input: { sortBy: 'name', sortOrder: 'asc', limit: 10, offset: 0 }, }) ).rejects.toThrow('Accessible workspace workspace-a disappeared during listing') diff --git a/apps/sim/lib/workspaces/application/list-public-workspaces.ts b/apps/sim/lib/workspaces/application/list-public-workspaces.ts index 91e906f75b0..b8ecd7e2962 100644 --- a/apps/sim/lib/workspaces/application/list-public-workspaces.ts +++ b/apps/sim/lib/workspaces/application/list-public-workspaces.ts @@ -1,10 +1,13 @@ import type { ListSortOrder } from '@/lib/api/list-query' +import { SIM_CLI_CLIENT_ID } from '@/lib/auth/oauth-provider' import { authorizeWorkspaceOperation, type OperationUseCase, requireAllowedWorkspacePrincipal, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { workspaceOperations } from '@/lib/workspaces/application/operations' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' import { @@ -32,6 +35,8 @@ type WorkspaceRow = Awaited< ReturnType >[number]['workspace'] +const WORKSPACE_CAPABILITY_CONCURRENCY = 8 + function compareWorkspaceRows( left: WorkspaceRow, right: WorkspaceRow, @@ -75,9 +80,34 @@ export const listPublicWorkspaces: OperationUseCase< } const accessible = await listAccessibleWorkspaceRowsForUser(principal.userId, 'active') - const sorted = accessible - .filter(({ workspace }) => workspace.allowPersonalApiKeys) - .map(({ workspace }) => workspace) + const candidates = accessible.filter(({ workspace }) => workspace.allowPersonalApiKeys) + const governed = await mapWithConcurrency( + candidates, + WORKSPACE_CAPABILITY_CONCURRENCY, + async ({ workspace }) => { + const personalCredentialsWithheld = await isWorkspaceCapabilityWithheld( + principal.userId, + workspace.id, + 'personal_api_key.use', + workspace.organizationId + ) + if (personalCredentialsWithheld) return null + + if (principal.kind !== 'oauth_access_token' || principal.clientId !== SIM_CLI_CLIENT_ID) { + return workspace + } + + const cliWithheld = await isWorkspaceCapabilityWithheld( + principal.userId, + workspace.id, + 'cli.use', + workspace.organizationId + ) + return cliWithheld ? null : workspace + } + ) + const sorted = governed + .filter((workspace): workspace is WorkspaceRow => workspace !== null) .sort((left, right) => compareWorkspaceRows(left, right, input.sortBy, input.sortOrder)) const page = sorted.slice(input.offset, input.offset + input.limit) const details = await getPublicWorkspaceDetails(page.map(({ id }) => id)) diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts index b1ee6303784..df802f931ab 100644 --- a/apps/sim/lib/workspaces/application/operations.ts +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -1,6 +1,10 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const +const PUBLIC_API_PRINCIPAL_KINDS = [ + 'personal_api_key', + 'oauth_access_token', + 'workspace_api_key', +] as const export const workspaceOperations = { // permission-group-exempt: the public API's own view of the workspaces a key can reach; it answers what that credential already proves, and `disablePublicApi` governs whether the key reaches the surface at all diff --git a/apps/sim/package.json b/apps/sim/package.json index 9125db409ee..87f130fb5bb 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -63,6 +63,7 @@ "@aws-sdk/s3-request-presigner": "3.1117.0", "@azure/communication-email": "1.1.0", "@azure/storage-blob": "12.27.0", + "@better-auth/oauth-provider": "1.6.27", "@better-auth/sso": "1.6.27", "@better-auth/stripe": "1.6.27", "@browserbasehq/stagehand": "^3.2.1", diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index d5ce595f8a9..3a85a478257 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -12,7 +12,7 @@ vi.mock('@/lib/core/config/env', () => import { resolveApiCorsPolicy } from '@/proxy' const EXPOSED_HEADERS = - 'Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' + 'Retry-After, WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' function makeRequest(pathname: string, origin?: string): NextRequest { return { @@ -34,6 +34,32 @@ describe('resolveApiCorsPolicy', () => { }) }) + it('serves OAuth discovery documents read-only with wildcard origin', () => { + expect( + resolveApiCorsPolicy(makeRequest('/api/auth/.well-known/oauth-authorization-server')) + ).toEqual({ + origin: '*', + credentials: false, + methods: 'GET, OPTIONS', + headers: 'Content-Type, Accept', + exposeHeaders: EXPOSED_HEADERS, + }) + }) + + /** + * `proxy()` consults this table only for `/api/` paths, so a rule matching + * the origin-root discovery document would never run. That copy sets its own + * `Access-Control-Allow-Origin` in the route handler instead; a rule here + * would read as coverage it does not have. + */ + it('leaves the origin-root discovery document to its own route handler', () => { + const rootPolicy = resolveApiCorsPolicy(makeRequest('/.well-known/oauth-authorization-server')) + const apiPolicy = resolveApiCorsPolicy( + makeRequest('/api/auth/.well-known/oauth-authorization-server') + ) + expect(rootPolicy).not.toEqual(apiPolicy) + }) + it('serves MCP copilot with DELETE in allowed methods', () => { const policy = resolveApiCorsPolicy(makeRequest('/api/mcp/copilot')) expect(policy.origin).toBe('*') @@ -104,6 +130,7 @@ describe('resolveApiCorsPolicy', () => { expect(policy.credentials).toBe(false) expect(policy.headers).toContain('X-Run-Id') expect(policy.headers).toContain('X-Sim-Stream-Protocol') + expect(policy.headers).toContain('Authorization') expect(policy.headers).not.toContain('X-Execution-Id') // Async is body-selected on v2 — the mode header is deliberately absent. expect(policy.headers).not.toContain('X-Execution-Mode') @@ -133,8 +160,7 @@ describe('resolveApiCorsPolicy', () => { origin: 'https://app.sim.test', credentials: true, methods: 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS', - exposeHeaders: - 'Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id', + exposeHeaders: EXPOSED_HEADERS, headers: expect.stringContaining('Authorization'), }) }) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 8d4a0ecb2e3..49c740d800a 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -45,17 +45,17 @@ const DEFAULT_API_ALLOWED_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS' * to miss. */ const DEFAULT_API_EXPOSED_HEADERS = - 'Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' + 'Retry-After, WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' const DEFAULT_API_ALLOWED_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization' const WORKFLOW_EXECUTE_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Execution-Id, X-Execution-Mode, X-Execution-Timeout-Seconds' + 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, X-Execution-Id, X-Execution-Mode, X-Execution-Timeout-Seconds' /** v2 execute: run identity and modes use the v2 wire names while streaming negotiates its protocol. */ const WORKFLOW_EXECUTE_V2_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Run-Id, X-Sim-Stream-Protocol' + 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, X-Run-Id, X-Sim-Stream-Protocol' /** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */ const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate']) @@ -86,6 +86,15 @@ const CORS_RULES: readonly CorsRule[] = [ headers: 'Content-Type, Authorization, Accept', }), }, + { + match: (p) => p.startsWith('/api/auth/.well-known/'), + policy: () => ({ + origin: '*', + credentials: false, + methods: 'GET, OPTIONS', + headers: 'Content-Type, Accept', + }), + }, { match: (p) => p === '/api/mcp/copilot', policy: () => ({ diff --git a/apps/sim/scripts/create-oauth-client.test.ts b/apps/sim/scripts/create-oauth-client.test.ts new file mode 100644 index 00000000000..598a44ae90c --- /dev/null +++ b/apps/sim/scripts/create-oauth-client.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + assertRedirectUri, + assertTerminalSafe, + parseList, + parseOptionalBoolean, +} from '@/scripts/create-oauth-client' + +describe('create OAuth client input validation', () => { + it('parses comma-separated values without retaining blanks', () => { + expect(parseList(' api:read, ,offline_access ', [])).toEqual(['api:read', 'offline_access']) + }) + + it('accepts only explicit boolean values', () => { + expect(parseOptionalBoolean('OAUTH_CLIENT_PUBLIC', undefined)).toBe(false) + expect(parseOptionalBoolean('OAUTH_CLIENT_PUBLIC', 'TRUE')).toBe(true) + expect(parseOptionalBoolean('OAUTH_CLIENT_PUBLIC', 'false')).toBe(false) + expect(() => parseOptionalBoolean('OAUTH_CLIENT_PUBLIC', 'yes')).toThrow( + 'OAUTH_CLIENT_PUBLIC must be true or false' + ) + }) + + it.each(['https://app.example/callback', 'http://127.0.0.1/callback', 'http://[::1]/callback'])( + 'accepts a secure or loopback redirect URI: %s', + (uri) => { + expect(() => assertRedirectUri(uri)).not.toThrow() + } + ) + + it.each(['http://app.example/callback', 'https://app.example/callback#fragment', 'not a URL'])( + 'rejects an unsafe redirect URI: %s', + (uri) => { + expect(() => assertRedirectUri(uri)).toThrow() + } + ) + + it('rejects terminal control characters before values can be printed', () => { + expect(() => assertTerminalSafe('OAUTH_CLIENT_NAME', 'trusted\u001b[2J')).toThrow( + 'OAUTH_CLIENT_NAME cannot contain control characters' + ) + }) +}) diff --git a/apps/sim/scripts/create-oauth-client.ts b/apps/sim/scripts/create-oauth-client.ts new file mode 100644 index 00000000000..0ae26f6a916 --- /dev/null +++ b/apps/sim/scripts/create-oauth-client.ts @@ -0,0 +1,195 @@ +#!/usr/bin/env bun + +/** + * Registers an OAuth client with Sim's authorization server by writing the + * `oauth_client` row directly, the way `register-sso-provider.ts` registers + * an SSO provider. Better Auth's own `adminCreateOAuthClient` endpoint needs a + * signed-in session, and dynamic registration is deliberately switched off, + * so an operator creates clients here. + * + * Usage: bun run apps/sim/scripts/create-oauth-client.ts + * + * Required environment variables: + * DATABASE_URL + * BETTER_AUTH_SECRET The deployment's auth secret; the stored secret is encrypted under it + * OAUTH_CLIENT_ID=my-app Stable identifier the app sends as client_id + * OAUTH_CLIENT_NAME="My App" Shown on the consent page + * OAUTH_REDIRECT_URIS=https://my-app.example/callback,https://… Comma-separated. Exact match, except a loopback-IP URI, which matches any port (RFC 8252 §7.3) — register it without one + * + * Optional: + * OAUTH_CLIENT_PUBLIC=true A native/CLI app that cannot keep a secret (PKCE only, no secret issued) + * OAUTH_CLIENT_URI=https://… Homepage stored in the client's metadata + * OAUTH_CLIENT_LOGO_URI=https://… Logo stored in the client's metadata + * + * Required for least privilege: + * OAUTH_SCOPES=api:read Comma-separated scopes the client may request + * + * A confidential client's secret is generated here, encrypted the way the + * provider stores it, and printed exactly once. + * + * Better Auth requires reversibly encrypted client secrets whenever its JWT + * plugin is disabled. `symmetricEncrypt` under `BETTER_AUTH_SECRET` matches + * the provider's own client creation path, so token-endpoint authentication + * can read and compare the secret. + */ + +import { randomBytes } from 'node:crypto' +import { oauthClient } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { symmetricEncrypt } from 'better-auth/crypto' +import { eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { OAUTH_SCOPES } from '@/lib/auth/oauth-provider' + +/** + * Read from the provider's own list rather than copied, and every requested + * scope is checked against it: the authorize endpoint validates against the + * client's row, so a scope the provider never declared would be granted and + * then mean nothing to any check that reads it. + */ +const DEFAULT_SCOPES: readonly string[] = OAUTH_SCOPES + +function requireEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) throw new Error(`${name} is required`) + return value +} + +export function parseList(value: string | undefined, fallback: string[]): string[] { + const items = (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + return items.length > 0 ? items : fallback +} + +export function parseOptionalBoolean(name: string, input: string | undefined): boolean { + const value = input?.trim().toLowerCase() + if (!value) return false + if (value === 'true') return true + if (value === 'false') return false + throw new Error(`${name} must be true or false`) +} + +export function assertTerminalSafe(name: string, value: string): void { + if (/[\u0000-\u001f\u007f-\u009f]/u.test(value)) { + throw new Error(`${name} cannot contain control characters`) + } +} + +export function assertRedirectUri(uri: string): void { + assertTerminalSafe('Redirect URI', uri) + let parsed: URL + try { + parsed = new URL(uri) + } catch { + throw new Error(`Invalid redirect URI: ${uri}`) + } + const loopback = parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]' + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) { + throw new Error(`Redirect URI must be https, or http on a loopback address: ${uri}`) + } + if (parsed.hash) throw new Error(`Redirect URI cannot carry a fragment: ${uri}`) +} + +export async function main(): Promise { + const clientId = requireEnv('OAUTH_CLIENT_ID') + const name = requireEnv('OAUTH_CLIENT_NAME') + assertTerminalSafe('OAUTH_CLIENT_ID', clientId) + assertTerminalSafe('OAUTH_CLIENT_NAME', name) + const redirectUris = parseList(process.env.OAUTH_REDIRECT_URIS, []) + if (redirectUris.length === 0) throw new Error('OAUTH_REDIRECT_URIS is required') + for (const uri of redirectUris) assertRedirectUri(uri) + + const isPublic = parseOptionalBoolean('OAUTH_CLIENT_PUBLIC', process.env.OAUTH_CLIENT_PUBLIC) + const scopes = parseList(process.env.OAUTH_SCOPES, []) + if (scopes.length === 0) throw new Error('OAUTH_SCOPES is required') + for (const scope of scopes) assertTerminalSafe('OAuth scope', scope) + const unknownScopes = scopes.filter((scope) => !DEFAULT_SCOPES.includes(scope)) + if (unknownScopes.length > 0) { + throw new Error( + `Unknown scope(s): ${unknownScopes.join(', ')}. Sim's provider declares: ${DEFAULT_SCOPES.join(', ')}` + ) + } + const secret = isPublic ? null : randomBytes(32).toString('base64url') + const storedSecret = secret + ? await symmetricEncrypt({ key: requireEnv('BETTER_AUTH_SECRET'), data: secret }) + : null + + const postgresClient = postgres(requireEnv('DATABASE_URL'), { + prepare: false, + idle_timeout: 20, + connect_timeout: 30, + max: 2, + onnotice: () => {}, + }) + const db = drizzle(postgresClient) + + try { + const [existing] = await db + .select({ id: oauthClient.id }) + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .limit(1) + if (existing) { + throw new Error( + `An OAuth client with client_id "${clientId}" already exists. Delete it first, or choose another id.` + ) + } + + const now = new Date() + const tokenEndpointAuthMethod = isPublic ? 'none' : 'client_secret_basic' + const clientUri = process.env.OAUTH_CLIENT_URI?.trim() || null + const logoUri = process.env.OAUTH_CLIENT_LOGO_URI?.trim() || null + if (clientUri) assertTerminalSafe('OAUTH_CLIENT_URI', clientUri) + if (logoUri) assertTerminalSafe('OAUTH_CLIENT_LOGO_URI', logoUri) + + await db.insert(oauthClient).values({ + id: generateId(), + clientId, + clientSecret: storedSecret, + name, + uri: clientUri, + icon: logoUri, + disabled: false, + skipConsent: false, + public: isPublic, + type: isPublic ? 'native' : 'web', + tokenEndpointAuthMethod, + requirePKCE: true, + grantTypes: ['authorization_code', 'refresh_token'], + responseTypes: ['code'], + redirectUris, + scopes, + createdAt: now, + updatedAt: now, + }) + + const output = [ + `Created OAuth client "${name}"`, + ` client_id: ${clientId}`, + ` type: ${isPublic ? 'public (PKCE only)' : 'confidential'}`, + ` token_auth: ${tokenEndpointAuthMethod}`, + ` redirect_uris: ${redirectUris.join(', ')}`, + ` scopes: ${scopes.join(' ')}`, + ] + if (secret) { + output.push( + ` client_secret: ${secret}`, + ' The secret is shown once and cannot be read back; keep it now.' + ) + } + process.stdout.write(`${output.join('\n')}\n`) + } finally { + await postgresClient.end() + } +} + +if (import.meta.main) { + main().catch((error) => { + process.stderr.write(`Failed to create OAuth client: ${getErrorMessage(error)}\n`) + process.exit(1) + }) +} diff --git a/bun.lock b/bun.lock index 3ea6e5b5251..e52a1f01f6c 100644 --- a/bun.lock +++ b/bun.lock @@ -173,6 +173,7 @@ "@aws-sdk/s3-request-presigner": "3.1117.0", "@azure/communication-email": "1.1.0", "@azure/storage-blob": "12.27.0", + "@better-auth/oauth-provider": "1.6.27", "@better-auth/sso": "1.6.27", "@better-auth/stripe": "1.6.27", "@browserbasehq/stagehand": "^3.2.1", @@ -627,11 +628,13 @@ "chalk": "5.6.2", "commander": "^11.1.0", "js-yaml": "4.3.1", + "proper-lockfile": "4.1.2", }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", + "@types/proper-lockfile": "4.1.4", "typescript": "^7.0.2", "vitest": "^4.1.0", }, @@ -1070,6 +1073,8 @@ "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-IOYbJMIjEC//f+JpFlmIQjh6x1R/rGAfdzlojFk6HeAJqbsELuMcI+27dIoesbfHLF/icTrSpZtK986XhJrCfA=="], + "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.6.27", "", { "dependencies": { "jose": "^6.1.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.6.27", "better-call": "1.4.0" } }, "sha512-xUQ8GgbWUOYesO5wocrzbHdGa0Jojrxh59Vdew/1xzRRDYfXPEP/vq8Sj754BBwXjGAoKkvU/u6BaLXLBNOlYA=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-v7DXVyaFbkrfLoiFDtcVF7BkEZgFa/DgEGE7XjrAXmMACa5pjDvb7lm8W5X+/qgIbQP04eThhgFQ6EWOsjr8OQ=="], "@better-auth/sso": ["@better-auth/sso@1.6.27", "", { "dependencies": { "fast-xml-parser": "^5.8.0", "jose": "^6.1.3", "samlify": "^2.13.1", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.6.27", "better-call": "1.4.0" } }, "sha512-WD9ctVNLnEogSqIea+HsDg9Y3HxSjA0IA7eR7ZdlCQMyGu3DIkNpKQHd7PztYBX7v2IoE6GIFhCNbiW6/8Wy7g=="], @@ -2254,6 +2259,8 @@ "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + "@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -4772,6 +4779,8 @@ "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@better-auth/oauth-provider/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], diff --git a/docker/crontab b/docker/crontab index 39a2eeccf2d..0af7ad45723 100644 --- a/docker/crontab +++ b/docker/crontab @@ -46,6 +46,9 @@ SHELL=/bin/sh # Deletes table rows whose TTL column has expired */15 * * * * curl -fsS -m 60 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-table-row-ttl" +# Deletes OAuth token rows after the retention tail +0 * * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-oauth-tokens" + # Microsoft Graph subscription renewal (Teams chat triggers expire after ~3 days) 0 */12 * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/renew-subscriptions" diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index b3d1464925c..80914ca33f9 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.9.0 +version: 1.9.1 appVersion: "v0.8.18" kubeVersion: ">=1.25.0-0" home: https://sim.ai diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 9324fa59c1a..1d40bb19e0b 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1502,6 +1502,17 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + # Deletes OAuth token rows after the retention tail, including historical + # rows after the provider is disabled. + cleanupOAuthTokens: + enabled: true + name: cleanup-oauth-tokens + schedule: "0 * * * *" + path: "/api/cron/cleanup-oauth-tokens" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + # Deletes prebuilt sandbox images that no workspace sandbox references and that # have gone unused past the retention window, from the provider and locally. # A no-op on deployments whose sandbox provider installs at run time. diff --git a/package.json b/package.json index ab7c2cd427e..809188dc684 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "check:actorless-executor-operations": "bun run scripts/check-actorless-executor-operations.ts", "check:permission-group-enforcement": "bun run scripts/check-permission-group-enforcement.ts", "check:capability-subject": "bun run scripts/check-capability-subject.ts", + "check:principal-kind-parity": "bun run scripts/check-principal-kind-parity.ts", "check:application-graph": "bun run scripts/check-application-graph.ts", "generate:block-successors": "bun run scripts/generate-block-successors.ts", "check:block-successors": "bun run scripts/generate-block-successors.ts --check", diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 68569697fa8..69a545e0dcf 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -12,6 +12,9 @@ export const AuditAction = { PERSONAL_API_KEY_CREATED: 'personal_api_key.created', PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked', + // OAuth apps (Sim as the authorization server) + OAUTH_APP_REVOKED: 'oauth_app.revoked', + // BYOK Keys BYOK_KEY_CREATED: 'byok_key.created', BYOK_KEY_UPDATED: 'byok_key.updated', @@ -262,6 +265,7 @@ export const AuditResourceType = { KNOWLEDGE_BASE: 'knowledge_base', MCP_SERVER: 'mcp_server', OAUTH: 'oauth', + OAUTH_CLIENT: 'oauth_client', ORGANIZATION: 'organization', PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index d535d29f23e..0ad45cef624 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -1,6 +1,7 @@ export type Principal = | SessionPrincipal | PersonalApiKeyPrincipal + | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal | DelegatedPrincipal | SystemPrincipal @@ -18,6 +19,22 @@ export interface PersonalApiKeyPrincipal { keyId: string } +/** + * A person acting through an OAuth access token a registered client obtained + * with their consent. The same authorization class as a personal API key — a + * human subject, governed by their permission group and by the workspace's + * personal-key policy — narrowed by `scopes` and bounded by `expiresAt`. + */ +export interface OAuthAccessTokenPrincipal { + kind: 'oauth_access_token' + userId: string + clientId: string + /** The `oauth_access_token` row id, never the token itself. */ + tokenId: string + scopes: readonly string[] + expiresAt: Date +} + export interface WorkspaceApiKeyPrincipal { kind: 'workspace_api_key' workspaceId: string @@ -136,6 +153,18 @@ export interface CredentialGroupEnrollmentPrincipal { export type DelegatedServiceId = DelegatedPrincipal['serviceId'] +/** + * A person reaching the API through a bearer credential of their own — a + * personal API key or an OAuth access token. The two are one authorization + * class, so a surface that distinguishes "a user is here" from "a workspace + * or service is here" asks this rather than naming either kind. + */ +export function isUserCredentialPrincipal( + principal: Principal +): principal is PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal { + return principal.kind === 'personal_api_key' || principal.kind === 'oauth_access_token' +} + export class PrincipalSubjectUserRequiredError extends Error { constructor(principalKind: Principal['kind']) { super(`Principal kind ${principalKind} does not represent a human subject`) @@ -184,6 +213,7 @@ export function resolvePrincipalExecutionActorUserId(principal: Principal): stri export type WorkflowExecutionPrincipal = | SessionPrincipal | PersonalApiKeyPrincipal + | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal | SubjectDelegatedPrincipal | SystemPrincipal @@ -191,6 +221,7 @@ export type WorkflowExecutionPrincipal = type SerializedWorkflowExecutionPrincipal = | SessionPrincipal | PersonalApiKeyPrincipal + | (Omit & { expiresAt: string }) | WorkspaceApiKeyPrincipal | SystemPrincipal | (Omit & { @@ -292,6 +323,15 @@ export function serializePrincipal(principal: WorkflowExecutionPrincipal): Seria case 'personal_api_key': case 'workspace_api_key': return { version: 1, principal: { ...principal } } + case 'oauth_access_token': + return { + version: 1, + principal: { + ...principal, + scopes: [...principal.scopes], + expiresAt: principal.expiresAt.toISOString(), + }, + } case 'system': if (principal.serviceId === 'webhook') { if (principal.subject && principal.subject.provider !== principal.provider) { @@ -341,6 +381,20 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { workspaceId: requireString(principal.workspaceId, 'workspaceId'), keyId: requireString(principal.keyId, 'keyId'), } + case 'oauth_access_token': { + requireExactKeys(principal, ['kind', 'userId', 'clientId', 'tokenId', 'scopes', 'expiresAt']) + if (!Array.isArray(principal.scopes)) { + throw new Error('Serialized principal scopes must be an array') + } + return { + kind, + userId: requireString(principal.userId, 'userId'), + clientId: requireString(principal.clientId, 'clientId'), + tokenId: requireString(principal.tokenId, 'tokenId'), + scopes: principal.scopes.map((scope, index) => requireString(scope, `scopes[${index}]`)), + expiresAt: requireDate(principal.expiresAt, 'expiresAt'), + } + } case 'system': { requireExactKeys( principal, @@ -447,6 +501,7 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { export type PrincipalActor = | { kind: 'session'; userId: string } | { kind: 'personal_api_key'; keyId: string; userId: string } + | { kind: 'oauth_access_token'; tokenId: string; clientId: string; userId: string } | { kind: 'workspace_api_key'; keyId: string; workspaceId: string } | { kind: 'system' @@ -504,6 +559,7 @@ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject switch (principal.kind) { case 'session': case 'personal_api_key': + case 'oauth_access_token': return { kind: 'sim_user', userId: principal.userId } case 'delegated': if (principal.serviceId !== 'executor') { @@ -529,6 +585,13 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { return { kind: principal.kind, userId: principal.userId } case 'personal_api_key': return { kind: principal.kind, keyId: principal.keyId, userId: principal.userId } + case 'oauth_access_token': + return { + kind: principal.kind, + tokenId: principal.tokenId, + clientId: principal.clientId, + userId: principal.userId, + } case 'workspace_api_key': return { kind: principal.kind, @@ -574,8 +637,8 @@ export function resolvePrincipalAuditAttribution(principal: Principal): Principa switch (actor.kind) { case 'session': - return { actor, actorId: actor.userId } case 'personal_api_key': + case 'oauth_access_token': return { actor, actorId: actor.userId } case 'delegated': return actor.subjectUserId @@ -604,6 +667,7 @@ export function resolvePrincipalAttribution( switch (actor.kind) { case 'session': case 'personal_api_key': + case 'oauth_access_token': return { actor, attributedUserId: actor.userId } case 'workspace_api_key': { const attributedUserId = context.workspaceBillingOwnerUserId diff --git a/packages/db/migrations/0322_oauth_provider.sql b/packages/db/migrations/0322_oauth_provider.sql new file mode 100644 index 00000000000..cc631a1760d --- /dev/null +++ b/packages/db/migrations/0322_oauth_provider.sql @@ -0,0 +1,258 @@ +-- migration-safe: New OAuth tables, constraints, triggers, and seed data only; no existing rows or serving queries are rewritten. +-- Migration 0321 deliberately commits the runner's batch transaction before concurrent index builds. +-- Re-open one here so every OAuth object and Drizzle's journal row commit or roll back together. +-- On upgrades where 0322 is the only pending file, PostgreSQL treats this as a harmless nested-BEGIN warning. +BEGIN;--> statement-breakpoint +CREATE TABLE "oauth_access_token" ( + "id" text PRIMARY KEY NOT NULL, + "token" text NOT NULL, + "client_id" text NOT NULL, + "session_id" text, + "user_id" text, + "reference_id" text, + "refresh_id" text, + "expires_at" timestamp NOT NULL, + "created_at" timestamp NOT NULL, + "scopes" text[] NOT NULL, + CONSTRAINT "oauth_access_token_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "oauth_client" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "client_secret" text, + "disabled" boolean DEFAULT false NOT NULL, + "skip_consent" boolean, + "enable_end_session" boolean, + "subject_type" text, + "scopes" text[], + "user_id" text, + "created_at" timestamp, + "updated_at" timestamp, + "name" text, + "uri" text, + "icon" text, + "contacts" text[], + "tos" text, + "policy" text, + "software_id" text, + "software_version" text, + "software_statement" text, + "redirect_uris" text[] NOT NULL, + "post_logout_redirect_uris" text[], + "token_endpoint_auth_method" text, + "grant_types" text[], + "response_types" text[], + "public" boolean, + "type" text, + "require_pkce" boolean, + "reference_id" text, + "metadata" jsonb, + CONSTRAINT "oauth_client_client_id_unique" UNIQUE("client_id") +); +--> statement-breakpoint +CREATE TABLE "oauth_consent" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "user_id" text, + "reference_id" text, + "scopes" text[] NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + CONSTRAINT "oauth_consent_user_client_reference_unique" UNIQUE NULLS NOT DISTINCT("user_id","client_id","reference_id") +); +--> statement-breakpoint +CREATE TABLE "oauth_refresh_token" ( + "id" text PRIMARY KEY NOT NULL, + "token" text NOT NULL, + "client_id" text NOT NULL, + "session_id" text, + "user_id" text NOT NULL, + "reference_id" text, + "expires_at" timestamp NOT NULL, + "created_at" timestamp NOT NULL, + "revoked" timestamp, + "auth_time" timestamp, + "scopes" text[] NOT NULL, + "family_id" text NOT NULL, + "generation" integer NOT NULL, + CONSTRAINT "oauth_refresh_token_token_unique" UNIQUE("token"), + CONSTRAINT "oauth_refresh_token_family_generation_unique" UNIQUE("family_id","generation"), + CONSTRAINT "oauth_refresh_token_generation_check" CHECK ("oauth_refresh_token"."generation" BETWEEN 0 AND 1000) +); +--> statement-breakpoint +CREATE TABLE "oauth_token_family" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "session_id" text, + "user_id" text NOT NULL, + "reference_id" text, + "consent_id" text, + "current_generation" integer DEFAULT 0 NOT NULL, + "created_at" timestamp NOT NULL, + "expires_at" timestamp NOT NULL, + CONSTRAINT "oauth_token_family_generation_check" CHECK ("oauth_token_family"."current_generation" BETWEEN 0 AND 1000) +); +--> statement-breakpoint +ALTER TABLE "oauth_client" ADD CONSTRAINT "oauth_client_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_token_family" ADD CONSTRAINT "oauth_token_family_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_token_family" ADD CONSTRAINT "oauth_token_family_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_token_family" ADD CONSTRAINT "oauth_token_family_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_token_family" ADD CONSTRAINT "oauth_token_family_consent_id_oauth_consent_id_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."oauth_consent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_family_id_oauth_token_family_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."oauth_token_family"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_refresh_id_oauth_refresh_token_id_fk" FOREIGN KEY ("refresh_id") REFERENCES "public"."oauth_refresh_token"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "oauth_access_token_client_id_idx" ON "oauth_access_token" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauth_access_token_session_id_idx" ON "oauth_access_token" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "oauth_access_token_refresh_id_idx" ON "oauth_access_token" USING btree ("refresh_id");--> statement-breakpoint +CREATE INDEX "oauth_access_token_user_client_idx" ON "oauth_access_token" USING btree ("user_id","client_id");--> statement-breakpoint +CREATE INDEX "oauth_access_token_expires_at_idx" ON "oauth_access_token" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "oauth_client_user_id_idx" ON "oauth_client" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "oauth_consent_client_id_idx" ON "oauth_consent" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauth_refresh_token_client_id_idx" ON "oauth_refresh_token" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauth_refresh_token_session_id_idx" ON "oauth_refresh_token" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "oauth_refresh_token_user_client_idx" ON "oauth_refresh_token" USING btree ("user_id","client_id");--> statement-breakpoint +CREATE INDEX "oauth_refresh_token_expires_at_idx" ON "oauth_refresh_token" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "oauth_token_family_client_id_idx" ON "oauth_token_family" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "oauth_token_family_session_id_idx" ON "oauth_token_family" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "oauth_token_family_user_client_idx" ON "oauth_token_family" USING btree ("user_id","client_id");--> statement-breakpoint +CREATE INDEX "oauth_token_family_consent_id_idx" ON "oauth_token_family" USING btree ("consent_id");--> statement-breakpoint +CREATE INDEX "oauth_token_family_expires_at_idx" ON "oauth_token_family" USING btree ("expires_at");--> statement-breakpoint +CREATE FUNCTION "oauth_refresh_token_prepare_family"() RETURNS trigger AS $$ +DECLARE + resolved_consent_id text; +BEGIN + IF NEW."family_id" IS NULL THEN + SELECT "id" INTO resolved_consent_id + FROM "oauth_consent" + WHERE "client_id" = NEW."client_id" + AND "user_id" IS NOT DISTINCT FROM NEW."user_id" + AND "reference_id" IS NOT DISTINCT FROM NEW."reference_id" + FOR KEY SHARE; + + NEW."family_id" := NEW."id"; + NEW."generation" := 0; + INSERT INTO "oauth_token_family" ( + "id", "client_id", "session_id", "user_id", "reference_id", + "consent_id", "current_generation", "created_at", "expires_at" + ) VALUES ( + NEW."id", NEW."client_id", NEW."session_id", NEW."user_id", NEW."reference_id", + resolved_consent_id, 0, NEW."created_at", NEW."expires_at" + ); + ELSE + PERFORM 1 + FROM "oauth_token_family" AS family + WHERE family."id" = NEW."family_id" + AND family."client_id" = NEW."client_id" + AND family."user_id" = NEW."user_id" + AND family."session_id" IS NOT DISTINCT FROM NEW."session_id" + AND family."reference_id" IS NOT DISTINCT FROM NEW."reference_id" + AND family."current_generation" = NEW."generation" + AND NEW."expires_at" <= family."expires_at"; + + IF NOT FOUND THEN + RAISE EXCEPTION 'OAuth refresh token does not match its current family generation' + USING ERRCODE = 'foreign_key_violation'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER "oauth_refresh_token_10_prepare_family" + BEFORE INSERT ON "oauth_refresh_token" + FOR EACH ROW + EXECUTE FUNCTION "oauth_refresh_token_prepare_family"();--> statement-breakpoint +CREATE FUNCTION "oauth_token_require_active_consent"() RETURNS trigger AS $$ +BEGIN + PERFORM 1 + FROM "oauth_client" + WHERE "client_id" = NEW."client_id" + AND "skip_consent" IS TRUE; + + IF FOUND THEN + RETURN NEW; + END IF; + + PERFORM 1 + FROM "oauth_consent" + WHERE "client_id" = NEW."client_id" + AND "user_id" IS NOT DISTINCT FROM NEW."user_id" + AND "reference_id" IS NOT DISTINCT FROM NEW."reference_id" + AND NEW."scopes" <@ "scopes" + FOR SHARE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'OAuth token requires an active consent grant' + USING ERRCODE = 'foreign_key_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER "oauth_access_token_require_active_consent" + BEFORE INSERT ON "oauth_access_token" + FOR EACH ROW + EXECUTE FUNCTION "oauth_token_require_active_consent"();--> statement-breakpoint +CREATE TRIGGER "oauth_refresh_token_20_require_active_consent" + BEFORE INSERT ON "oauth_refresh_token" + FOR EACH ROW + EXECUTE FUNCTION "oauth_token_require_active_consent"();--> statement-breakpoint +CREATE FUNCTION "oauth_consent_narrow_tokens"() RETURNS trigger AS $$ +BEGIN + IF NEW."scopes" = OLD."scopes" THEN + RETURN NEW; + END IF; + + DELETE FROM "oauth_token_family" AS family + WHERE family."consent_id" = NEW."id" + AND EXISTS ( + SELECT 1 + FROM "oauth_refresh_token" AS refresh + WHERE refresh."family_id" = family."id" + AND refresh."generation" = family."current_generation" + AND NOT (refresh."scopes" <@ NEW."scopes") + ); + + DELETE FROM "oauth_access_token" + WHERE "client_id" = NEW."client_id" + AND "user_id" IS NOT DISTINCT FROM NEW."user_id" + AND "reference_id" IS NOT DISTINCT FROM NEW."reference_id" + AND NOT ("scopes" <@ NEW."scopes"); + RETURN NEW; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER "oauth_consent_narrow_tokens" + AFTER UPDATE OF "scopes" ON "oauth_consent" + FOR EACH ROW + EXECUTE FUNCTION "oauth_consent_narrow_tokens"();--> statement-breakpoint +CREATE FUNCTION "oauth_consent_delete_unlinked_access_tokens"() RETURNS trigger AS $$ +BEGIN + DELETE FROM "oauth_access_token" + WHERE "client_id" = OLD."client_id" + AND "user_id" IS NOT DISTINCT FROM OLD."user_id" + AND "reference_id" IS NOT DISTINCT FROM OLD."reference_id"; + RETURN OLD; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER "oauth_consent_delete_unlinked_access_tokens" + AFTER DELETE ON "oauth_consent" + FOR EACH ROW + EXECUTE FUNCTION "oauth_consent_delete_unlinked_access_tokens"();--> statement-breakpoint +-- Seed the first-party Sim CLI as a public PKCE client. Loopback URIs match any port per RFC 8252. +INSERT INTO "oauth_client" ( + "id", "client_id", "name", "disabled", "skip_consent", "public", "type", + "token_endpoint_auth_method", "require_pkce", "grant_types", "response_types", + "redirect_uris", "scopes", "created_at", "updated_at" +) VALUES ( + 'sim-cli', 'sim-cli', 'Sim CLI', false, false, true, 'native', + 'none', true, ARRAY['authorization_code', 'refresh_token'], ARRAY['code'], + ARRAY['http://127.0.0.1/callback', 'http://[::1]/callback'], + ARRAY['offline_access', 'api:read', 'api:write'], + now(), now() +) ON CONFLICT ("client_id") DO NOTHING; diff --git a/packages/db/migrations/meta/0322_snapshot.json b/packages/db/migrations/meta/0322_snapshot.json new file mode 100644 index 00000000000..43f46c0ef38 --- /dev/null +++ b/packages/db/migrations/meta/0322_snapshot.json @@ -0,0 +1,22664 @@ +{ + "id": "da36288c-6d30-4956-bc1d-5157d2131d85", + "prevId": "db628744-c7d5-4412-b5d0-44cb6ed4368f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_384_vector_hnsw_idx": { + "name": "embedding_384_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_768_vector_hnsw_idx": { + "name": "embedding_768_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_1024_vector_hnsw_idx": { + "name": "embedding_1024_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_3072_vector_hnsw_idx": { + "name": "embedding_3072_vector_hnsw_idx", + "columns": [ + { + "expression": "(\"embedding_3072\"::halfvec(3072)) halfvec_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 44724f1962b..9ba042f9ca8 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2248,6 +2248,13 @@ "when": 1788482043862, "tag": "0321_multi_width_embeddings", "breakpoints": true + }, + { + "idx": 322, + "version": "7", + "when": 1788562996397, + "tag": "0322_oauth_provider", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 9abdb9bbf0a..ce3da80900c 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -19,6 +19,7 @@ import { primaryKey, text, timestamp, + unique, uniqueIndex, uuid, vector, @@ -758,9 +759,8 @@ export const secretUsage = pgTable( * * Empty string rather than null because both this and `actorUserId` sit inside the unique * key below, and Postgres treats nulls as distinct — two Copilot rows would never collide, - * so the upsert would insert forever instead of incrementing. `NULLS NOT DISTINCT` fixes - * that but requires Postgres 15, and this is self-hosted software that must not raise its - * database floor for one table. A sentinel keeps the key null-free on every version. + * so the upsert would insert forever instead of incrementing. A sentinel keeps the upsert + * identity explicit and null-free rather than relying on nullable uniqueness semantics. * * Deliberately not a foreign key, and neither is `actorUserId`. An `onDelete: 'set null'` * would rewrite a key column, so two rows differing only by the deleted id would collide @@ -3952,6 +3952,182 @@ export const ssoDomain = pgTable( }) ) +/** + * OAuth 2.0 provider tables (Better Auth `@better-auth/oauth-provider`). + * + * Sim is the authorization server: a registered client (the Sim CLI, or an + * admin-created third-party app) sends a user through `/api/auth/oauth2/authorize`, + * the user consents, and the client redeems a code for an opaque access token and + * a rotating refresh token. Tokens are stored hashed; the plaintext exists only in + * the client. Column keys follow the plugin's model fields so the Better Auth + * drizzle adapter maps them without a per-field `fieldName` override. + */ +export const oauthClient = pgTable( + 'oauth_client', + { + id: text('id').primaryKey(), + clientId: text('client_id').notNull().unique(), + clientSecret: text('client_secret'), + disabled: boolean('disabled').notNull().default(false), + skipConsent: boolean('skip_consent'), + enableEndSession: boolean('enable_end_session'), + subjectType: text('subject_type'), + scopes: text('scopes').array(), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at'), + name: text('name'), + uri: text('uri'), + icon: text('icon'), + contacts: text('contacts').array(), + tos: text('tos'), + policy: text('policy'), + softwareId: text('software_id'), + softwareVersion: text('software_version'), + softwareStatement: text('software_statement'), + redirectUris: text('redirect_uris').array().notNull(), + postLogoutRedirectUris: text('post_logout_redirect_uris').array(), + tokenEndpointAuthMethod: text('token_endpoint_auth_method'), + grantTypes: text('grant_types').array(), + responseTypes: text('response_types').array(), + public: boolean('public'), + type: text('type'), + requirePKCE: boolean('require_pkce'), + referenceId: text('reference_id'), + metadata: jsonb('metadata'), + }, + (table) => ({ + userIdIdx: index('oauth_client_user_id_idx').on(table.userId), + }) +) + +/** The scopes a user has granted a client; deleted when the user revokes the app. */ +export const oauthConsent = pgTable( + 'oauth_consent', + { + id: text('id').primaryKey(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + scopes: text('scopes').array().notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => ({ + clientIdIdx: index('oauth_consent_client_id_idx').on(table.clientId), + /** One grant per user, client, and reference, including nullable dimensions. */ + userClientUnique: unique('oauth_consent_user_client_reference_unique') + .on(table.userId, table.clientId, table.referenceId) + .nullsNotDistinct(), + }) +) + +/** + * One independently revocable login. Every rotating refresh token belongs to + * a stable family so replay and logout can atomically remove all descendants. + */ +export const oauthTokenFamily = pgTable( + 'oauth_token_family', + { + id: text('id').primaryKey(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => session.id, { onDelete: 'set null' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + consentId: text('consent_id').references(() => oauthConsent.id, { onDelete: 'cascade' }), + currentGeneration: integer('current_generation').notNull().default(0), + createdAt: timestamp('created_at').notNull(), + expiresAt: timestamp('expires_at').notNull(), + }, + (table) => ({ + clientIdIdx: index('oauth_token_family_client_id_idx').on(table.clientId), + sessionIdIdx: index('oauth_token_family_session_id_idx').on(table.sessionId), + userClientIdx: index('oauth_token_family_user_client_idx').on(table.userId, table.clientId), + consentIdIdx: index('oauth_token_family_consent_id_idx').on(table.consentId), + expiresAtIdx: index('oauth_token_family_expires_at_idx').on(table.expiresAt), + generationCheck: check( + 'oauth_token_family_generation_check', + sql`${table.currentGeneration} BETWEEN 0 AND 1000` + ), + }) +) + +/** A member of a rotating refresh-token family. */ +export const oauthRefreshToken = pgTable( + 'oauth_refresh_token', + { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => session.id, { onDelete: 'set null' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').notNull(), + revoked: timestamp('revoked'), + authTime: timestamp('auth_time'), + scopes: text('scopes').array().notNull(), + familyId: text('family_id') + .notNull() + .references(() => oauthTokenFamily.id, { onDelete: 'cascade' }), + generation: integer('generation').notNull(), + }, + (table) => ({ + clientIdIdx: index('oauth_refresh_token_client_id_idx').on(table.clientId), + sessionIdIdx: index('oauth_refresh_token_session_id_idx').on(table.sessionId), + userClientIdx: index('oauth_refresh_token_user_client_idx').on(table.userId, table.clientId), + /** Drives the cleanup pass; nothing else reads tokens by expiry. */ + expiresAtIdx: index('oauth_refresh_token_expires_at_idx').on(table.expiresAt), + familyGenerationUnique: unique('oauth_refresh_token_family_generation_unique').on( + table.familyId, + table.generation + ), + generationCheck: check( + 'oauth_refresh_token_generation_check', + sql`${table.generation} BETWEEN 0 AND 1000` + ), + }) +) + +/** An opaque access token, looked up by hash on every bearer-authenticated request. */ +export const oauthAccessToken = pgTable( + 'oauth_access_token', + { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => session.id, { onDelete: 'set null' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + refreshId: text('refresh_id').references(() => oauthRefreshToken.id, { + onDelete: 'cascade', + }), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').notNull(), + scopes: text('scopes').array().notNull(), + }, + (table) => ({ + clientIdIdx: index('oauth_access_token_client_id_idx').on(table.clientId), + sessionIdIdx: index('oauth_access_token_session_id_idx').on(table.sessionId), + refreshIdIdx: index('oauth_access_token_refresh_id_idx').on(table.refreshId), + userClientIdx: index('oauth_access_token_user_client_idx').on(table.userId, table.clientId), + /** Drives the cleanup pass; nothing else reads tokens by expiry. */ + expiresAtIdx: index('oauth_access_token_expires_at_idx').on(table.expiresAt), + }) +) + /** * Workflow MCP Servers - User-created MCP servers that expose workflows as tools. * These servers are accessible by external MCP clients via API key authentication, diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index ccb6381cc45..0344b40b023 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -33,23 +33,26 @@ Sign in to the default profile: sim login ``` -The CLI opens a browser and prints a pairing code. Confirm that the code in the -browser matches the one in your terminal, approve the login, and choose a -workspace. The selected workspace becomes the default for this profile. - -The login stores a personal API key locally. It does not start a local callback -server, so the same flow works over SSH and in containers. Use -`sim login --no-browser` when the browser is on another machine. - -Check the active profile and verify that its endpoint, API key, and workspace +The CLI opens Sim in your browser, asks you to approve the requested access, +and receives the one-time authorization code on a loopback callback. It stores +a short-lived OAuth login that renews automatically and can be revoked under +**Settings → Authorized apps**. Choose a default workspace afterward with +`sim configure --set-workspace `. + +Use `sim login --no-browser` to print the OAuth URL without opening it. The +browser must still be able to reach the CLI's loopback callback. Over SSH or in +a container without port forwarding, use `sim login --browserless`; that +pairing-code fallback creates a permanent personal API key instead. + +Check the active profile and verify that its endpoint, credential, and workspace work together: ```bash sim whoami ``` -This also reports whether the active key is personal or workspace-scoped. Some -administrative and deployment operations require a personal key. +This also reports whether the active credential is an OAuth login or an API +key. Some administrative operations require a personal credential. Then list and run workflows: @@ -73,7 +76,7 @@ not a workflow. A profile is a named CLI configuration. It determines: - which Sim deployment to use -- which API key to authenticate with +- which stored login or API key to authenticate with - which workspace to target by default - how command output is formatted @@ -94,8 +97,8 @@ There are two common ways to create profiles. ### Use one login with several workspaces -After `sim login`, create another profile that shares the active profile's API -key but has its own default workspace: +After `sim login`, create another profile that shares the active profile's +credential but has its own default workspace: ```bash sim workspaces list @@ -105,7 +108,7 @@ sim --profile acme whoami If you omit `--workspace` in an interactive terminal, the CLI asks you to choose one. The new profile stores an `auth_profile` reference to the active login; it -does not copy the API key. +does not copy the credential. ### Use a separate account or deployment @@ -117,8 +120,8 @@ sim login --profile work sim login --profile local --endpoint http://localhost:3000 ``` -Each of these profiles stores its own API key. The endpoint selected during -login is saved with the profile. +Each of these profiles stores its own login. The endpoint selected during login +is saved with the profile. ### View and change profiles @@ -134,8 +137,9 @@ sim whoami --profile work `sim profiles` marks the active profile with `*`. Running `sim configure` with no setting flags prints the saved settings for that profile. -Non-secret settings are stored in `~/.sim/config`. API keys are stored separately -in `~/.sim/credentials`, which is written with `0600` permissions. Set +Non-secret settings are stored in `~/.sim/config`. OAuth tokens and API keys are +stored separately in `~/.sim/credentials`, which is written with `0600` +permissions. Set `SIM_CONFIG_DIR` to use a different directory. For each setting, the CLI uses the first available value in this order: diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 29be5031915..becfb2a3564 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -49,12 +49,14 @@ "dependencies": { "chalk": "5.6.2", "commander": "^11.1.0", - "js-yaml": "4.3.1" + "js-yaml": "4.3.1", + "proper-lockfile": "4.1.2" }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", + "@types/proper-lockfile": "4.1.4", "typescript": "^7.0.2", "vitest": "^4.1.0" } diff --git a/packages/sim-cli/src/auth/oauth-flow.test.ts b/packages/sim-cli/src/auth/oauth-flow.test.ts new file mode 100644 index 00000000000..2fd325a7146 --- /dev/null +++ b/packages/sim-cli/src/auth/oauth-flow.test.ts @@ -0,0 +1,280 @@ +import { createHash } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SimApiError } from '../http/client' +import { + buildAuthorizeUrl, + buildRedirectUri, + createPkce, + discoverOAuthProvider, + exchangeCode, + isLikelyRemoteSession, + loginWithBrowser, + OAUTH_CLIENT_ID, + OAuthTokenError, + refreshTokens, +} from './oauth-flow' + +const ENDPOINT = 'https://sim.test' + +function reply(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status }) as Response +} + +const TOKENS = { + access_token: 'sim_oat_access', + refresh_token: 'sim_ort_refresh', + expires_in: 3600, + scope: 'offline_access api:read', + token_type: 'Bearer', +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('createPkce', () => { + it('derives an S256 challenge from a fresh 256-bit verifier', () => { + const pkce = createPkce() + expect(pkce.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(pkce.challenge).toBe( + createHash('sha256').update(pkce.verifier, 'ascii').digest('base64url') + ) + expect(pkce.state).toMatch(/^[A-Za-z0-9_-]{22}$/) + expect(createPkce().verifier).not.toBe(pkce.verifier) + }) +}) + +describe('buildAuthorizeUrl', () => { + it('names the seeded public client, S256, and a loopback IP-literal redirect', () => { + const pkce = createPkce() + const url = new URL( + buildAuthorizeUrl(ENDPOINT, { + redirectUri: buildRedirectUri(54321), + scopes: ['offline_access', 'api:read'], + pkce, + }) + ) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('client_id')).toBe(OAUTH_CLIENT_ID) + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:54321/callback') + expect(url.searchParams.get('scope')).toBe('offline_access api:read') + expect(url.searchParams.get('code_challenge')).toBe(pkce.challenge) + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('state')).toBe(pkce.state) + }) +}) + +describe('discoverOAuthProvider', () => { + it('reports a server that publishes a token endpoint as available', async () => { + vi.stubGlobal('fetch', async () => + reply(200, { + issuer: `${ENDPOINT}/api/auth`, + token_endpoint: `${ENDPOINT}/api/auth/oauth2/token`, + }) + ) + await expect(discoverOAuthProvider(ENDPOINT)).resolves.toBe('available') + }) + + it('treats a 404 as a server without the provider, which selects the handoff', async () => { + vi.stubGlobal('fetch', async () => reply(404, { error: 'OAuth provider is not enabled' })) + await expect(discoverOAuthProvider(ENDPOINT)).resolves.toBe('unavailable') + }) + + it('separates an unreachable endpoint from one that lacks the feature', async () => { + vi.stubGlobal('fetch', async () => { + throw new Error('ECONNREFUSED') + }) + await expect(discoverOAuthProvider(ENDPOINT)).resolves.toBe('unreachable') + }) +}) + +describe('token endpoint', () => { + it('posts the code with its verifier as a form and reads the pair back', async () => { + const fetchMock = vi.fn(async () => reply(200, TOKENS)) + vi.stubGlobal('fetch', fetchMock) + const before = Date.now() + + const tokens = await exchangeCode(ENDPOINT, { + code: 'abc', + redirectUri: 'http://127.0.0.1:1/callback', + verifier: 'v', + requestedScopes: ['offline_access', 'api:read'], + }) + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${ENDPOINT}/api/auth/oauth2/token`) + expect(init.headers).toMatchObject({ 'content-type': 'application/x-www-form-urlencoded' }) + expect(init.redirect).toBe('manual') + expect(Object.fromEntries(new URLSearchParams(String(init.body)))).toEqual({ + grant_type: 'authorization_code', + client_id: OAUTH_CLIENT_ID, + code: 'abc', + redirect_uri: 'http://127.0.0.1:1/callback', + code_verifier: 'v', + }) + expect(tokens).toMatchObject({ accessToken: 'sim_oat_access', refreshToken: 'sim_ort_refresh' }) + expect(tokens.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000) + }) + + it('surfaces the RFC 6749 error code on a refusal', async () => { + vi.stubGlobal('fetch', async () => + reply(400, { error: 'invalid_grant', error_description: 'refresh token revoked' }) + ) + const failure = await refreshTokens(ENDPOINT, 'dead').catch((error) => error) + expect(failure).toBeInstanceOf(OAuthTokenError) + expect(failure.oauthError).toBe('invalid_grant') + expect(failure.message).toBe('refresh token revoked') + }) + + it('refuses a pair missing its refresh token rather than storing half a login', async () => { + vi.stubGlobal('fetch', async () => reply(200, { access_token: 'only', expires_in: 60 })) + await expect(refreshTokens(ENDPOINT, 'r')).rejects.toThrow('Nothing was stored') + }) + + it('does not follow a redirect that would carry the verifier elsewhere', async () => { + vi.stubGlobal( + 'fetch', + async () => new Response(null, { status: 302, headers: { location: 'https://evil.test' } }) + ) + await expect(refreshTokens(ENDPOINT, 'r')).rejects.toThrow('does not follow redirects') + }) +}) + +describe('loginWithBrowser', () => { + /** Drives the loopback listener the way a browser would, by following the authorize URL's redirect params. */ + async function completeInBrowser( + outcome: (params: URLSearchParams, state: string) => Record + ) { + const fetchMock = vi.fn(async () => reply(200, TOKENS)) + vi.stubGlobal('fetch', fetchMock) + + const login = loginWithBrowser(ENDPOINT, { + scopes: ['offline_access', 'api:read'], + onAuthorizeUrl: (url) => { + const authorize = new URL(url) + const redirectUri = new URL(authorize.searchParams.get('redirect_uri') as string) + const state = authorize.searchParams.get('state') as string + for (const [key, value] of Object.entries(outcome(authorize.searchParams, state))) { + redirectUri.searchParams.set(key, value) + } + /** Node's real HTTP client, not the stubbed fetch, exercises the listener. */ + void import('node:http').then(({ get }) => { + get(redirectUri, (response) => response.resume()) + }) + }, + timeoutMs: 5000, + }) + return { login, fetchMock } + } + + it('listens on 127.0.0.1, verifies state, and redeems the code with the verifier', async () => { + const { login, fetchMock } = await completeInBrowser((_params, state) => ({ + code: 'the-code', + state, + })) + + const tokens = await login + expect(tokens.accessToken).toBe('sim_oat_access') + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const form = Object.fromEntries(new URLSearchParams(String(init.body))) + expect(form.code).toBe('the-code') + expect(form.redirect_uri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/) + expect(form.code_verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + it('ignores a redirect whose state this terminal did not issue, and keeps waiting', async () => { + /** + * Anything on the machine can reach a loopback port, so a forged callback + * must not be able to end someone's sign-in. The forged hit is answered + * and dropped; the real browser then arrives and the login completes. + * + * The forged request is awaited to completion before the real one is sent. + * Firing both and letting them race meant a run where the real callback + * landed first passed every assertion below without the mismatch branch + * ever executing. + */ + const fetchMock = vi.fn(async () => reply(200, TOKENS)) + vi.stubGlobal('fetch', fetchMock) + + let forgedStatus: number | undefined + const login = loginWithBrowser(ENDPOINT, { + scopes: ['offline_access', 'api:read'], + onAuthorizeUrl: (url) => { + const authorize = new URL(url) + const redirectUri = new URL(authorize.searchParams.get('redirect_uri') as string) + const state = authorize.searchParams.get('state') as string + void (async () => { + const { get } = await import('node:http') + const forged = new URL(redirectUri) + forged.searchParams.set('code', 'forged-code') + forged.searchParams.set('state', 'forged') + forgedStatus = await new Promise((resolve) => { + get(forged, (response) => { + response.resume() + response.once('end', () => resolve(response.statusCode ?? 0)) + }) + }) + const real = new URL(redirectUri) + real.searchParams.set('code', 'the-code') + real.searchParams.set('state', state) + get(real, (response) => response.resume()) + })() + }, + timeoutMs: 5000, + }) + + const tokens = await login + expect(forgedStatus).toBe(400) + expect(tokens.accessToken).toBe('sim_oat_access') + expect(fetchMock).toHaveBeenCalledOnce() + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(Object.fromEntries(new URLSearchParams(String(init.body))).code).toBe('the-code') + }) + + it('reports a declined consent as a cancellation, not a server failure', async () => { + const { login, fetchMock } = await completeInBrowser((_params, state) => ({ + error: 'access_denied', + state, + })) + + const failure = await login.catch((error) => error) + expect(failure).toBeInstanceOf(SimApiError) + expect(failure.message).toBe('Sign-in was declined in the browser.') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('gives up after the timeout with the browserless fallback named', async () => { + vi.stubGlobal('fetch', vi.fn()) + await expect( + loginWithBrowser(ENDPOINT, { + scopes: ['offline_access', 'api:read'], + onAuthorizeUrl: () => {}, + timeoutMs: 20, + }) + ).rejects.toThrow('--browserless') + }) +}) + +describe('isLikelyRemoteSession', () => { + it('detects an SSH session from its environment', () => { + expect(isLikelyRemoteSession({ SSH_CONNECTION: '1.2.3.4 22 5.6.7.8 22' })).toBe(true) + }) + + it('treats a Linux desktop session as local', () => { + expect(isLikelyRemoteSession({ DISPLAY: ':0' }, 'linux')).toBe(false) + expect(isLikelyRemoteSession({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toBe(false) + }) + + /** The branch the automatic pairing-code fallback actually turns on. */ + it('treats a headless Linux box as remote', () => { + expect(isLikelyRemoteSession({}, 'linux')).toBe(true) + }) + + it('treats a desktop OS as local even with no display variables', () => { + expect(isLikelyRemoteSession({}, 'darwin')).toBe(false) + expect(isLikelyRemoteSession({}, 'win32')).toBe(false) + }) +}) diff --git a/packages/sim-cli/src/auth/oauth-flow.ts b/packages/sim-cli/src/auth/oauth-flow.ts new file mode 100644 index 00000000000..ac16f5346ba --- /dev/null +++ b/packages/sim-cli/src/auth/oauth-flow.ts @@ -0,0 +1,561 @@ +import { createHash, randomBytes } from 'node:crypto' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { oauthIssuerForEndpoint, redact } from '../config/profile' +import { buildUrl, REDIRECT_STATUSES, SimApiError } from '../http/client' +import { USER_AGENT } from '../version' + +/** + * The OAuth half of `sim login`: authorization code + PKCE with a loopback + * redirect (RFC 8252), the flow gcloud, the AWS CLI, Wrangler and Railway use. + * + * The CLI is a public client — there is no secret to keep — so PKCE is what + * binds the code to this process: the browser carries only the SHA-256 of a + * verifier that never leaves memory, and the token endpoint refuses a code + * presented without it. `state` guards the loopback listener against a stray + * or forged redirect, and the listener binds the loopback interface only, for + * the life of one login. + * + * The result is a short-lived access token and a rotating refresh token, both + * revocable from Settings → Authorized apps, instead of the permanent API key + * the pairing-code handoff in `device-flow.ts` mints. That handoff remains the + * path for a terminal whose browser cannot reach it (SSH, containers). + */ + +/** The client id migration `0322_oauth_provider` seeds; a public client, no secret. */ +export const OAUTH_CLIENT_ID = 'sim-cli' + +/** + * Everything the CLI does, and nothing more: renew itself, and read and change + * workspace resources. + * + * Sim's provider deliberately exposes OAuth API authorization rather than an + * OpenID Connect identity surface, so the CLI requests no identity claims. + */ +export const OAUTH_SCOPES_FULL = ['offline_access', 'api:read', 'api:write'] as const + +/** `--read-only`: a token that can inspect but never change anything. */ +export const OAUTH_SCOPES_READ_ONLY = ['offline_access', 'api:read'] as const + +const AUTHORIZE_PATH = '/api/auth/oauth2/authorize' +const TOKEN_PATH = '/api/auth/oauth2/token' +const REVOKE_PATH = '/api/auth/oauth2/revoke' +const DISCOVERY_PATH = '/.well-known/oauth-authorization-server' +const CALLBACK_PATH = '/callback' + +/** + * How long the browser leg may take. Railway and Wrangler use the same window; + * long enough to sign up and read the consent page, short enough that a + * forgotten terminal does not keep a listener open all afternoon. + */ +const LOGIN_TIMEOUT_MS = 5 * 60 * 1000 + +/** How long a discovery, revocation, or code exchange may take. */ +const REQUEST_TIMEOUT_MS = 10 * 1000 + +/** + * Refuses to run the OAuth flow over cleartext. + * + * The code, the verifier, and both tokens cross this connection. An API key + * over `http` earns a warning because the user typed the endpoint and may know + * something we do not; a login is different, because the flow itself is what + * would leak, and because a tampered discovery response silently downgrades + * `sim login` to the pairing-code handoff — which mints a permanent key. + * Loopback is exempt: it never leaves the machine. + */ +export function requireSecureEndpoint(endpoint: string): void { + let url: URL + try { + url = new URL(endpoint) + } catch { + return + } + const loopback = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + if (url.protocol === 'http:' && !loopback) { + throw new SimApiError( + `Refusing to sign in to ${url.host} over http: the sign-in would send your tokens in the clear. Use https. There is no cleartext fallback — the pairing-code handoff would send a permanent API key over the same connection.`, + 0 + ) + } +} + +export interface OAuthTokens { + accessToken: string + refreshToken: string + /** Epoch milliseconds at which `accessToken` stops working. */ + expiresAt: number + scope: string +} + +/** A refusal from the token endpoint, carrying the RFC 6749 error code. */ +export class OAuthTokenError extends SimApiError { + constructor( + readonly oauthError: string, + description: string | undefined, + status: number + ) { + super(description ?? `Authorization server refused the request (${oauthError})`, status) + this.name = 'OAuthTokenError' + } +} + +export interface Pkce { + verifier: string + challenge: string + state: string +} + +function base64url(bytes: Buffer): string { + return bytes.toString('base64url') +} + +/** A fresh verifier (43 chars, 256 bits), its S256 challenge, and a `state` nonce. */ +export function createPkce(): Pkce { + const verifier = base64url(randomBytes(32)) + return { + verifier, + challenge: createHash('sha256').update(verifier, 'ascii').digest('base64url'), + state: base64url(randomBytes(16)), + } +} + +export function buildRedirectUri(port: number): string { + return `http://127.0.0.1:${port}${CALLBACK_PATH}` +} + +export function buildAuthorizeUrl( + endpoint: string, + args: { redirectUri: string; scopes: readonly string[]; pkce: Pkce } +): string { + return buildUrl(endpoint, AUTHORIZE_PATH, { + client_id: OAUTH_CLIENT_ID, + response_type: 'code', + redirect_uri: args.redirectUri, + scope: args.scopes.join(' '), + code_challenge: args.pkce.challenge, + code_challenge_method: 'S256', + state: args.pkce.state, + }) +} + +export type OAuthProviderStatus = 'available' | 'unavailable' | 'unreachable' + +/** + * Whether the endpoint is an OAuth authorization server, from the RFC 8414 + * discovery document. A 404 is a definite "no" — an older Sim, or one with the + * provider switched off — and sends login to the pairing-code handoff; a + * transport failure is reported as such so a typo'd endpoint is not mistaken + * for a server that lacks the feature. + */ +export async function discoverOAuthProvider(endpoint: string): Promise { + let response: Response + try { + response = await fetch(buildUrl(endpoint, DISCOVERY_PATH), { + headers: { accept: 'application/json', 'user-agent': USER_AGENT }, + redirect: 'manual', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch { + return 'unreachable' + } + /** + * Only a definite "no" counts as unavailable. A 5xx or a redirect means the + * question was not answered — a proxy mid-deploy, a captive portal — and + * calling that "no OAuth here" would quietly hand the user a permanent API + * key from the handoff on a server that does support signing in. + */ + if (response.status === 404) return 'unavailable' + if (!response.ok || REDIRECT_STATUSES.has(response.status)) return 'unreachable' + try { + const metadata = (await response.json()) as { issuer?: unknown; token_endpoint?: unknown } + const expectedIssuer = oauthIssuerForEndpoint(endpoint) + const expectedTokenEndpoint = buildUrl(endpoint, TOKEN_PATH) + return metadata.issuer === expectedIssuer && metadata.token_endpoint === expectedTokenEndpoint + ? 'available' + : 'unreachable' + } catch { + return 'unreachable' + } +} + +interface TokenResponse { + access_token?: unknown + refresh_token?: unknown + expires_in?: unknown + scope?: unknown + token_type?: unknown + error?: unknown + error_description?: unknown +} + +async function postToken( + endpoint: string, + path: string, + form: Record, + signal?: AbortSignal +): Promise { + let response: Response + try { + response = await fetch(buildUrl(endpoint, path), { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + 'user-agent': USER_AGENT, + }, + body: new URLSearchParams(form).toString(), + signal, + redirect: 'manual', + }) + } catch (cause) { + throw new SimApiError(`Could not reach ${endpoint}: ${(cause as Error).message}`, 0) + } + if (REDIRECT_STATUSES.has(response.status)) { + throw new SimApiError( + `${endpoint} redirected the token request. The CLI does not follow redirects, because a redirect drops the request body and would carry the login secret to another origin. Check the endpoint.`, + response.status + ) + } + return response +} + +async function readTokens( + response: Response, + issuedAt: number, + expectedScopes?: readonly string[] +): Promise { + const raw = await response.text() + let body: TokenResponse + try { + body = JSON.parse(raw) as TokenResponse + } catch { + throw new SimApiError( + `The authorization server answered HTTP ${response.status} with a non-JSON body.`, + response.status + ) + } + if (!response.ok || typeof body.error === 'string') { + throw new OAuthTokenError( + typeof body.error === 'string' ? body.error : 'server_error', + /** Remote descriptions are redacted before they reach the terminal. */ + typeof body.error_description === 'string' ? redact(body.error_description) : undefined, + response.status + ) + } + if (typeof body.access_token !== 'string' || typeof body.refresh_token !== 'string') { + throw new SimApiError( + 'The authorization server did not return both an access token and a refresh token. Nothing was stored.', + response.status + ) + } + if ( + body.token_type !== undefined && + (typeof body.token_type !== 'string' || body.token_type.toLowerCase() !== 'bearer') + ) { + throw new SimApiError( + 'The authorization server returned an unsupported token type.', + response.status + ) + } + if ( + body.expires_in !== undefined && + (typeof body.expires_in !== 'number' || + !Number.isFinite(body.expires_in) || + body.expires_in <= 0) + ) { + throw new SimApiError( + 'The authorization server returned an invalid access-token lifetime.', + response.status + ) + } + const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600 + const scope = + typeof body.scope === 'string' ? body.scope : expectedScopes ? expectedScopes.join(' ') : '' + if (expectedScopes) { + const granted = new Set(scope.split(' ').filter(Boolean)) + const unexpected = [...granted].filter((item) => !expectedScopes.includes(item)) + if (!granted.has('offline_access') || !granted.has('api:read') || unexpected.length > 0) { + throw new SimApiError( + 'The authorization server returned scopes that do not match the login request. Nothing was stored.', + response.status + ) + } + } + return { + accessToken: body.access_token, + refreshToken: body.refresh_token, + expiresAt: issuedAt + expiresIn * 1000, + scope, + } +} + +export function grantsWriteAccess(scope: string): boolean { + return scope.split(' ').includes('api:write') +} + +/** Redeems an authorization code with its PKCE verifier. */ +export async function exchangeCode( + endpoint: string, + args: { code: string; redirectUri: string; verifier: string; requestedScopes: readonly string[] }, + signal?: AbortSignal +): Promise { + const issuedAt = Date.now() + const response = await postToken( + endpoint, + TOKEN_PATH, + { + grant_type: 'authorization_code', + client_id: OAUTH_CLIENT_ID, + code: args.code, + redirect_uri: args.redirectUri, + code_verifier: args.verifier, + }, + signal + ) + return readTokens(response, issuedAt, args.requestedScopes) +} + +/** + * Trades a refresh token for a new pair. The server rotates: the token used + * here is dead afterwards, and presenting it again invalidates every access + * and refresh token from this login. Other independently authorized CLI + * logins remain active. Callers serialize local refreshes through the + * credentials lock before reaching this. + */ +export async function refreshTokens( + endpoint: string, + refreshToken: string, + expectedScopes?: readonly string[], + signal?: AbortSignal +): Promise { + const issuedAt = Date.now() + const response = await postToken( + endpoint, + TOKEN_PATH, + { grant_type: 'refresh_token', client_id: OAUTH_CLIENT_ID, refresh_token: refreshToken }, + signal + ) + return readTokens(response, issuedAt, expectedScopes) +} + +/** + * Revokes a refresh token server-side, which also kills every access and + * refresh token in that login family. RFC 7009 answers 200 for an unknown + * token, so this only fails when the server cannot be reached or refuses the + * client. + */ +export async function revokeToken(endpoint: string, token: string): Promise { + const response = await postToken( + endpoint, + REVOKE_PATH, + { token, token_type_hint: 'refresh_token', client_id: OAUTH_CLIENT_ID }, + AbortSignal.timeout(REQUEST_TIMEOUT_MS) + ) + if (!response.ok) { + throw new SimApiError( + `The authorization server refused to revoke the session (HTTP ${response.status}).`, + response.status + ) + } +} + +const PAGE_STYLE = + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;color:#111;background:#fff' + +function callbackPage(title: string, body: string): string { + return `${title}

${title}

${body}

` +} + +interface LoopbackResult { + code: string +} + +/** + * Listens on the loopback interface for one redirect and hands back its code. + * + * `127.0.0.1` rather than `localhost`, as RFC 8252 §7.3 recommends: the name + * can resolve to a non-loopback interface, and the server registers the IP + * literal. Port 0 lets the OS pick, which the server accepts for any loopback + * port; `--callback-port` pins it when an SSH tunnel forwards that same port. + */ +function listenForCallback( + server: Server, + expectedState: string, + signal: AbortSignal | undefined, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (outcome: { ok: true; value: LoopbackResult } | { ok: false; error: Error }) => { + if (settled) return + settled = true + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + server.close() + /** Close keep-alive sockets so a finished login does not wait for Node's timeout. */ + server.closeAllConnections() + if (outcome.ok) resolve(outcome.value) + else reject(outcome.error) + } + const onAbort = () => finish({ ok: false, error: new SimApiError('Login cancelled.', 0) }) + const timer = setTimeout( + () => + finish({ + ok: false, + error: new SimApiError( + `Timed out after ${Math.round(timeoutMs / 60000)} minutes waiting for the browser. Run sim login again, or use --browserless if this terminal's browser cannot reach it.`, + 0 + ), + }), + timeoutMs + ) + signal?.addEventListener('abort', onAbort, { once: true }) + + server.on('request', (request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + if (url.pathname !== CALLBACK_PATH) { + response.writeHead(404, { 'content-type': 'text/plain' }).end('Not found') + return + } + const error = url.searchParams.get('error') + const state = url.searchParams.get('state') + const code = url.searchParams.get('code') + + /** + * Anything on the machine can reach a loopback port, so a request that + * does not carry this login's `state` is answered and ignored rather + * than ending the wait — otherwise any page the user has open could + * cancel their sign-in by fetching the callback. The real browser still + * arrives with the right `state`, or the timeout fires. + */ + if (state !== expectedState) { + response + .writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) + .end( + callbackPage( + 'Sign-in mismatch', + 'This response did not come from the sign-in this terminal started. Return to your terminal.' + ) + ) + return + } + if (error || !code) { + const description = url.searchParams.get('error_description') ?? undefined + response + .writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + .end( + callbackPage('Sign-in cancelled', 'You can close this tab and return to your terminal.') + ) + finish({ + ok: false, + error: + error === 'access_denied' + ? new SimApiError('Sign-in was declined in the browser.', 0) + : new SimApiError( + description + ? redact(description) + : `Sign-in failed (${redact(error ?? 'no code returned')}).`, + 0 + ), + }) + return + } + response + .writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + .end( + callbackPage( + 'Authorization received', + 'Return to your terminal while Sim finishes signing you in.' + ) + ) + finish({ ok: true, value: { code } }) + }) + }) +} + +export interface BrowserLoginOptions { + scopes: readonly string[] + /** Pin the loopback port, for a container that forwards a fixed one. */ + callbackPort?: number + /** Called with the authorize URL once the listener is up, before waiting. */ + onAuthorizeUrl: (url: string) => void + signal?: AbortSignal + timeoutMs?: number +} + +/** + * Runs the whole browser login: listener, authorize URL, callback, exchange. + * Resolves with tokens only after the code has been redeemed, so a caller that + * gets a result holds a working credential. + */ +export async function loginWithBrowser( + endpoint: string, + options: BrowserLoginOptions +): Promise { + requireSecureEndpoint(endpoint) + + const pkce = createPkce() + const server = createServer() + + await new Promise((resolve, reject) => { + server.once('error', (cause) => { + /** A pinned port may already be occupied or privileged, so report an actionable error. */ + reject( + new SimApiError( + `Could not listen on the sign-in callback port: ${cause.message}. Pick another --callback-port.`, + 0 + ) + ) + }) + server.listen(options.callbackPort ?? 0, '127.0.0.1', () => { + server.removeAllListeners('error') + resolve() + }) + }) + + const port = (server.address() as AddressInfo).port + const redirectUri = buildRedirectUri(port) + const callbackAbort = new AbortController() + const callbackSignal = options.signal + ? AbortSignal.any([options.signal, callbackAbort.signal]) + : callbackAbort.signal + const pending = listenForCallback( + server, + pkce.state, + callbackSignal, + options.timeoutMs ?? LOGIN_TIMEOUT_MS + ) + + try { + options.onAuthorizeUrl( + buildAuthorizeUrl(endpoint, { redirectUri, scopes: options.scopes, pkce }) + ) + } catch (error) { + callbackAbort.abort() + await pending.catch(() => undefined) + throw error + } + + const { code } = await pending + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) + return exchangeCode( + endpoint, + { code, redirectUri, verifier: pkce.verifier, requestedScopes: options.scopes }, + options.signal ? AbortSignal.any([options.signal, timeout]) : timeout + ) +} + +/** + * Whether this terminal's browser is unlikely to reach a loopback listener on + * this machine: an SSH session, or a Linux box with no display. The signals + * Railway and Stripe use to auto-select their pairing flows; `--browserless` + * forces it and `--callback-port` overrides the guess. + */ +export function isLikelyRemoteSession( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): boolean { + if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) return true + return platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY +} diff --git a/packages/sim-cli/src/auth/refresh.test.ts b/packages/sim-cli/src/auth/refresh.test.ts new file mode 100644 index 00000000000..8a6a42eaef9 --- /dev/null +++ b/packages/sim-cli/src/auth/refresh.test.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readStoredCredential, writeCredentialsProfile } from '../config/profile' +import { refreshStoredOAuth } from './refresh' + +const PROFILE = { name: 'default', endpoint: 'https://sim.test', authProfile: 'default' } +const OAUTH_CONTEXT = { + issuer: 'https://sim.test/api/auth', + loginId: 'login-1', + scope: 'offline_access api:read', +} + +function reply(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status }) as Response +} + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-cli-refresh-')) + vi.stubEnv('SIM_CONFIG_DIR', dir) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('refreshStoredOAuth', () => { + it('rotates the pair on the server and persists what came back', async () => { + writeCredentialsProfile('default', { + kind: 'oauth', + oauth: { + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: 1, + ...OAUTH_CONTEXT, + }, + }) + const fetchMock = vi.fn(async () => + reply(200, { access_token: 'new-access', refresh_token: 'new-refresh', expires_in: 3600 }) + ) + vi.stubGlobal('fetch', fetchMock) + + const next = await refreshStoredOAuth(PROFILE, { + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: 1, + ...OAUTH_CONTEXT, + }) + + expect(next.refreshToken).toBe('new-refresh') + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(Object.fromEntries(new URLSearchParams(String(init.body)))).toMatchObject({ + grant_type: 'refresh_token', + refresh_token: 'old-refresh', + }) + expect(readStoredCredential('default')).toEqual({ + kind: 'oauth', + oauth: { + accessToken: 'new-access', + refreshToken: 'new-refresh', + expiresAt: next.expiresAt, + ...OAUTH_CONTEXT, + }, + }) + }) + + /** + * The rotation race: a refresh token presented twice revokes the whole + * session server-side. A process that took the lock second must find the + * winner's tokens on disk and use them instead of presenting the dead one. + */ + it('adopts a rotation another process already wrote instead of presenting the dead token', async () => { + writeCredentialsProfile('default', { + kind: 'oauth', + oauth: { + accessToken: 'winner-access', + refreshToken: 'winner-refresh', + expiresAt: 99, + ...OAUTH_CONTEXT, + }, + }) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const next = await refreshStoredOAuth(PROFILE, { + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + expiresAt: 1, + ...OAUTH_CONTEXT, + }) + + expect(next.refreshToken).toBe('winner-refresh') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('names the remedy when the server no longer honours the refresh token', async () => { + writeCredentialsProfile('default', { + kind: 'oauth', + oauth: { accessToken: 'a', refreshToken: 'r', expiresAt: 1, ...OAUTH_CONTEXT }, + }) + vi.stubGlobal('fetch', async () => reply(400, { error: 'invalid_grant' })) + + await expect( + refreshStoredOAuth(PROFILE, { + accessToken: 'a', + refreshToken: 'r', + expiresAt: 1, + ...OAUTH_CONTEXT, + }) + ).rejects.toThrow('Run sim logout --profile default, then sim login --profile default.') + expect(readStoredCredential('default')).toMatchObject({ kind: 'oauth' }) + }) +}) diff --git a/packages/sim-cli/src/auth/refresh.ts b/packages/sim-cli/src/auth/refresh.ts new file mode 100644 index 00000000000..f0e2d30864b --- /dev/null +++ b/packages/sim-cli/src/auth/refresh.ts @@ -0,0 +1,76 @@ +import { + type ResolvedProfile, + readCredentialsProfile, + readStoredOAuth, + type StoredOAuthCredential, + withCredentialsLock, + writeCredentialsProfile, +} from '../config/index' +import { SimApiError } from '../http/client' +import { OAuthTokenError, refreshTokens } from './oauth-flow' + +/** + * Bounded well under the credentials lock's stale window: a hung authorization + * server must not hold the lock long enough for another process to reclaim it + * and race the same single-use refresh token. + */ +const REFRESH_TIMEOUT_MS = 10 * 1000 + +/** + * Renews a stored OAuth login and persists the rotated pair. + * + * Under the credentials lock because the refresh token is single-use and two + * local processes must not race it. After taking the lock the file is read + * again: if another process already rotated the token, its result is adopted + * and no request is made. This coordinates trusted local processes; detecting + * and containing a copied token remains the authorization server's job. + * + * `invalid_grant` means the server no longer honours the refresh token — it + * was revoked from Settings → Authorized apps, expired, or was already rotated + * by a process this one could not see — and the remedy is logout followed by a + * new login. + */ +export async function refreshStoredOAuth( + profile: Pick, + current: StoredOAuthCredential +): Promise { + return withCredentialsLock(async () => { + const stored = readStoredOAuth(readCredentialsProfile(profile.authProfile)) + if (!stored || stored.loginId !== current.loginId) { + throw new SimApiError( + `The stored login changed while this command was waiting to refresh it. Retry the command with the active login for profile ${profile.authProfile}.`, + 401 + ) + } + if (stored.refreshToken !== current.refreshToken) return stored + + let tokens: StoredOAuthCredential + try { + const refreshed = await refreshTokens( + profile.endpoint, + current.refreshToken, + current.scope.split(' ').filter(Boolean), + AbortSignal.timeout(REFRESH_TIMEOUT_MS) + ) + tokens = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + issuer: current.issuer, + loginId: current.loginId, + scope: refreshed.scope, + } + } catch (error) { + if (error instanceof OAuthTokenError && error.oauthError === 'invalid_grant') { + throw new SimApiError( + `Your Sim login expired, was revoked, or detected refresh-token reuse. Run sim logout --profile ${profile.authProfile}, then sim login --profile ${profile.authProfile}.`, + 401 + ) + } + throw error + } + + writeCredentialsProfile(profile.authProfile, { kind: 'oauth', oauth: tokens }) + return tokens + }) +} diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 920ba55b3c7..8da68b7054d 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -9,13 +9,37 @@ const mocks = vi.hoisted(() => ({ listAuthenticationDependents: vi.fn<() => string[]>(() => []), listProfiles: vi.fn<() => string[]>(() => []), request: vi.fn(), + readConfigProfile: vi.fn<() => Record>(() => ({})), readCredentialsProfile: vi.fn<() => Record>(() => ({})), + discoverOAuthProvider: vi.fn( + async () => 'unavailable' as 'available' | 'unavailable' | 'unreachable' + ), + isLikelyRemoteSession: vi.fn(() => false), + requireSecureEndpoint: vi.fn(), + loginWithBrowser: vi.fn(async () => ({ + accessToken: 'sim_oat_access', + refreshToken: 'sim_ort_refresh', + expiresAt: 1_800_000_000_000, + scope: 'offline_access api:read api:write', + })), + revokeToken: vi.fn(async () => undefined), + grantsWriteAccess: vi.fn((scope: string) => scope.split(' ').includes('api:write')), resolveAuthenticationProfileName: vi.fn((profile: string) => profile), - pollForKey: vi.fn(async () => ({ + withCredentialsLock: vi.fn((work: () => Promise) => work()), + pollForKey: vi.fn< + () => Promise<{ + id?: string + apiKey: string + scope: 'platform' | 'copilot' + workspaceBound?: boolean + workspaceId?: string + }> + >(async () => ({ + id: 'key-id', apiKey: 'sim-key', - scope: 'platform' as const, + scope: 'platform', workspaceBound: false, - workspaceId: 'ws_1' as string | undefined, + workspaceId: 'ws_1', })), profileFrom: vi.fn(() => ({ name: 'default', @@ -25,7 +49,7 @@ const mocks = vi.hoisted(() => ({ output: 'table', sources: { endpoint: 'default', - apiKey: 'unset', + credential: 'unset', workspaceId: 'unset', output: 'default', }, @@ -40,6 +64,21 @@ vi.mock('../auth/device-flow', () => ({ createAuthRequest: mocks.createAuthRequest, pollForKey: mocks.pollForKey, })) +/** + * Discovery answers "unavailable" unless a test says otherwise, so the suite + * below keeps exercising the pairing-code handoff it was written against; the + * OAuth-path tests flip it to "available". + */ +vi.mock('../auth/oauth-flow', () => ({ + discoverOAuthProvider: mocks.discoverOAuthProvider, + isLikelyRemoteSession: mocks.isLikelyRemoteSession, + requireSecureEndpoint: mocks.requireSecureEndpoint, + loginWithBrowser: mocks.loginWithBrowser, + revokeToken: mocks.revokeToken, + grantsWriteAccess: mocks.grantsWriteAccess, + OAUTH_SCOPES_FULL: ['offline_access', 'api:read', 'api:write'], + OAUTH_SCOPES_READ_ONLY: ['offline_access', 'api:read'], +})) /** * The validators and the format list come from the real module rather than a * copy: a duplicated pattern here would keep passing if the shipped one were @@ -69,9 +108,35 @@ vi.mock('../config/index', async () => ({ listAuthenticationDependents: mocks.listAuthenticationDependents, listProfiles: mocks.listProfiles, readCredentialsProfile: mocks.readCredentialsProfile, + readConfigProfile: mocks.readConfigProfile, + oauthIssuerForEndpoint: (endpoint: string) => `${endpoint}/api/auth`, + /** + * Derived from the section mock so a test that seeds `{ api_key }` or the + * OAuth keys sees the same credential the shipped reader would. + */ + readStoredCredential: () => { + const section = mocks.readCredentialsProfile() + if (section.access_token && section.refresh_token) { + return { + kind: 'oauth', + oauth: { + accessToken: section.access_token, + refreshToken: section.refresh_token, + expiresAt: Number(section.token_expires_at ?? 0), + issuer: section.oauth_issuer ?? 'https://sim.ai/api/auth', + loginId: section.oauth_login_id ?? 'login-1', + scope: section.oauth_scope ?? 'offline_access api:read api:write', + }, + } + } + return section.api_key ? { kind: 'api_key', apiKey: section.api_key } : null + }, resolveAuthenticationProfileName: mocks.resolveAuthenticationProfileName, writeConfigProfile: mocks.writeConfigProfile, writeCredentialsProfile: mocks.writeCredentialsProfile, + /** The real lock is exercised in profile.test.ts; command tests preserve observable writes. */ + withCredentialsLock: mocks.withCredentialsLock, + withProfileLoginLease: (_profile: string, work: () => Promise) => work(), })) vi.mock('../context', () => ({ globalsOf: (command: Command) => command.optsWithGlobals(), @@ -116,10 +181,15 @@ async function logout(...args: string[]): Promise { await root.parseAsync(['node', 'sim', 'logout', ...args]) } +beforeEach(() => { + mocks.withCredentialsLock.mockImplementation((work) => work()) +}) + describe('login command', () => { beforeEach(() => { vi.clearAllMocks() mocks.listProfiles.mockReturnValue([]) + mocks.readConfigProfile.mockReturnValue({}) mocks.readCredentialsProfile.mockReturnValue({}) mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile) mocks.profileFrom.mockReturnValue({ @@ -130,12 +200,13 @@ describe('login command', () => { output: 'table', sources: { endpoint: 'default', - apiKey: 'unset', + credential: 'unset', workspaceId: 'unset', output: 'default', }, }) mocks.pollForKey.mockResolvedValue({ + id: 'key-id', apiKey: 'sim-key', scope: 'platform', workspaceBound: false, @@ -184,7 +255,7 @@ describe('login command', () => { output: 'table', sources: { endpoint: 'config', - apiKey: 'credentials', + credential: 'credentials', workspaceId: 'config', output: 'default', }, @@ -220,7 +291,7 @@ describe('login command', () => { await login() expect(question).toHaveBeenCalledWith( - 'Profile "default" already exists. Replace its API key and login defaults? (y/N) ' + 'Profile "default" already exists. Replace its login and defaults? (y/N) ' ) expect(close).toHaveBeenCalledOnce() expect(mocks.createAuthRequest).toHaveBeenCalledOnce() @@ -252,9 +323,7 @@ describe('login command', () => { expect(mocks.createAuthRequest).toHaveBeenCalledOnce() }) - it('writes the endpoint before the key, so a failed write cannot strand one', async () => { - // A key on disk with no endpoint beside it falls back to the default host - // on the next command, which would send a self-hosted key elsewhere. + it('clears the previous key before changing its endpoint', async () => { setInteractive(false) const order: string[] = [] mocks.writeConfigProfile.mockImplementation(() => { @@ -266,7 +335,31 @@ describe('login command', () => { await login() - expect(order).toEqual(['config', 'credentials']) + expect(order).toEqual(['credentials', 'config', 'credentials']) + }) + + it('restores settings and reports a minted handoff key when credential storage fails', async () => { + setInteractive(false) + mocks.writeCredentialsProfile + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error('credentials disk full') + }) + + await expect(login()).rejects.toThrow('credentials disk full') + + expect(mocks.writeConfigProfile).toHaveBeenNthCalledWith(1, 'default', { + endpoint: 'https://sim.ai', + workspace: 'ws_1', + }) + expect(mocks.writeConfigProfile).toHaveBeenNthCalledWith(2, 'default', { + endpoint: null, + workspace: null, + }) + expect(mocks.writeCredentialsProfile).toHaveBeenLastCalledWith('default', null) + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'API key key-id was created but could not be stored safely' + ) }) it('stores nothing when the server answers with an unstorable workspace id', async () => { @@ -293,7 +386,7 @@ describe('login command', () => { workspaceId: 'ws_1', }) - await expect(login()).rejects.toThrow('malformed API key') + await expect(login()).rejects.toThrow('malformed credential') expect(mocks.writeConfigProfile).not.toHaveBeenCalled() expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() @@ -316,7 +409,7 @@ describe('login command', () => { workspaceId: 'ws_1', }) - await expect(login()).rejects.toThrow('malformed API key') + await expect(login()).rejects.toThrow('malformed credential') expect(mocks.writeConfigProfile).not.toHaveBeenCalled() expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() @@ -332,7 +425,7 @@ describe('login command', () => { output: 'table', sources: { endpoint: 'default', - apiKey: 'unset', + credential: 'unset', workspaceId: 'config', output: 'default', }, @@ -363,7 +456,7 @@ describe('login command', () => { output: 'table', sources: { endpoint: 'default', - apiKey: 'unset', + credential: 'unset', workspaceId: 'config', output: 'default', }, @@ -396,7 +489,7 @@ describe('profiles command', () => { output: 'table', sources: { endpoint: 'config', - apiKey: 'credentials', + credential: 'credentials', workspaceId: 'config', output: 'default', }, @@ -439,6 +532,61 @@ describe('profiles command', () => { expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() }) + it('refuses to create a dangling alias when logout wins the credential lock', async () => { + mocks.withCredentialsLock.mockImplementationOnce(async (work) => { + mocks.readCredentialsProfile.mockReturnValue({}) + return work() + }) + + await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( + 'the active login is not stored' + ) + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('refuses to bind a workspace selected with a credential replaced before commit', async () => { + mocks.withCredentialsLock.mockImplementationOnce(async (work) => { + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'replacement-key' }) + return work() + }) + + await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( + 'changed while the workspace was being selected' + ) + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('accepts a normal OAuth refresh while selecting the workspace', async () => { + const initial = { + access_token: 'sim_oat_initial', + refresh_token: 'sim_ort_initial', + token_expires_at: '1800000000000', + oauth_issuer: 'https://sim.ai/api/auth', + oauth_login_id: 'stable-login', + oauth_scope: 'offline_access api:read api:write', + } + const refreshed = { + ...initial, + access_token: 'sim_oat_refreshed', + refresh_token: 'sim_ort_refreshed', + token_expires_at: '1800003600000', + } + mocks.readCredentialsProfile.mockReturnValue(initial) + mocks.request.mockImplementationOnce(async () => { + mocks.readCredentialsProfile.mockReturnValue(refreshed) + return { data: { id: 'ws_acme', name: 'Acme', memberCount: 3 } } + }) + + await profiles('add', 'acme', '--workspace', 'ws_acme') + + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('acme', { + auth_profile: 'default', + workspace: 'ws_acme', + }) + }) + it('does not write a profile when the active key cannot reach the workspace', async () => { mocks.request.mockRejectedValue(new SimApiError('Workspace not found', 404)) @@ -457,7 +605,7 @@ describe('profiles command', () => { output: 'table', sources: { endpoint: 'config', - apiKey: 'credentials', + credential: 'credentials', workspaceId: 'config', output: 'default', }, @@ -481,14 +629,14 @@ describe('profiles command', () => { output: 'table', sources: { endpoint: 'default', - apiKey: 'env', + credential: 'env', workspaceId: 'unset', output: 'default', }, }) await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( - 'the active API key is not stored' + 'the active login is not stored' ) expect(mocks.request).not.toHaveBeenCalled() expect(mocks.writeConfigProfile).not.toHaveBeenCalled() @@ -503,7 +651,7 @@ describe('profiles command', () => { output: 'table', sources: { endpoint: 'env', - apiKey: 'credentials', + credential: 'credentials', workspaceId: 'unset', output: 'default', }, @@ -630,7 +778,7 @@ describe('profiles command', () => { output: 'json', sources: { endpoint: 'config', - apiKey: 'credentials', + credential: 'credentials', workspaceId: 'config', output: 'config', }, @@ -655,7 +803,7 @@ describe('profiles command', () => { output: 'json', sources: { endpoint: 'default', - apiKey: 'unset', + credential: 'unset', workspaceId: 'unset', output: 'config', }, @@ -771,7 +919,7 @@ describe('logout command', () => { output: 'table', sources: { endpoint: 'config', - apiKey: 'credentials', + credential: 'credentials', workspaceId: 'config', output: 'default', }, @@ -782,7 +930,7 @@ describe('logout command', () => { it('does not remove a key through a shared workspace profile', async () => { mocks.resolveAuthenticationProfileName.mockReturnValue('default') - await expect(logout()).rejects.toThrow( + await expect(logout('--profile', 'acme')).rejects.toThrow( 'Log out of the authentication profile instead: sim logout --profile default' ) expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() @@ -806,6 +954,19 @@ describe('logout command', () => { ) expect(mocks.deleteProfile).not.toHaveBeenCalled() }) + + it('rechecks dependents after taking the lock before removing an authentication profile', async () => { + mocks.withCredentialsLock.mockImplementationOnce(async (work) => { + mocks.listAuthenticationDependents.mockReturnValue(['acme']) + return work() + }) + + await expect(logout('--all', '--profile', 'default')).rejects.toThrow( + 'Cannot remove authentication profile "default" because it is used by: acme.' + ) + + expect(mocks.deleteProfile).not.toHaveBeenCalled() + }) }) describe('whoami command', () => { @@ -820,7 +981,7 @@ describe('whoami command', () => { output: 'text', sources: { endpoint: 'default', - apiKey: 'credentials', + credential: 'credentials', workspaceId: 'config', output: 'flag', }, @@ -853,7 +1014,7 @@ describe('whoami command', () => { await whoami() const output = vi.mocked(console.log).mock.calls.flat().join('\n') - expect(output).toContain('API key\tconfigured (credentials)') + expect(output).toContain('Login\tAPI key (credentials)') expect(output).not.toContain('sim_super_secret_value') expect(output).not.toContain('secret') }) @@ -963,7 +1124,7 @@ describe('whoami command', () => { it('exits 1 when no key is configured', async () => { mocks.profileFrom.mockReturnValue( - configured({ apiKey: null, sources: { ...configured().sources, apiKey: 'unset' } }) + configured({ apiKey: null, sources: { ...configured().sources, credential: 'unset' } }) ) await whoami() @@ -1023,3 +1184,354 @@ describe('whoami command', () => { expect(process.exitCode).toBeUndefined() }) }) + +describe('login command — OAuth', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listProfiles.mockReturnValue([]) + mocks.readConfigProfile.mockReturnValue({}) + mocks.readCredentialsProfile.mockReturnValue({}) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile) + mocks.discoverOAuthProvider.mockResolvedValue('available') + mocks.isLikelyRemoteSession.mockReturnValue(false) + mocks.pollForKey.mockResolvedValue({ + id: 'key-id', + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null, + output: 'table', + sources: { + endpoint: 'default', + credential: 'unset', + workspaceId: 'unset', + output: 'default', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + if (originalIsTTY) Object.defineProperty(process.stdin, 'isTTY', originalIsTTY) + else Reflect.deleteProperty(process.stdin, 'isTTY') + }) + + it('signs in through the browser by default and stores the login, not a key', async () => { + setInteractive(false) + await login() + + expect(mocks.loginWithBrowser).toHaveBeenCalledWith( + 'https://sim.ai', + expect.objectContaining({ + scopes: ['offline_access', 'api:read', 'api:write'], + }) + ) + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('default', { + endpoint: 'https://sim.ai', + }) + expect(mocks.writeCredentialsProfile).toHaveBeenCalledWith('default', { + kind: 'oauth', + oauth: { + accessToken: 'sim_oat_access', + refreshToken: 'sim_ort_refresh', + expiresAt: 1_800_000_000_000, + issuer: 'https://sim.ai/api/auth', + loginId: expect.any(String), + scope: 'offline_access api:read api:write', + }, + }) + }) + + it('keeps a concurrently stored login and revokes the family it could not commit', async () => { + setInteractive(false) + mocks.readCredentialsProfile + .mockReturnValueOnce({}) + .mockReturnValueOnce({}) + .mockReturnValueOnce({ + access_token: 'sim_oat_newer', + refresh_token: 'sim_ort_newer', + token_expires_at: '1800000000001', + oauth_issuer: 'https://sim.ai/api/auth', + oauth_login_id: 'newer-login', + oauth_scope: 'offline_access api:read', + }) + + await expect(login()).rejects.toThrow('changed while sign-in was open') + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + expect(mocks.revokeToken).toHaveBeenCalledWith('https://sim.ai', 'sim_ort_refresh') + }) + + it('keeps a concurrently added authentication-profile link', async () => { + setInteractive(false) + mocks.readConfigProfile + .mockReturnValueOnce({}) + .mockReturnValueOnce({}) + .mockReturnValueOnce({ auth_profile: 'default' }) + + await expect(login()).rejects.toThrow('changed while sign-in was open') + + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.revokeToken).toHaveBeenCalledWith('https://sim.ai', 'sim_ort_refresh') + }) + + it('best-effort revokes a newly issued family when local persistence fails', async () => { + setInteractive(false) + mocks.writeCredentialsProfile.mockImplementationOnce(() => { + throw new Error('disk full') + }) + + await expect(login()).rejects.toThrow('disk full') + expect(mocks.revokeToken).toHaveBeenCalledWith('https://sim.ai', 'sim_ort_refresh') + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + + it('clears the previous credential before changing its endpoint', async () => { + setInteractive(false) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'previous-key' }) + const order: string[] = [] + mocks.writeConfigProfile.mockImplementation(() => { + order.push('config') + }) + mocks.writeCredentialsProfile.mockImplementation((_profile, credential) => { + order.push(credential ? 'credential' : 'clear') + }) + + await login('--yes') + + expect(order).toEqual(['clear', 'config', 'credential']) + }) + + it('restores the previous credential when endpoint persistence fails', async () => { + setInteractive(false) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'previous-key' }) + mocks.writeConfigProfile.mockImplementationOnce(() => { + throw new Error('config disk full') + }) + + await expect(login('--yes')).rejects.toThrow('config disk full') + + expect(mocks.writeCredentialsProfile).toHaveBeenNthCalledWith(1, 'default', null) + expect(mocks.writeCredentialsProfile).toHaveBeenNthCalledWith(2, 'default', { + kind: 'api_key', + apiKey: 'previous-key', + }) + expect(mocks.writeConfigProfile).toHaveBeenNthCalledWith(2, 'default', { endpoint: null }) + expect(mocks.revokeToken).toHaveBeenCalledWith('https://sim.ai', 'sim_ort_refresh') + }) + + it('restores the previous endpoint and credential when OAuth storage fails', async () => { + setInteractive(false) + mocks.readConfigProfile.mockReturnValue({ endpoint: 'https://old.example' }) + mocks.readCredentialsProfile.mockReturnValue({ api_key: 'previous-key' }) + mocks.writeCredentialsProfile + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error('credentials disk full') + }) + + await expect(login('--yes')).rejects.toThrow('credentials disk full') + + expect(mocks.writeConfigProfile).toHaveBeenNthCalledWith(1, 'default', { + endpoint: 'https://sim.ai', + }) + expect(mocks.writeConfigProfile).toHaveBeenNthCalledWith(2, 'default', { + endpoint: 'https://old.example', + }) + expect(mocks.writeCredentialsProfile).toHaveBeenLastCalledWith('default', { + kind: 'api_key', + apiKey: 'previous-key', + }) + expect(mocks.revokeToken).toHaveBeenCalledWith('https://sim.ai', 'sim_ort_refresh') + }) + + it('asks only for the read scope under --read-only', async () => { + setInteractive(false) + await login('--read-only') + + expect(mocks.loginWithBrowser).toHaveBeenCalledWith( + 'https://sim.ai', + expect.objectContaining({ + scopes: ['offline_access', 'api:read'], + }) + ) + }) + + it('pins the loopback port when asked, and refuses an unusable one', async () => { + setInteractive(false) + await login('--callback-port', '8976') + expect(mocks.loginWithBrowser).toHaveBeenCalledWith( + 'https://sim.ai', + expect.objectContaining({ callbackPort: 8976 }) + ) + + await expect(login('--callback-port', '70000')).rejects.toThrow('Invalid --callback-port') + }) + + it('falls back to the pairing code under --browserless', async () => { + setInteractive(false) + await login('--browserless') + + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.pollForKey).toHaveBeenCalledOnce() + }) + + it('falls back to the pairing code in a remote session', async () => { + setInteractive(false) + mocks.isLikelyRemoteSession.mockReturnValue(true) + await login() + + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.pollForKey).toHaveBeenCalledOnce() + }) + + it('uses the pairing code for a copilot-scope key, which only the handoff mints', async () => { + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'copilot', + workspaceBound: false, + workspaceId: undefined, + }) + await login('--scope', 'copilot') + + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.discoverOAuthProvider).not.toHaveBeenCalled() + }) + + it('refuses an unreachable endpoint rather than guessing it lacks the provider', async () => { + setInteractive(false) + mocks.discoverOAuthProvider.mockResolvedValue('unreachable') + + await expect(login()).rejects.toThrow('Could not reach https://sim.ai') + expect(mocks.pollForKey).not.toHaveBeenCalled() + }) + + it('requires logout before replacing a stored OAuth login', async () => { + setInteractive(false) + mocks.readCredentialsProfile.mockReturnValue({ + access_token: 'a', + refresh_token: 'r', + token_expires_at: '1', + }) + + await expect(login()).rejects.toThrow('Run sim logout --profile default') + await expect(login('--yes')).rejects.toThrow('Run sim logout --profile default') + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.pollForKey).not.toHaveBeenCalled() + }) +}) + +describe('logout command — OAuth', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listAuthenticationDependents.mockReturnValue([]) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => profile) + mocks.readCredentialsProfile.mockReturnValue({ + access_token: 'sim_oat_a', + refresh_token: 'sim_ort_r', + token_expires_at: '1', + }) + mocks.profileFrom.mockReturnValue({ + name: 'acme', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: 'ws_acme', + output: 'table', + sources: { + endpoint: 'config', + credential: 'credentials', + workspaceId: 'config', + output: 'default', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('revokes the refresh token on the server before forgetting it', async () => { + const order: string[] = [] + mocks.revokeToken.mockImplementation(async () => { + order.push('revoke') + }) + mocks.writeCredentialsProfile.mockImplementation(() => { + order.push('clear') + }) + + await logout('--profile', 'acme') + + expect(mocks.revokeToken).toHaveBeenCalledWith('https://sim.ai', 'sim_ort_r') + expect(order).toEqual(['revoke', 'clear']) + expect(mocks.writeCredentialsProfile).toHaveBeenCalledWith('acme', null) + }) + + it('still clears the machine when the server cannot be reached, and says so', async () => { + mocks.revokeToken.mockRejectedValue(new Error('ECONNREFUSED')) + + await logout('--profile', 'acme') + + expect(mocks.writeCredentialsProfile).toHaveBeenCalledWith('acme', null) + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('Could not revoke the login') + }) + + it('revokes against the stored issuer even when the configured endpoint changed', async () => { + mocks.readCredentialsProfile.mockReturnValue({ + access_token: 'sim_oat_a', + refresh_token: 'sim_ort_r', + token_expires_at: '1', + oauth_issuer: 'https://original.example/api/auth', + oauth_login_id: 'login-1', + oauth_scope: 'offline_access api:read', + }) + mocks.profileFrom.mockImplementation(() => { + throw new ProfileConfigError('issuer mismatch') + }) + + await logout('--profile', 'acme') + + expect(mocks.profileFrom).not.toHaveBeenCalled() + expect(mocks.revokeToken).toHaveBeenCalledWith('https://original.example', 'sim_ort_r') + expect(mocks.writeCredentialsProfile).toHaveBeenCalledWith('acme', null) + }) + + it('does not contact or print credentials embedded in a hand-edited issuer', async () => { + mocks.readCredentialsProfile.mockReturnValue({ + access_token: 'sim_oat_a', + refresh_token: 'sim_ort_r', + token_expires_at: '1', + oauth_issuer: 'https://user:password@example.com/api/auth', + oauth_login_id: 'login-1', + oauth_scope: 'offline_access api:read', + }) + + await logout('--profile', 'acme') + + expect(mocks.revokeToken).not.toHaveBeenCalled() + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).not.toContain('user') + expect(output).not.toContain('password') + expect(output).toContain('https://example.com/api/auth') + }) + + it('removes terminal controls from an invalid stored issuer error', async () => { + mocks.readCredentialsProfile.mockReturnValue({ + access_token: 'sim_oat_a', + refresh_token: 'sim_ort_r', + token_expires_at: '1', + oauth_issuer: 'bad\u001b[31m-issuer', + oauth_login_id: 'login-1', + oauth_scope: 'offline_access api:read', + }) + + await logout('--profile', 'acme') + + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).not.toContain('\u001b') + }) +}) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 1316dd7c027..103dd7c0fc8 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -1,5 +1,7 @@ import { spawn } from 'node:child_process' +import { randomBytes } from 'node:crypto' import { createInterface } from 'node:readline/promises' +import { getErrorMessage } from '@sim/utils/errors' import chalk from 'chalk' import { Command } from 'commander' import { @@ -8,6 +10,16 @@ import { createAuthRequest, pollForKey, } from '../auth/device-flow' +import { + discoverOAuthProvider, + grantsWriteAccess, + isLikelyRemoteSession, + loginWithBrowser, + OAUTH_SCOPES_FULL, + OAUTH_SCOPES_READ_ONLY, + requireSecureEndpoint, + revokeToken, +} from '../auth/oauth-flow' import { configPath, credentialsPath, @@ -19,12 +31,18 @@ import { normalizeWorkspaceId, OUTPUT_FORMATS, type OutputFormat, + oauthIssuerForEndpoint, ProfileConfigError, type ResolvedProfile, - readCredentialsProfile, + readConfigProfile, + readStoredCredential, resolveAuthenticationProfileName, type SettingSource, + type StoredCredential, + type StoredOAuthCredential, validateProfileName, + withCredentialsLock, + withProfileLoginLease, writeConfigProfile, writeCredentialsProfile, } from '../config/index' @@ -85,7 +103,7 @@ function presentAuthentication(source: SettingSource): { return { authenticated: false, source: 'unset' } case 'config': case 'default': - throw new SimApiError(`Unexpected API key source "${source}".`, 0) + throw new SimApiError(`Unexpected credential source "${source}".`, 0) } } @@ -100,7 +118,7 @@ async function confirmProfileOverwrite(profileName: string): Promise { const prompt = createInterface({ input: process.stdin, output: process.stderr }) try { const answer = await prompt.question( - `Profile "${redact(profileName)}" already exists. Replace its API key and login defaults? (y/N) ` + `Profile "${redact(profileName)}" already exists. Replace its login and defaults? (y/N) ` ) return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes' } finally { @@ -125,10 +143,10 @@ function validateNewProfileName(profileName: string): void { } /** - * Refuses a minted key the credentials file could not represent. + * Refuses a minted credential the credentials file could not represent. * - * The poll response is remote input, and the deployment answering it is - * whatever the endpoint names. A key carrying a line break would be written + * The response is remote input, and the deployment answering it is + * whatever the endpoint names. A value carrying a line break would be written * verbatim into an escape-less format, so the writer refuses it — this refuses * it one step earlier, before anything is on disk, and says which side is wrong. * @@ -142,15 +160,88 @@ function validateNewProfileName(profileName: string): void { * padding from the credential. Trimming would store a value the server never * issued and turn a loud, explained failure into a 401 on every later command. */ -function requireStorableKey(apiKey: unknown): void { +function requireStorableCredential(value: unknown): void { if ( - typeof apiKey !== 'string' || - !apiKey || - apiKey !== apiKey.trim() || - FORBIDDEN_IN_VALUE.test(apiKey) + typeof value !== 'string' || + !value || + value !== value.trim() || + FORBIDDEN_IN_VALUE.test(value) ) { throw new SimApiError( - 'The server returned a malformed API key. Nothing was stored; check the endpoint.', + 'The server returned a malformed credential. Nothing was stored; check the endpoint.', + 0 + ) + } +} + +/** Compares the credential snapshot taken before an interactive login began. */ +function sameStoredCredential( + current: StoredCredential | null, + expected: StoredCredential | null +): boolean { + if (!current || !expected) return current === expected + if (current.kind !== expected.kind) return false + if (current.kind === 'api_key' && expected.kind === 'api_key') { + return current.apiKey === expected.apiKey + } + if (current.kind !== 'oauth' || expected.kind !== 'oauth') return false + return ( + current.oauth.accessToken === expected.oauth.accessToken && + current.oauth.refreshToken === expected.oauth.refreshToken && + current.oauth.expiresAt === expected.oauth.expiresAt && + current.oauth.issuer === expected.oauth.issuer && + current.oauth.loginId === expected.oauth.loginId && + current.oauth.scope === expected.oauth.scope + ) +} + +/** Whether two snapshots identify the same stored login across a normal OAuth refresh. */ +function sameStoredAuthentication( + current: StoredCredential | null, + expected: StoredCredential | null +): boolean { + if (current?.kind === 'oauth' && expected?.kind === 'oauth') { + return ( + current.oauth.loginId === expected.oauth.loginId && + current.oauth.issuer === expected.oauth.issuer + ) + } + return sameStoredCredential(current, expected) +} + +interface LoginProfileSnapshot { + credential: StoredCredential | null + authProfile: string | null + endpoint: string | null + workspace: string | null +} + +function readLoginProfileSnapshot(profileName: string): LoginProfileSnapshot { + const config = readConfigProfile(profileName) + return { + credential: readStoredCredential(profileName), + authProfile: config.auth_profile ?? null, + endpoint: config.endpoint ?? null, + workspace: config.workspace ?? null, + } +} + +function sameLoginProfileSnapshot( + current: LoginProfileSnapshot, + expected: LoginProfileSnapshot +): boolean { + return ( + sameStoredCredential(current.credential, expected.credential) && + current.authProfile === expected.authProfile && + current.endpoint === expected.endpoint && + current.workspace === expected.workspace + ) +} + +function assertLoginProfileUnchanged(profileName: string, expected: LoginProfileSnapshot): void { + if (!sameLoginProfileSnapshot(readLoginProfileSnapshot(profileName), expected)) { + throw new SimApiError( + `Profile "${redact(profileName)}" changed while sign-in was open. Its newer settings and login were kept.`, 0 ) } @@ -158,10 +249,9 @@ function requireStorableKey(apiKey: unknown): void { function requireStoredAuthentication(profile: ResolvedProfile): string { const authProfile = resolveAuthenticationProfileName(profile.name) - const storedKey = readCredentialsProfile(authProfile).api_key - if (profile.sources.apiKey !== 'credentials' || !storedKey) { + if (profile.sources.credential !== 'credentials' || !readStoredCredential(authProfile)) { throw new SimApiError( - `Cannot create a shared profile from "${redact(profile.name)}": the active API key is not stored. Run: sim login --profile ${redact(authProfile)}`, + `Cannot create a shared profile from "${redact(profile.name)}": the active login is not stored. Run: sim login --profile ${redact(authProfile)}`, 0 ) } @@ -202,11 +292,11 @@ async function chooseWorkspace(client: Pick): Promise 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 instead.`, + `The active credential can access more than ${MAX_INTERACTIVE_WORKSPACES} workspaces, which is too many to show interactively. Pass --workspace instead.`, 0 ) } @@ -242,18 +332,31 @@ function addProfileCommand(): Command { const { client, profile } = clientFrom(command) const authProfile = requireStoredAuthentication(profile) + const credential = readStoredCredential(authProfile) const workspaceId = globalsOf(command).workspace const workspace = workspaceId ? await getWorkspaceById(client, workspaceId) : await chooseWorkspace(client) - - writeConfigProfile(profileName, { - auth_profile: authProfile, - // Server-supplied, exactly like the login response's workspace id, so it - // is checked the same way: the writer would refuse an unstorable one - // anyway, but with a message about the file format rather than the - // response that produced it. - workspace: normalizeWorkspaceId(workspace.id, 'the workspace response'), + const normalizedWorkspaceId = normalizeWorkspaceId(workspace.id, 'the workspace response') + + await withCredentialsLock(async () => { + validateNewProfileName(profileName) + const currentProfile = profileFrom(command) + const currentAuthProfile = requireStoredAuthentication(currentProfile) + if ( + currentAuthProfile !== authProfile || + currentProfile.endpoint !== profile.endpoint || + !sameStoredAuthentication(readStoredCredential(currentAuthProfile), credential) + ) { + throw new SimApiError( + `Profile "${redact(profile.name)}" changed while the workspace was being selected. Its newer settings and login were kept.`, + 0 + ) + } + writeConfigProfile(profileName, { + auth_profile: authProfile, + workspace: normalizedWorkspaceId, + }) }) console.log(chalk.green(`✓ Added profile "${safeOneLine(profileName)}" in ${configPath()}`)) @@ -263,164 +366,451 @@ function addProfileCommand(): Command { }) } -export function loginCommand(): Command { - return new Command('login') - .description('Authorize this terminal and store an API key for the profile') - .option('--scope ', 'Key space to mint from: platform or copilot', 'platform') - .option('--no-browser', 'Print the URL instead of opening a browser') - .option('-y, --yes', 'Overwrite an existing profile without prompting') - .action( - async (options: { scope: string; browser: boolean; yes?: boolean }, command: Command) => { - // `login --profile x` is how a profile comes into existence, so the name - // is allowed to be one resolution would otherwise reject as unknown. - const profile = profileFrom(command, { allowUnknownProfile: true }) - const authProfile = resolveAuthenticationProfileName(profile.name) - - if (authProfile !== profile.name) { - throw new SimApiError( - `Profile "${redact(profile.name)}" shares authentication with "${redact(authProfile)}". Run: sim login --profile ${redact(authProfile)}`, - 0 - ) - } +interface LoginOptions { + scope: string + browser: boolean + browserless?: boolean + readOnly?: boolean + callbackPort?: string + yes?: boolean +} - if (options.scope !== 'platform' && options.scope !== 'copilot') { - throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) - } - const scope = options.scope as CliAuthScope +/** + * Which login to run. + * + * OAuth is the default: it leaves a short-lived, revocable login instead of a + * permanent key. The pairing-code handoff remains for the cases OAuth's + * loopback redirect cannot serve — a terminal whose browser is on another + * machine (`--browserless`, or an SSH session detected), a copilot-scope key + * (which only the handoff mints), and a server without the provider (an older + * Sim, or one with it switched off), which discovery reports before the browser + * opens. An unreachable server is an error, not a fallback: a typo'd endpoint + * must not be mistaken for one that lacks the feature. + * + * `--callback-port` overrides the remote-session guess, because naming the port + * is how someone with an SSH tunnel says the loopback redirect does reach them. + */ +async function chooseLoginFlow( + profile: ResolvedProfile, + options: LoginOptions, + scope: CliAuthScope +): Promise<'oauth' | 'handoff'> { + requireSecureEndpoint(profile.endpoint) + if (options.browserless || scope === 'copilot') return 'handoff' + if (isLikelyRemoteSession() && options.callbackPort === undefined) { + console.log( + chalk.dim( + 'This looks like a remote session, so the browser on this machine cannot finish an OAuth login; using the pairing code instead. Forward a port and pass --callback-port to sign in through the browser anyway.\n' + ) + ) + return 'handoff' + } + const status = await discoverOAuthProvider(profile.endpoint) + if (status === 'unreachable') { + throw new SimApiError(`Could not reach ${profile.endpoint}. Check the endpoint.`, 0) + } + if (status === 'unavailable') { + console.log( + chalk.dim( + `${profile.endpoint} does not offer OAuth sign-in; using the pairing code instead.\n` + ) + ) + return 'handoff' + } + return 'oauth' +} - if (readCredentialsProfile(profile.name).api_key && !options.yes) { - const confirmed = await confirmProfileOverwrite(profile.name) - if (!confirmed) { - console.log(chalk.dim('Login cancelled; the existing profile was not changed.')) - return - } - } +function parseCallbackPort(value: string | undefined): number | undefined { + if (value === undefined) return undefined + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new SimApiError( + `Invalid --callback-port "${redact(value)}". Use a port from 1 to 65535.`, + 0 + ) + } + return port +} - const auth = createAuthRequest() - const url = buildApprovalUrl( - profile.endpoint, - auth, - scope, - profile.workspaceId ?? undefined - ) +/** + * Authorization code + PKCE through the browser; see `oauth-flow.ts`. The + * profile's workspace default is left as it was: the consent page has no + * workspace picker, and `sim configure --set-workspace` is one command away. + */ +async function loginWithOAuth( + profile: ResolvedProfile, + options: LoginOptions, + callbackPort: number | undefined, + expected: LoginProfileSnapshot +): Promise { + console.log( + `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(safeOneLine(profile.name))}` + ) - console.log( - `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(safeOneLine(profile.name))}` + const tokens = await loginWithBrowser(profile.endpoint, { + scopes: options.readOnly ? OAUTH_SCOPES_READ_ONLY : OAUTH_SCOPES_FULL, + callbackPort, + onAuthorizeUrl: (url) => { + console.log(`\n${url}`) + if (options.browser) openBrowser(url) + console.log(chalk.dim('\nWaiting for you to approve in the browser…')) + }, + }) + + try { + requireStorableCredential(tokens.accessToken) + requireStorableCredential(tokens.refreshToken) + + await withCredentialsLock(async () => { + assertLoginProfileUnchanged(profile.name, expected) + const credential: StoredCredential = { + kind: 'oauth', + oauth: { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + issuer: oauthIssuerForEndpoint(profile.endpoint), + loginId: randomBytes(16).toString('base64url'), + scope: tokens.scope, + }, + } + writeCredentialsProfile(profile.name, null) + try { + writeConfigProfile(profile.name, { endpoint: profile.endpoint }) + writeCredentialsProfile(profile.name, credential) + } catch (error) { + try { + writeConfigProfile(profile.name, { endpoint: expected.endpoint }) + writeCredentialsProfile(profile.name, expected.credential) + } catch (rollbackError) { + try { + writeCredentialsProfile(profile.name, null) + } catch {} + console.log( + chalk.yellow( + `Could not restore the previous profile safely (${safeOneLine(getErrorMessage(rollbackError))}). Its local login was cleared to avoid using it against the wrong endpoint. The new server login will still be revoked.` + ) + ) + } + throw error + } + }) + } catch (error) { + try { + await revokeToken(profile.endpoint, tokens.refreshToken) + } catch (revocationError) { + console.log( + chalk.yellow( + `Could not revoke the uncommitted login (${safeOneLine(getErrorMessage(revocationError))}). Revoke Sim CLI in Settings → Authorized apps.` ) - console.log(`\nPairing code: ${chalk.bold(auth.pairing)}`) - console.log( - chalk.dim('Confirm this code matches what the browser shows before approving.\n') + ) + } + throw error + } + + console.log(chalk.green(`\n✓ Logged in. Login stored in ${credentialsPath()}`)) + /** + * Read back from the granted scope rather than the requested flag. The + * authorization server decides what it issued, and a person can narrow the + * grant on the consent page, so `--read-only` is a request and this is the + * answer. + */ + console.log( + chalk.dim( + grantsWriteAccess(tokens.scope) + ? ' Renews itself; revoke it any time in Settings → Authorized apps, or with: sim logout' + : ' Read-only login — commands that change anything will be refused.' + ) + ) + if (!profile.workspaceId) { + console.log( + chalk.dim(' No default workspace. Set one with: sim configure --set-workspace ') + ) + } +} + +export function loginCommand(): Command { + return new Command('login') + .description('Sign in through the browser and store the login for the profile') + .option( + '--scope ', + 'Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow', + 'platform' + ) + .option('--no-browser', 'Print the URL instead of opening a browser') + .option( + '--browserless', + 'Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers)' + ) + .option('--read-only', 'Ask only for permission to read, never to change anything') + .option('--callback-port ', 'Pin the local port the browser returns to') + .option('-y, --yes', 'Overwrite an existing API-key profile without prompting') + .action(async (options: LoginOptions, command: Command) => { + /** Login may name the profile it is about to create. */ + const profile = profileFrom(command, { allowUnknownProfile: true }) + const authProfile = resolveAuthenticationProfileName(profile.name) + + if (authProfile !== profile.name) { + throw new SimApiError( + `Profile "${redact(profile.name)}" shares authentication with "${redact(authProfile)}". Run: sim login --profile ${redact(authProfile)}`, + 0 ) - console.log(url) + } - if (options.browser) openBrowser(url) - console.log(chalk.dim('\nWaiting for approval…')) + if (options.scope !== 'platform' && options.scope !== 'copilot') { + throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) + } + const scope = options.scope as CliAuthScope - const key = await pollForKey(profile.endpoint, auth) + const snapshot = readLoginProfileSnapshot(profile.name) + const storedCredential = snapshot.credential + if (storedCredential?.kind === 'oauth') { + throw new SimApiError( + `Profile "${redact(profile.name)}" already has an OAuth login. Run sim logout --profile ${redact(profile.name)} before signing in again.`, + 0 + ) + } + if (storedCredential && !options.yes) { + const confirmed = await confirmProfileOverwrite(profile.name) + if (!confirmed) { + console.log(chalk.dim('Login cancelled; the existing profile was not changed.')) + return + } + } - if (key.scope !== scope) { - // The approval, not the request, decides the scope. Storing a copilot - // key where a platform key belongs would fail every later call with an - // unexplained 401, so refuse now with the reason. + /** Validate a pinned port before opening a browser or starting either flow. */ + const callbackPort = parseCallbackPort(options.callbackPort) + + const loginFlow = await chooseLoginFlow(profile, options, scope) + /** + * Neither flag has a meaning in the handoff: it mints a permanent, + * full-power API key on the server and never opens a local listener. + * Honouring `--read-only` by ignoring it would hand back the opposite of + * what was asked for, so the whole login stops here rather than storing a + * credential the person did not agree to. + */ + if (loginFlow === 'handoff') { + if (options.readOnly) { throw new SimApiError( - `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, + 'The pairing-code handoff cannot issue a read-only login; it mints a full API key. Drop --read-only, or sign in through the browser.', 0 ) } - - // The workspace picked in the browser becomes the profile's default, - // whether or not the key is scoped to it. The user chose it by name — - // making them look up its id afterwards would waste the one moment the - // answer was already on screen. It arrives off the wire, so it is - // checked before either file is touched. - // - // Absence is the whole of "no workspace" here, and it is a legitimate - // outcome — a personal key with nothing selected in the browser. A - // *present* value is a workspace id, so every one of them goes to - // `normalizeWorkspaceId` to be accepted or refused by name. Testing - // truthiness instead let an empty string through the absent branch, so - // a malformed response was quietly stored as "no workspace" rather than - // reported. - const settings: Record = { - endpoint: profile.endpoint, - workspace: - key.workspaceId == null - ? null - : normalizeWorkspaceId(key.workspaceId, 'the login response'), + if (callbackPort !== undefined) { + throw new SimApiError( + 'The pairing-code handoff has no local callback, so --callback-port does not apply. Drop it, or sign in through the browser.', + 0 + ) } - requireStorableKey(key.apiKey) + } + await withProfileLoginLease(profile.name, async () => { + assertLoginProfileUnchanged(profile.name, snapshot) + if (loginFlow === 'oauth') { + await loginWithOAuth(profile, options, callbackPort, snapshot) + return + } + await loginWithHandoff(profile, options, scope, snapshot) + }) + }) +} - // Config before credentials: the endpoint decides where the key is sent - // later. Storing the key first and then failing on the settings left a - // key on disk with no endpoint beside it, so the next command fell back - // to the default host — sending a self-hosted key somewhere else. - writeConfigProfile(profile.name, settings) - writeCredentialsProfile(profile.name, key.apiKey) +/** The pairing-code handoff, which mints a permanent API key; see `device-flow.ts`. */ +async function loginWithHandoff( + profile: ResolvedProfile, + options: LoginOptions, + scope: CliAuthScope, + expected: LoginProfileSnapshot +): Promise { + const auth = createAuthRequest() + const url = buildApprovalUrl(profile.endpoint, auth, scope, profile.workspaceId ?? undefined) + + console.log( + `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(safeOneLine(profile.name))}` + ) + console.log(`\nPairing code: ${chalk.bold(auth.pairing)}`) + console.log(chalk.dim('Confirm this code matches what the browser shows before approving.\n')) + console.log(url) - console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) - if (key.workspaceBound && key.workspaceId) { - console.log(chalk.dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) - } else if (key.workspaceId) { - console.log( - chalk.dim( - ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` - ) - ) - } else { + if (options.browser) openBrowser(url) + console.log(chalk.dim('\nWaiting for approval…')) + + const key = await pollForKey(profile.endpoint, auth) + try { + if (key.scope !== scope) { + throw new SimApiError( + `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, + 0 + ) + } + + const settings: Record = { + endpoint: profile.endpoint, + workspace: + key.workspaceId == null + ? null + : normalizeWorkspaceId(key.workspaceId, 'the login response'), + } + requireStorableCredential(key.apiKey) + + await withCredentialsLock(async () => { + assertLoginProfileUnchanged(profile.name, expected) + writeCredentialsProfile(profile.name, null) + try { + writeConfigProfile(profile.name, settings) + writeCredentialsProfile(profile.name, { kind: 'api_key', apiKey: key.apiKey }) + } catch (error) { + try { + writeConfigProfile(profile.name, { + endpoint: expected.endpoint, + workspace: expected.workspace, + }) + writeCredentialsProfile(profile.name, expected.credential) + } catch (rollbackError) { + try { + writeCredentialsProfile(profile.name, null) + } catch {} console.log( - chalk.dim( - ' Personal key with no default workspace. Set one with: sim configure --set-workspace ' + chalk.yellow( + `Could not restore the previous profile safely (${safeOneLine(getErrorMessage(rollbackError))}). Its local login was cleared to avoid using it against the wrong endpoint.` ) ) } + throw error } + }) + } catch (error) { + const keyId = typeof key.id === 'string' && key.id ? safeOneLine(key.id) : 'unknown' + console.log( + chalk.yellow( + `API key ${keyId} was created but could not be stored safely. Revoke it in Settings → API keys.` + ) + ) + throw error + } + + console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) + if (key.workspaceBound && key.workspaceId) { + console.log(chalk.dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) + } else if (key.workspaceId) { + console.log( + chalk.dim( + ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` + ) + ) + } else { + console.log( + chalk.dim( + ' Personal key with no default workspace. Set one with: sim configure --set-workspace ' + ) ) + } +} + +/** + * Revokes the complete server-side token family before forgetting it locally. + * Settings → Authorized apps is broader: it revokes every independent login + * for the client. An unreachable server must not stop someone clearing their + * machine, but it is said out loud so nobody assumes revocation succeeded. + */ +async function revokeStoredOAuth(credential: StoredOAuthCredential): Promise { + let displayEndpoint = safeOneLine(redact(credential.issuer)) + try { + const issuer = new URL(credential.issuer) + const displayIssuer = new URL(issuer) + displayIssuer.username = '' + displayIssuer.password = '' + displayEndpoint = safeOneLine(displayIssuer.toString()) + if ( + FORBIDDEN_IN_VALUE.test(credential.issuer) || + (issuer.protocol !== 'http:' && issuer.protocol !== 'https:') || + issuer.username || + issuer.password || + issuer.search || + issuer.hash || + !issuer.pathname.endsWith('/api/auth') + ) { + throw new Error('stored issuer is invalid') + } + issuer.pathname = issuer.pathname.slice(0, -'/api/auth'.length) || '/' + const endpoint = issuer.toString().replace(/\/$/, '') + displayEndpoint = safeOneLine(endpoint) + await revokeToken(endpoint, credential.refreshToken) + console.log(chalk.dim(' Signed out of Sim; every token from this login was revoked.')) + } catch (error) { + console.log( + chalk.yellow( + ` Could not revoke the login on ${displayEndpoint} (${safeOneLine(getErrorMessage(error))}). Revoke it in Settings → Authorized apps.` + ) + ) + } } export function logoutCommand(): Command { return new Command('logout') - .description("Remove the profile's stored API key") + .description("Sign out and remove the profile's stored login") .option('--all', 'Remove the profile entirely, including its settings') - .action((options: { all?: boolean }, command: Command) => { + .action(async (options: { all?: boolean }, command: Command) => { if (options.all) { const profileName = selectedProfileName(command) - const dependents = listAuthenticationDependents(profileName) - if (dependents.length > 0) { - throw new SimApiError( - `Cannot remove authentication profile "${redact(profileName)}" because it is used by: ${dependents.map(redact).join(', ')}. Remove those profiles first.`, - 0 - ) - } - const removed = deleteProfile(profileName) + const { removed, credential } = await withCredentialsLock(async () => { + const dependents = listAuthenticationDependents(profileName) + if (dependents.length > 0) { + throw new SimApiError( + `Cannot remove authentication profile "${redact(profileName)}" because it is used by: ${dependents.map(redact).join(', ')}. Remove those profiles first.`, + 0 + ) + } + const credential = readStoredCredential(profileName) + if (credential?.kind === 'oauth') { + await revokeStoredOAuth(credential.oauth) + } + return { removed: deleteProfile(profileName), credential } + }) if (!removed.config && !removed.credentials) { console.log(chalk.dim(`Nothing stored for profile "${safeOneLine(profileName)}".`)) return } console.log(chalk.green(`✓ Removed profile "${safeOneLine(profileName)}".`)) + if (credential?.kind === 'api_key') { + console.log( + chalk.dim(' The key itself is still active — revoke it in Settings → API keys.') + ) + } return } - const profile = profileFrom(command) - const authProfile = resolveAuthenticationProfileName(profile.name) - if (authProfile !== profile.name) { + const profileName = selectedProfileName(command) + const authProfile = resolveAuthenticationProfileName(profileName) + if (authProfile !== profileName) { throw new SimApiError( - `Profile "${redact(profile.name)}" shares authentication with "${redact(authProfile)}". Log out of the authentication profile instead: sim logout --profile ${redact(authProfile)}`, + `Profile "${redact(profileName)}" shares authentication with "${redact(authProfile)}". Log out of the authentication profile instead: sim logout --profile ${redact(authProfile)}`, 0 ) } - if (!readCredentialsProfile(profile.name).api_key) { - console.log(chalk.dim(`No stored key for profile "${safeOneLine(profile.name)}".`)) + const credential = await withCredentialsLock(async () => { + const credential = readStoredCredential(profileName) + if (!credential) return null + if (credential.kind === 'oauth') { + await revokeStoredOAuth(credential.oauth) + } + writeCredentialsProfile(profileName, null) + return credential + }) + if (!credential) { + console.log(chalk.dim(`No stored login for profile "${safeOneLine(profileName)}".`)) return } - writeCredentialsProfile(profile.name, null) console.log( - chalk.green(`✓ Removed the stored key for profile "${safeOneLine(profile.name)}".`) + chalk.green(`✓ Removed the stored login for profile "${safeOneLine(profileName)}".`) ) - // The key still exists server-side; leaving that unsaid invites the - // assumption that logging out revoked it. - console.log(chalk.dim(' The key itself is still active — revoke it in Settings → API keys.')) + if (credential.kind === 'api_key') { + /** Local API-key removal cannot revoke the server-side key. */ + console.log( + chalk.dim(' The key itself is still active — revoke it in Settings → API keys.') + ) + } }) } @@ -520,12 +910,12 @@ async function verifyProfile( client: Pick, profile: ResolvedProfile ): Promise { - if (!profile.apiKey) { + if (!profile.apiKey && !profile.oauth) { return { status: 'unauthenticated', workspace: null, keyType: null, - detail: `no API key — run: sim login --profile ${safeOneLine(profile.name)}`, + detail: `not logged in — run: sim login --profile ${safeOneLine(profile.name)}`, } } @@ -592,7 +982,7 @@ export function whoamiCommand(): Command { .action(async (options: { verify: boolean }, command: Command) => { const { client, profile } = clientFrom(command) const { sources } = profile - const authentication = presentAuthentication(sources.apiKey) + const authentication = presentAuthentication(sources.credential) const verification: Verification = options.verify ? await verifyProfile(client, profile) @@ -612,9 +1002,9 @@ export function whoamiCommand(): Command { ['Profile', profile.name], ['Endpoint', annotate(profile.endpoint, sources.endpoint)], [ - 'API key', + 'Login', authentication.authenticated - ? annotate('configured', authentication.source) + ? annotate(profile.oauth ? 'OAuth' : 'API key', authentication.source) : chalk.yellow('not logged in'), ], [ @@ -683,7 +1073,7 @@ function buildProfileRow(name: string, active: boolean): ProfileRow { return { name, active, - hasKey: Boolean(readCredentialsProfile(authProfile).api_key), + hasKey: readStoredCredential(authProfile) !== null, authProfile, error: null, } diff --git a/packages/sim-cli/src/commands/configure.test.ts b/packages/sim-cli/src/commands/configure.test.ts index 05bb5187098..629c74cd554 100644 --- a/packages/sim-cli/src/commands/configure.test.ts +++ b/packages/sim-cli/src/commands/configure.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listProfiles, readConfigProfile, + withCredentialsLock, writeConfigProfile, writeCredentialsProfile, } from '../config/index' @@ -79,9 +80,50 @@ describe('configure --set-endpoint', () => { expect(readConfigProfile('default')).toMatchObject({ endpoint: 'http://localhost:3000' }) }) + it('rechecks an OAuth binding after taking the credential lock', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.example', + }) + writeConfigProfile('default', { endpoint: 'https://sim.example' }) + + let releaseHolder: (() => void) | undefined + let holderAcquired: (() => void) | undefined + const acquired = new Promise((resolve) => { + holderAcquired = resolve + }) + const release = new Promise((resolve) => { + releaseHolder = resolve + }) + const holder = withCredentialsLock(async () => { + holderAcquired?.() + await release + }) + await acquired + + const configure = run('--set-endpoint', 'https://other.example') + await new Promise((resolve) => setImmediate(resolve)) + writeCredentialsProfile('default', { + kind: 'oauth', + oauth: { + accessToken: 'sim_oat_access', + refreshToken: 'sim_ort_refresh', + expiresAt: Date.now() + 3_600_000, + issuer: 'https://sim.example/api/auth', + loginId: 'login-1', + scope: 'offline_access api:read api:write', + }, + }) + releaseHolder?.() + await holder + + await expect(configure).rejects.toThrow('has an OAuth login bound to') + expect(readConfigProfile('default')).toEqual({ endpoint: 'https://sim.example' }) + }) + it('refuses to set an endpoint locally on a shared workspace profile', async () => { writeConfigProfile('default', { endpoint: 'https://sim.example' }) - writeCredentialsProfile('default', 'stored-key') + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'stored-key' }) writeConfigProfile('acme', { auth_profile: 'default', workspace: 'ws_acme' }) mocks.profileName = 'acme' diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts index 5e1f0c0a999..45b6c85c7b0 100644 --- a/packages/sim-cli/src/commands/configure.ts +++ b/packages/sim-cli/src/commands/configure.ts @@ -3,8 +3,11 @@ import { Command } from 'commander' import { configPath, OUTPUT_FORMATS, + oauthIssuerForEndpoint, readConfigProfile, + readStoredCredential, resolveAuthenticationProfileName, + withCredentialsLock, writeConfigProfile, } from '../config/index' import { @@ -80,7 +83,7 @@ export function configureCommand(): Command { .option('--set-output ', `Default output format (${OUTPUT_FORMATS.join(' | ')})`) .option('--unset ', 'Remove settings (endpoint, workspace, output)') .action( - ( + async ( options: { setEndpoint?: string setWorkspace?: string @@ -106,7 +109,6 @@ export function configureCommand(): Command { // `configure --profile x --set-…` is a documented way to create a // profile, so the name is allowed to be one that does not exist yet. const profile = profileFrom(command, { allowUnknownProfile: true }) - const authProfile = resolveAuthenticationProfileName(profile.name) const updates: Record = {} requireValue(options.setEndpoint, '--set-endpoint', 'endpoint') @@ -114,12 +116,6 @@ export function configureCommand(): Command { requireValue(options.setOutput, '--set-output', 'output') 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) { @@ -142,12 +138,6 @@ export function configureCommand(): Command { 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 } @@ -163,16 +153,43 @@ export function configureCommand(): Command { return } - // An unset against a profile with nothing stored writes nothing — the - // file layer refuses to conjure a section for a removal — so reporting - // an update would claim a change that did not happen. const removalOnly = Object.values(updates).every((value) => value === null) - if (removalOnly && Object.keys(readConfigProfile(profile.name)).length === 0) { + const changed = await withCredentialsLock(async () => { + const authProfile = resolveAuthenticationProfileName(profile.name) + const credential = readStoredCredential(authProfile) + + if (Object.hasOwn(updates, 'endpoint')) { + const endpoint = updates.endpoint + if (authProfile !== profile.name) { + const action = endpoint ? `--set-endpoint ${redact(endpoint)}` : '--unset endpoint' + throw new SimApiError( + `Profile "${redact(profile.name)}" shares its endpoint with authentication profile "${redact(authProfile)}". Run: sim configure --profile ${quoteProfileArgument(authProfile)} ${action}`, + 0 + ) + } + if ( + credential?.kind === 'oauth' && + ((endpoint && oauthIssuerForEndpoint(endpoint) !== credential.oauth.issuer) || + (!endpoint && Object.hasOwn(readConfigProfile(authProfile), 'endpoint'))) + ) { + throw new SimApiError( + `Profile "${redact(profile.name)}" has an OAuth login bound to ${redact(credential.oauth.issuer)}. Run sim logout before ${endpoint ? 'changing' : 'removing'} its endpoint.`, + 0 + ) + } + } + + if (removalOnly && Object.keys(readConfigProfile(profile.name)).length === 0) { + return false + } + writeConfigProfile(profile.name, updates) + return true + }) + + if (!changed) { console.log(chalk.dim(`No settings stored for profile "${profile.name}".`)) return } - - writeConfigProfile(profile.name, updates) console.log(chalk.green(`✓ Updated profile "${profile.name}" in ${configPath()}`)) } ) diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 414ea6abfee..e7256a7ae77 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -10,16 +10,23 @@ export { normalizeWorkspaceId, OUTPUT_FORMATS, type OutputFormat, + oauthIssuerForEndpoint, PROFILE_NAME_PATTERN, ProfileConfigError, type ProfileOverrides, type ResolvedProfile, readConfigProfile, readCredentialsProfile, + readStoredCredential, + readStoredOAuth, resolveAuthenticationProfileName, resolveProfile, type SettingSource, + type StoredCredential, + type StoredOAuthCredential, validateProfileName, + withCredentialsLock, + withProfileLoginLease, writeConfigProfile, writeCredentialsProfile, } from './profile' diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 26974605938..f16b9d41db1 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -1,7 +1,17 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { sleep } from '../helpers' import { configPath, credentialsPath } from './paths' import { DEFAULT_ENDPOINT, @@ -11,9 +21,12 @@ import { listProfiles, OUTPUT_FORMATS, ProfileOverrideError, + readStoredCredential, resolveAuthenticationProfileName, resolveProfile, validateProfileName, + withCredentialsLock, + withProfileLoginLease, writeConfigProfile, writeCredentialsProfile, } from './profile' @@ -40,23 +53,23 @@ describe('profile resolution', () => { expect(profile.endpoint).toBe('https://www.sim.ai') expect(profile.apiKey).toBeNull() expect(profile.output).toBe('table') - expect(profile.sources.apiKey).toBe('unset') + expect(profile.sources.credential).toBe('unset') }) it('reads settings and credentials for the default profile', () => { writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_1' }) - writeCredentialsProfile('default', 'sim_key') + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'sim_key' }) const profile = resolveProfile() expect(profile.endpoint).toBe('https://a.example') expect(profile.workspaceId).toBe('ws_1') expect(profile.apiKey).toBe('sim_key') - expect(profile.sources).toMatchObject({ endpoint: 'config', apiKey: 'credentials' }) + expect(profile.sources).toMatchObject({ endpoint: 'config', credential: 'credentials' }) }) it('namespaces a non-default profile as [profile x] in config but [x] in credentials', () => { writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) - writeCredentialsProfile('dev', 'sim_dev') + writeCredentialsProfile('dev', { kind: 'api_key', apiKey: 'sim_dev' }) expect(readFileSync(configPath(), 'utf8')).toContain('[profile dev]') expect(readFileSync(credentialsPath(), 'utf8')).toContain('[dev]') @@ -65,9 +78,9 @@ describe('profile resolution', () => { it('keeps profiles isolated from one another', () => { writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_a' }) - writeCredentialsProfile('default', 'key_a') + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'key_a' }) writeConfigProfile('dev', { endpoint: 'http://localhost:3000', workspace: 'ws_b' }) - writeCredentialsProfile('dev', 'key_b') + writeCredentialsProfile('dev', { kind: 'api_key', apiKey: 'key_b' }) expect(resolveProfile()).toMatchObject({ workspaceId: 'ws_a', apiKey: 'key_a' }) expect(resolveProfile({ profile: 'dev' })).toMatchObject({ @@ -78,7 +91,7 @@ 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') + writeCredentialsProfile('dev', { kind: 'api_key', apiKey: 'key_dev' }) expect(resolveAuthenticationProfileName('dev')).toBe('dev') expect(resolveProfile({ profile: 'dev' })).toMatchObject({ @@ -94,7 +107,7 @@ describe('profile resolution', () => { workspace: 'ws_default', output: 'yaml', }) - writeCredentialsProfile('default', 'key_default') + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'key_default' }) writeConfigProfile('acme', { auth_profile: 'default', workspace: 'ws_acme', @@ -112,7 +125,7 @@ describe('profile resolution', () => { endpoint: 'config', workspaceId: 'config', output: 'config', - apiKey: 'credentials', + credential: 'credentials', }, }) }) @@ -136,7 +149,7 @@ describe('profile resolution', () => { ) writeConfigProfile('base', { auth_profile: 'root' }) - writeCredentialsProfile('root', 'key_root') + writeCredentialsProfile('root', { kind: 'api_key', apiKey: 'key_root' }) writeConfigProfile('chained', { auth_profile: 'base' }) expect(() => resolveProfile({ profile: 'chained' })).toThrow( 'Profile "chained" references auth_profile "base", which also has auth_profile set.' @@ -144,7 +157,7 @@ describe('profile resolution', () => { }) it('rejects ambiguous local authentication settings on a shared profile', () => { - writeCredentialsProfile('default', 'key_default') + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'key_default' }) writeConfigProfile('endpoint-alias', { auth_profile: 'default', endpoint: 'https://other.example', @@ -154,9 +167,9 @@ describe('profile resolution', () => { ) writeConfigProfile('key-alias', { auth_profile: 'default' }) - writeCredentialsProfile('key-alias', 'key_alias') + writeCredentialsProfile('key-alias', { kind: 'api_key', apiKey: 'key_alias' }) expect(() => resolveProfile({ profile: 'key-alias' })).toThrow( - 'Profile "key-alias" cannot set both auth_profile and its own API key.' + 'Profile "key-alias" cannot set both auth_profile and its own login. Remove one of them.' ) }) @@ -176,7 +189,7 @@ describe('profile resolution', () => { }) it('selects the profile from SIM_PROFILE when no flag is given', () => { - writeCredentialsProfile('dev', 'key_dev') + writeCredentialsProfile('dev', { kind: 'api_key', apiKey: 'key_dev' }) process.env.SIM_PROFILE = 'dev' expect(resolveProfile()).toMatchObject({ name: 'dev', apiKey: 'key_dev' }) expect(resolveProfile({ profile: 'default' }).name).toBe('default') @@ -186,7 +199,7 @@ describe('profile resolution', () => { // A typo used to fall through to the built-in defaults, so `--profile // stagng` talked to https://www.sim.ai and handed it whatever key resolved. writeConfigProfile('staging', { endpoint: 'https://staging.example' }) - writeCredentialsProfile('staging', 'key_staging') + writeCredentialsProfile('staging', { kind: 'api_key', apiKey: 'key_staging' }) expect(() => resolveProfile({ profile: 'stagng' })).toThrow( 'Unknown profile "stagng". Did you mean "staging"? Configured profiles: staging.' @@ -223,7 +236,7 @@ describe('profile resolution', () => { }) it('accepts a profile that exists in only one of the two files', () => { - writeCredentialsProfile('creds-only', 'key') + writeCredentialsProfile('creds-only', { kind: 'api_key', apiKey: 'key' }) writeConfigProfile('config-only', { workspace: 'ws_1' }) expect(resolveProfile({ profile: 'creds-only' }).apiKey).toBe('key') @@ -361,21 +374,21 @@ describe('profile resolution', () => { it('writes credentials 0600 even when the file already existed world-readable', () => { writeFileSync(credentialsPath(), '', { mode: 0o644 }) - writeCredentialsProfile('default', 'sim_key') + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'sim_key' }) expect(statSync(credentialsPath()).mode & 0o777).toBe(0o600) }) it('lists profiles from both files without duplicating', () => { writeConfigProfile('default', { endpoint: 'https://a.example' }) writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) - writeCredentialsProfile('dev', 'key') - writeCredentialsProfile('ci', 'key') + writeCredentialsProfile('dev', { kind: 'api_key', apiKey: 'key' }) + writeCredentialsProfile('ci', { kind: 'api_key', apiKey: 'key' }) expect(listProfiles()).toEqual(['ci', 'default', 'dev']) }) it('lists direct authentication dependents without treating a bad self-reference as one', () => { - writeCredentialsProfile('default', 'key') + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'key' }) writeConfigProfile('acme', { auth_profile: 'default', workspace: 'ws_acme' }) writeConfigProfile('beta', { auth_profile: 'default', workspace: 'ws_beta' }) writeConfigProfile('broken', { auth_profile: 'broken' }) @@ -386,7 +399,7 @@ describe('profile resolution', () => { it('deletes a profile from both files', () => { writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) - writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('dev', { kind: 'api_key', apiKey: 'key' }) expect(deleteProfile('dev')).toEqual({ config: true, credentials: true }) expect(listProfiles()).toEqual([]) @@ -395,7 +408,7 @@ describe('profile resolution', () => { it('clears just the key when the credential is removed', () => { writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) - writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('dev', { kind: 'api_key', apiKey: 'key' }) writeCredentialsProfile('dev', null) expect(resolveProfile({ profile: 'dev' })).toMatchObject({ @@ -475,18 +488,18 @@ describe('config file injection', () => { it('refuses the same through the credentials file', () => { // The credentials reader merges duplicate sections too, so a forged // `[victim]` block there would be read as a real key. - expect(() => writeCredentialsProfile(FORGED_SECTION, 'key_evil')).toThrow( - /Refusing to write a section/ - ) - expect(() => writeCredentialsProfile('default', 'key\napi_key = other')).toThrow( - /Refusing to write a value/ - ) + expect(() => + writeCredentialsProfile(FORGED_SECTION, { kind: 'api_key', apiKey: 'key_evil' }) + ).toThrow(/Refusing to write a section/) + expect(() => + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'key\napi_key = other' }) + ).toThrow(/Refusing to write a value/) expect(existsSync(credentialsPath())).toBe(false) }) it('leaves an ordinary profile name and value writable', () => { writeConfigProfile('staging-1.eu', { endpoint: 'https://staging.example' }) - writeCredentialsProfile('staging-1.eu', 'sim_key') + writeCredentialsProfile('staging-1.eu', { kind: 'api_key', apiKey: 'sim_key' }) expect(resolveProfile({ profile: 'staging-1.eu' })).toMatchObject({ endpoint: 'https://staging.example', @@ -635,3 +648,100 @@ describe('redaction of rejected values', () => { expect(message).not.toMatch(FORBIDDEN_IN_VALUE) }) }) + +describe('OAuth logins in the credentials file', () => { + const OAUTH = { + accessToken: 'sim_oat_a', + refreshToken: 'sim_ort_r', + expiresAt: 1_800_000_000_000, + issuer: 'https://www.sim.ai/api/auth', + loginId: 'login-1', + scope: 'offline_access api:read api:write', + } + + it('stores and resolves an OAuth login, with the file kept private', () => { + writeCredentialsProfile('default', { kind: 'oauth', oauth: OAUTH }) + + const profile = resolveProfile() + expect(profile.oauth).toEqual(OAUTH) + expect(profile.apiKey).toBeNull() + expect(profile.sources.credential).toBe('credentials') + expect(readStoredCredential('default')).toEqual({ kind: 'oauth', oauth: OAUTH }) + expect(statSync(credentialsPath()).mode & 0o777).toBe(0o600) + }) + + it('replaces a stored key when an OAuth login is written, and vice versa', () => { + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'sim_key' }) + writeCredentialsProfile('default', { kind: 'oauth', oauth: OAUTH }) + expect(readFileSync(credentialsPath(), 'utf8')).not.toContain('api_key') + + writeCredentialsProfile('default', { kind: 'api_key', apiKey: 'sim_key_2' }) + const file = readFileSync(credentialsPath(), 'utf8') + expect(file).not.toContain('access_token') + expect(file).not.toContain('refresh_token') + expect(resolveProfile().apiKey).toBe('sim_key_2') + }) + + it('lets an explicit SIM_API_KEY outrank the stored login', () => { + writeCredentialsProfile('default', { kind: 'oauth', oauth: OAUTH }) + process.env.SIM_API_KEY = 'ci_key' + + const profile = resolveProfile() + expect(profile.apiKey).toBe('ci_key') + expect(profile.oauth).toBeNull() + expect(profile.sources.credential).toBe('env') + }) + + it('treats a hand-edited section missing its refresh token as logged out', () => { + writeCredentialsProfile('default', { kind: 'oauth', oauth: OAUTH }) + const file = readFileSync(credentialsPath(), 'utf8').replace(/refresh_token = .*\n/, '') + writeFileSync(credentialsPath(), file) + + expect(readStoredCredential('default')).toBeNull() + expect(resolveProfile().oauth).toBeNull() + }) + + it('serializes credential rewrites through the lock and releases it afterwards', async () => { + const order: string[] = [] + await Promise.all([ + withCredentialsLock(async () => { + order.push('a-start') + await sleep(30) + order.push('a-end') + }), + withCredentialsLock(async () => { + order.push('b-start') + order.push('b-end') + }), + ]) + expect(order).toEqual(['a-start', 'a-end', 'b-start', 'b-end']) + expect(existsSync(`${credentialsPath()}.lock`)).toBe(false) + }) + + it('reclaims a lock left behind by a process that died holding it', async () => { + const lockPath = `${credentialsPath()}.lock` + mkdirSync(lockPath, { mode: 0o700 }) + /** Older than the 30-second stale window, so the holder is presumed gone. */ + const dead = new Date(Date.now() - 60_000) + utimesSync(lockPath, dead, dead) + + await expect(withCredentialsLock(async () => 'ran')).resolves.toBe('ran') + expect(existsSync(lockPath)).toBe(false) + }) + + it('refuses a second interactive login lease for the same profile', async () => { + let releaseFirst!: () => void + const first = withProfileLoginLease( + 'default', + () => new Promise((resolve) => (releaseFirst = resolve)) + ) + await sleep(10) + + await expect(withProfileLoginLease('default', async () => undefined)).rejects.toThrow( + 'Another sim login is already in progress for profile "default".' + ) + + releaseFirst() + await first + }) +}) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 7cbcaeb46d6..14f86a60c90 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -1,5 +1,16 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { AsyncLocalStorage } from 'node:async_hooks' +import { createHash } from 'node:crypto' +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' import { dirname } from 'node:path' +import { lock } from 'proper-lockfile' import { FORBIDDEN_IN_VALUE, getSection, @@ -101,17 +112,49 @@ export function validateProfileName(name: string): void { } } +/** + * An OAuth login stored in the credentials file: the short-lived access token + * the API reads, the rotating refresh token that renews it, and when the + * access token lapses (epoch milliseconds). + */ +export interface StoredOAuthCredential { + accessToken: string + refreshToken: string + expiresAt: number + /** Authorization server that minted the credential. */ + issuer: string + /** Stable across refresh rotation and replaced by a fresh login. */ + loginId: string + /** Scope last returned by the authorization server. */ + scope: string +} + +/** What a credentials section holds for a profile, or `null` when it is logged out. */ +export type StoredCredential = + | { kind: 'api_key'; apiKey: string } + | { kind: 'oauth'; oauth: StoredOAuthCredential } + /** Everything a command needs to make a call, after the resolution chain runs. */ export interface ResolvedProfile { name: string endpoint: string + /** The profile whose credentials section authenticates this one (itself, or its `auth_profile`). */ + authProfile: string + /** + * An API key, from a flag, the environment, or the credentials file. Null + * when the profile authenticates through {@link oauth} instead — the two are + * exclusive: a stored OAuth login wins over a stored key, and an explicit + * `--api-key`/`SIM_API_KEY` wins over both. + */ apiKey: string | null + oauth: StoredOAuthCredential | null workspaceId: string | null output: OutputFormat /** Where each value came from, for `sim whoami` to explain surprising results. */ sources: { endpoint: SettingSource - apiKey: SettingSource + /** Where the profile's credential came from, whichever kind it is. */ + credential: SettingSource workspaceId: SettingSource output: SettingSource } @@ -150,13 +193,24 @@ function readIni(path: string): IniDocument { function writeIni(path: string, doc: IniDocument, secret: boolean): void { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - writeFileSync(path, serializeIni(doc), { mode: secret ? 0o600 : 0o644 }) - // `writeFileSync`'s mode only applies when it creates the file, so an existing - // credentials file written before this ran (or created by a hand `touch`) - // keeps its old, possibly world-readable, permissions without this. - if (secret) chmodSync(path, 0o600) + /** + * Written to a fresh file and renamed into place, so readers never observe a + * partial profile. Credentials are additionally forced to 0600 before the + * rename, including when a hand-created temporary path had wider permissions. + */ + const temporary = `${path}.${process.pid}.${temporaryFileSequence++}.tmp` + try { + writeFileSync(temporary, serializeIni(doc), { mode: secret ? 0o600 : 0o644 }) + if (secret) chmodSync(temporary, 0o600) + renameSync(temporary, path) + } catch (error) { + rmSync(temporary, { force: true }) + throw error + } } +let temporaryFileSequence = 0 + export function readConfigProfile(profile: string): Record { return getSection(readIni(configPath()), configSectionName(profile)) ?? {} } @@ -191,9 +245,9 @@ export function resolveAuthenticationProfileName(profile: string): string { `Profile "${redact(profile)}" cannot set both auth_profile and endpoint. Set the endpoint on authentication profile "${redact(authProfile)}".` ) } - if (readCredentialsProfile(profile).api_key) { + if (readStoredCredential(profile)) { throw new ProfileConfigError( - `Profile "${redact(profile)}" cannot set both auth_profile and its own API key. Remove one of them.` + `Profile "${redact(profile)}" cannot set both auth_profile and its own login. Remove one of them.` ) } @@ -318,22 +372,200 @@ export function writeConfigProfile(profile: string, values: Record = Object.fromEntries( + CREDENTIAL_KEYS.map((key) => [key, null]) + ) + if (credential?.kind === 'api_key') { + values.api_key = credential.apiKey + } else if (credential?.kind === 'oauth') { + values.access_token = credential.oauth.accessToken + values.refresh_token = credential.oauth.refreshToken + values.token_expires_at = String(credential.oauth.expiresAt) + values.oauth_issuer = credential.oauth.issuer + values.oauth_login_id = credential.oauth.loginId + values.oauth_scope = credential.oauth.scope + } + setSectionValues(doc, profile, values) writeIni(credentialsPath(), doc, true) } +/** + * The OAuth login a credentials section holds, or `null` when it holds none or + * only part of one. A hand-edited section missing its refresh token is treated + * as logged out rather than as a login that will fail on its first refresh. + */ +export function readStoredOAuth(credentials: Record): StoredOAuthCredential | null { + const { + access_token: accessToken, + refresh_token: refreshToken, + token_expires_at: expires, + oauth_issuer: issuer, + oauth_login_id: loginId, + oauth_scope: scope, + } = credentials + if (!accessToken || !refreshToken || !issuer || !loginId || !scope) return null + const expiresAt = Number(expires) + return { + accessToken, + refreshToken, + expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0, + issuer, + loginId, + scope, + } +} + +/** The Better Auth issuer mounted below a resolved Sim endpoint. */ +export function oauthIssuerForEndpoint(endpoint: string): string { + return new URL(`${endpoint}/api/auth`).toString().replace(/\/$/, '') +} + +/** What the named profile's own credentials section holds. */ +export function readStoredCredential(profile: string): StoredCredential | null { + const credentials = readCredentialsProfile(profile) + const oauth = readStoredOAuth(credentials) + if (oauth) return { kind: 'oauth', oauth } + if (credentials.api_key) return { kind: 'api_key', apiKey: credentials.api_key } + return null +} + +const CREDENTIALS_LOCK_STALE_MS = 30_000 +/** + * Long enough to outlast the slowest legitimate hold. + * + * The holder is refreshing tokens, which is bounded by the refresh request's + * own 10s timeout plus the write. Waiting less than that made an ordinary slow + * token endpoint — a cold start, a deploy cutover — fail every *other* `sim` + * process outright while the first one was still doing exactly what it should. + * It stays under {@link CREDENTIALS_LOCK_STALE_MS} so a genuinely dead holder + * is still reclaimed rather than waited out. + */ +const CREDENTIALS_LOCK_WAIT_MS = 20_000 +const CREDENTIALS_LOCK_POLL_MS = 50 + +/** + * Serializes credential rewrites across `sim` processes. + * + * A refresh token is single-use: the server rotates it and treats a second + * presentation as theft, revoking every token the CLI holds. Two commands run + * in parallel — a shell loop, a CI matrix, an editor plugin — would each see + * the same expiring token and both try to refresh it, and the loser logs the + * user out everywhere. `proper-lockfile` uses an atomic lock directory and a + * heartbeat, so exactly one process refreshes, the rest re-read what it wrote, + * and an abandoned lock is reclaimed without a hand-rolled compare/delete + * race. + */ +/** + * Whether the current async context already holds the lock. + * + * Re-entrancy has to follow the *call chain*, not the process: a nested write + * inside a refresh must not deadlock on a lock its own caller is holding, while + * two unrelated `withCredentialsLock` calls running concurrently in the same + * process must still serialize. A module-level flag cannot tell those apart — + * `AsyncLocalStorage` can, because only work started inside the holder sees the + * store. + */ +const heldLock = new AsyncLocalStorage() + +/** Prevents two interactive sign-ins from minting credentials for one profile concurrently. */ +export async function withProfileLoginLease( + profile: string, + work: () => Promise +): Promise { + const digest = createHash('sha256').update(profile, 'utf8').digest('hex') + const path = `${credentialsPath()}.login-${digest}` + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + + let release: (() => Promise) | undefined + try { + release = await lock(path, { + realpath: false, + stale: CREDENTIALS_LOCK_STALE_MS, + update: CREDENTIALS_LOCK_STALE_MS / 3, + retries: 0, + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOCKED') { + throw new ProfileConfigError( + `Another sim login is already in progress for profile "${redact(profile)}".` + ) + } + throw error + } + + try { + return await work() + } finally { + await release() + } +} + +export async function withCredentialsLock(work: () => Promise): Promise { + if (heldLock.getStore()) return work() + + const path = credentialsPath() + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + let release: (() => Promise) | undefined + try { + release = await lock(path, { + realpath: false, + stale: CREDENTIALS_LOCK_STALE_MS, + update: CREDENTIALS_LOCK_STALE_MS / 3, + retries: { + retries: Math.ceil(CREDENTIALS_LOCK_WAIT_MS / CREDENTIALS_LOCK_POLL_MS), + factor: 1, + minTimeout: CREDENTIALS_LOCK_POLL_MS, + maxTimeout: CREDENTIALS_LOCK_POLL_MS, + randomize: false, + }, + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOCKED') { + throw new ProfileConfigError( + 'Another sim process is updating the stored login. Wait for it to finish and retry.' + ) + } + throw error + } + + try { + return await heldLock.run(true, work) + } finally { + await release() + } +} + /** Drops the profile from both files. Returns whether anything was removed. */ export function deleteProfile(profile: string): { config: boolean; credentials: boolean } { - const configDoc = readIni(configPath()) - const config = removeSection(configDoc, configSectionName(profile)) - if (config) writeIni(configPath(), configDoc, false) - const credentialsDoc = readIni(credentialsPath()) const credentials = removeSection(credentialsDoc, profile) if (credentials) writeIni(credentialsPath(), credentialsDoc, true) + const configDoc = readIni(configPath()) + const config = removeSection(configDoc, configSectionName(profile)) + if (config) writeIni(configPath(), configDoc, false) + return { config, credentials } } @@ -498,15 +730,24 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil 'default' ) + const normalizedEndpoint = normalizeEndpoint(endpoint.value as string, endpoint.source) + const storedOAuth = readStoredOAuth(credentials) const apiKey = resolve( [ ['flag', overrides.apiKey], ['env', process.env.SIM_API_KEY], - ['credentials', credentials.api_key], + /** Prefer the OAuth login if a hand-edited section contains both credential kinds. */ + ['credentials', storedOAuth ? null : credentials.api_key], ], null, 'unset' ) + const oauth = apiKey.value === null ? storedOAuth : null + if (oauth && oauth.issuer !== oauthIssuerForEndpoint(normalizedEndpoint)) { + throw new ProfileConfigError( + `The stored OAuth login belongs to ${redact(oauth.issuer)}, but this profile resolves to ${redact(normalizedEndpoint)}. OAuth logins cannot be moved between deployments; restore the original endpoint or run sim logout before changing it.` + ) + } const workspaceId = resolve( [ @@ -535,13 +776,15 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil return { name, - endpoint: normalizeEndpoint(endpoint.value as string, endpoint.source), + endpoint: normalizedEndpoint, + authProfile, apiKey: apiKey.value, + oauth, workspaceId: workspaceId.value, output: output.value as OutputFormat, sources: { endpoint: endpoint.source, - apiKey: apiKey.source, + credential: oauth ? 'credentials' : apiKey.source, workspaceId: workspaceId.source, output: output.source, }, diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts index 61cc307a2b4..ef9c9e16447 100644 --- a/packages/sim-cli/src/context.ts +++ b/packages/sim-cli/src/context.ts @@ -1,4 +1,5 @@ import type { Command } from 'commander' +import { refreshStoredOAuth } from './auth/refresh' import { type OutputFormat, type ProfileOverrides, @@ -37,5 +38,5 @@ export function profileFrom(command: Command, extra: ProfileOverrides = {}): Res export function clientFrom(command: Command): { client: SimClient; profile: ResolvedProfile } { const profile = profileFrom(command) - return { client: new SimClient(profile), profile } + return { client: new SimClient(profile, { refreshOAuth: refreshStoredOAuth }), profile } } diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index cc3c41b692d..a76f0df294f 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -380,17 +380,17 @@ describe('records show what the API actually returns', () => { expect(paths).toContain('storage.percentUsed') // Credits and storage are both null for a workspace API key, so the record // has to say why it is showing em-dashes. - expect(spec?.describe).toContain('personal API key') + expect(spec?.describe).toContain('OAuth login or personal API key') }) it('says which ledger a billing-logs page answers', () => { // The same workspace, window and flags return a strict subset of rows on a - // personal key, which reads as a bug beside `billing status`. Read off the + // user credential, which reads as a bug beside `billing status`. Read off the // help the terminal prints, since a describe that never reached a command // would answer nobody. const help = flatHelp('billing', 'logs') - expect(help).toContain('personal API key reports only your own events') + expect(help).toContain('OAuth login or personal API key reports only your events') expect(help).toMatch(/workspace API key reports every member/) }) @@ -612,7 +612,7 @@ describe('help and gates state what is actually true', () => { expect(help).toContain('--organization') expect(help).toContain('defaults to your only organization') - expect(help).toContain('personal API key required') + expect(help).toContain('OAuth login or personal API key required') } }) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index b3ddd963511..f6a75695e53 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -133,7 +133,7 @@ export const CLI_CONTRACT: CliContract = { // fields otherwise render as an unexplained em-dash for exactly the key // most people run the CLI with. describe: - 'Show billing status and current-period credit usage (credits and storage require a personal API key)', + 'Show billing status and current-period credit usage (credits and storage require an OAuth login or personal API key)', fields: [ { header: 'plan' }, { header: 'status' }, @@ -153,19 +153,19 @@ export const CLI_CONTRACT: CliContract = { listBillingLogs: { command: 'billing logs', allWorkspaces: true, - // Which ledger answered depends on the key, and the counts otherwise read + // Which ledger answered depends on the credential, and the counts otherwise read // as a bug next to `billing status`. Said in the describe for the reason // `billing status` says its own caveat. The trailing parenthetical is what // keeps the generated docs heading unchanged. describe: - "List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed)", + "List credit usage events (an OAuth login or personal API key reports only your events; a workspace API key reports every member's in aggregate, unattributed)", flags: { source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, period: { describe: 'Billing period' }, startDate: { describe: 'Custom period start (ISO 8601)' }, endDate: { describe: 'Custom period end (ISO 8601)' }, }, - // Which ledger answered: a personal key reports only the calling user's + // Which ledger answered: a user credential reports only the calling user's // events, a workspace key the whole workspace. The difference was silent — // same workspace, same window, same flags, a strictly smaller result. pageNote: { path: 'scope', label: 'scope' }, @@ -981,7 +981,7 @@ export const CLI_CONTRACT: CliContract = { organizationId: { name: 'organization', describe: - 'Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required)', + 'Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required)', }, }, columns: [ @@ -1000,7 +1000,7 @@ export const CLI_CONTRACT: CliContract = { organizationId: { name: 'organization', describe: - 'Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required)', + 'Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required)', }, }, }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 9ce3f14bf0d..d766c6a8a5a 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -275,9 +275,9 @@ export interface CommandSpec { * A page-envelope field that qualifies the whole list, stated once for the * human formats. * - * `billing logs` answers a different question depending on the kind of API - * key that asked — a personal key sees the caller's own events, a workspace - * key the whole workspace ledger — and the response says which. The value + * `billing logs` answers a different question depending on the credential — + * an OAuth login or personal key sees the caller's own events, while a + * workspace key sees the whole workspace ledger. The response says which. The value * belongs to the query rather than to any row, so it is not a column; it goes * to stderr so that a `--output text` consumer cutting tab-separated fields * still reads only rows. `json` and `yaml` print the unwrapped `data` array diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 5e93eedf761..992e24b672a 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -4357,7 +4357,7 @@ export type GetMetaQuery = Record type GetMetaResponseRef0 = { v2Enabled: boolean - keyType: 'personal' | 'workspace' + keyType: 'personal' | 'workspace' | 'oauth_access_token' expiresAt: string | null } @@ -9409,7 +9409,7 @@ export type UpsertTableRowResponse = { * `summary` is the operation's one-line description, lifted from the OpenAPI * specs so `--help` reuses prose that is already written and already checked. * - * `personalKeyOnly` marks an operation whose spec description says a workspace + * `workspaceKeyUnsupported` marks an operation whose spec says a workspace * API key is rejected, so `--help` can say so before the request is sent. */ export const V2_OPERATIONS = { @@ -9470,7 +9470,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Activate Workflow Version', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, addTableColumn: { method: 'POST', @@ -9517,7 +9517,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Index Workspace Files', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -9539,7 +9539,6 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Apply Workflow Operations', - personalKeyOnly: true, query: { dryRun: { kind: 'boolean', @@ -9645,7 +9644,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Bulk Save Tag Definitions', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -9669,7 +9668,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Bulk Update Chunks', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -9697,7 +9696,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Bulk Enable or Disable Documents', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -9815,7 +9814,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', describe: - 'Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.', }, excludeRowIds: { kind: 'array', describe: 'Rows excluded from an all-scope cancellation.' }, }, @@ -9924,7 +9923,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Credential Connection', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10102,7 +10101,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Create Chunk', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10128,7 +10127,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Create Knowledge Connector', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10244,7 +10243,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Create Tag', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10362,7 +10361,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Sandbox', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10403,7 +10402,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Service-Account Credential', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10443,7 +10442,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Skill', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10506,7 +10505,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', describe: - 'Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.', }, excludeRowIds: { kind: 'array', describe: 'Rows excluded from a select-all run scope.' }, limit: { kind: 'object', describe: 'Optional cap on eligible rows to run.' }, @@ -10662,7 +10661,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Workflow MCP Server', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -10694,7 +10693,7 @@ export const V2_OPERATIONS = { pathParamDocs: { credentialId: 'Credential to disconnect.' }, responseMode: 'json', summary: 'Disconnect Credential', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -10786,7 +10785,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Chunk', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -10805,7 +10804,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Knowledge Connector', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -10877,7 +10876,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Tag', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -10893,7 +10892,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Delete Tag Definitions', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -10929,7 +10928,7 @@ export const V2_OPERATIONS = { pathParamDocs: { sandboxId: 'Unique sandbox identifier.' }, responseMode: 'json', summary: 'Delete Sandbox', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the sandbox.' }, }, @@ -10941,7 +10940,7 @@ export const V2_OPERATIONS = { pathParamDocs: { name: 'Secret to delete.' }, responseMode: 'json', summary: 'Delete Secret', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -10968,7 +10967,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Skill', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, }, @@ -11050,7 +11049,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', describe: - 'Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.', }, limit: { kind: 'integer', describe: 'Maximum matching rows to delete.' }, rowIds: { kind: 'array', describe: 'Explicit row identifiers to delete.' }, @@ -11082,7 +11081,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Delete Workflow Chat Deployment', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, deleteWorkflowFolder: { method: 'DELETE', @@ -11134,7 +11133,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Delete Workflow MCP Server', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, deployWorkflow: { method: 'POST', @@ -11143,7 +11142,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Deploy Workflow', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { name: { kind: 'string', describe: 'Optional label for the deployment version.' }, description: { @@ -11159,7 +11158,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Publish Workflow As MCP Tool', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workflowId: { kind: 'string', @@ -11250,7 +11249,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Run Tool', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -11290,13 +11289,13 @@ export const V2_OPERATIONS = { run: { kind: 'unknown', describe: - 'Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires a personal API key with write access and supports synchronous or streamed runs only.', + 'Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.', }, async: { kind: 'boolean', default: false, describe: - 'Queue the run and return a 202 receipt when true. Requires an API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).', + 'Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).', }, executionTimeoutSeconds: { kind: 'integer', @@ -11364,7 +11363,7 @@ export const V2_OPERATIONS = { pathParamDocs: { auditLogId: 'Audit-log entry identifier.' }, responseMode: 'json', summary: 'Get Audit Log', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { organizationId: { kind: 'string', @@ -11498,7 +11497,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Get Chunk', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -11517,7 +11516,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Get Knowledge Connector', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -11631,7 +11630,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Get Next Tag Slot', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -11815,7 +11814,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Get Workflow Chat Deployment', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, getWorkflowDeployment: { method: 'GET', @@ -11832,7 +11831,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Get Workflow MCP Server', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, getWorkflowRun: { method: 'GET', @@ -11903,7 +11902,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Grant Skill Editor', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, email: { @@ -11945,7 +11944,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Audit Logs', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { action: { kind: 'string', describe: 'Filter by exact action name.' }, resourceType: { @@ -12014,7 +12013,7 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', describe: - "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.", + "Narrow the ledger to one workspace. An OAuth token or personal API key reports only its user's events; a workspace API key reports every member's events in its bound workspace. The response `scope` identifies which view was returned. A workspace key asking for another workspace receives the same `404 Workspace not found` as an unknown id.", }, period: { kind: 'enum', @@ -12475,7 +12474,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'List Chunks', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -12527,7 +12526,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'List Knowledge Connector Documents', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -12558,7 +12557,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'List Knowledge Connectors', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -12710,7 +12709,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'List Tag Usage', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -12739,7 +12738,7 @@ export const V2_OPERATIONS = { triggers: { kind: 'string', describe: - 'Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries.', + 'Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries.', }, level: { kind: 'enum', @@ -12818,7 +12817,7 @@ export const V2_OPERATIONS = { includeJobRuns: { kind: 'boolean', describe: - 'Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.', + 'Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`.', }, runId: { kind: 'string', describe: 'Exact run identifier to match.' }, sortBy: { @@ -12890,7 +12889,7 @@ export const V2_OPERATIONS = { pathParamDocs: { mcpServerId: 'Unique MCP server identifier.' }, responseMode: 'json', summary: 'List MCP Server Tools', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -12948,7 +12947,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Secrets', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -13310,7 +13309,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Workflow MCP Servers', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -13350,7 +13349,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'List Workflow MCP Tools', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, listWorkflowRuns: { method: 'GET', @@ -13600,7 +13599,7 @@ export const V2_OPERATIONS = { predicate: { kind: 'unknown', describe: - 'A single `{ field, op, value }` condition or a recursive `all`/`any` group; either form is normalized to a grouped predicate after validation. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.', }, sort: { kind: 'array', describe: 'Ordered table-row sort specification.' }, limit: { @@ -13628,7 +13627,7 @@ export const V2_OPERATIONS = { predicate: { kind: 'unknown', describe: - 'A single `{ field, op, value }` condition or a recursive `all`/`any` group; either form is normalized to a grouped predicate after validation. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.', }, }, }, @@ -13739,7 +13738,6 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Create or Replace Workflow Chat Deployment', - personalKeyOnly: true, body: { identifier: { kind: 'string', @@ -13795,7 +13793,6 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Replace Workflow State', - personalKeyOnly: true, query: { dryRun: { kind: 'boolean', @@ -13937,7 +13934,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Revert Workflow To Version', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, revokeSkillEditor: { method: 'DELETE', @@ -13949,7 +13946,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Revoke Skill Editor', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, email: { @@ -13966,7 +13963,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Rollback Workflow', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { version: { kind: 'integer', @@ -14066,7 +14063,7 @@ export const V2_OPERATIONS = { tagFilters: { kind: 'array', describe: - 'Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.', + 'Up to 10 filters combined with AND; repeating a tag narrows results. To express OR, run separate searches. Every tag must exist with the same slot and field type in each selected knowledge base or the request is rejected. List valid names with the knowledge-base tag-list operation.', }, searchMode: { kind: 'enum', @@ -14105,7 +14102,7 @@ export const V2_OPERATIONS = { predicate: { kind: 'unknown', describe: - 'Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.', }, sort: { kind: 'array', describe: 'Ordered table-row sort specification.' }, }, @@ -14117,7 +14114,7 @@ export const V2_OPERATIONS = { pathParamDocs: { name: 'Secret to create or replace.' }, responseMode: 'json', summary: 'Set Secret', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -14159,7 +14156,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Sync Knowledge Connector', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -14198,7 +14195,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Undeploy Workflow', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, undeployWorkflowMcpTool: { method: 'DELETE', @@ -14210,7 +14207,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Unpublish Workflow MCP Tool', - personalKeyOnly: true, + workspaceKeyUnsupported: true, }, unzipFile: { method: 'POST', @@ -14230,7 +14227,7 @@ export const V2_OPERATIONS = { pathParamDocs: { credentialId: 'Credential to update.' }, responseMode: 'json', summary: 'Update Credential', - personalKeyOnly: true, + workspaceKeyUnsupported: true, query: { workspaceId: { kind: 'string', @@ -14333,7 +14330,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Chunk', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -14361,7 +14358,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Knowledge Connector', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -14394,7 +14391,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Knowledge Connector Documents', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -14424,7 +14421,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Document', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -14470,7 +14467,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Tag', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', @@ -14565,7 +14562,7 @@ export const V2_OPERATIONS = { kind: 'unknown', required: true, describe: - 'Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so "not X" is not the complement of "X" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: "Hi*"`, not `like: "Hi%"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.', + 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. Negating operators include null or absent cells. Combine them with `isNotNull`, or `isNotEmpty` for multi-select, to exclude nulls. Operator-specific operands and wildcard rules are documented on `op`.', }, data: { kind: 'object', @@ -14582,7 +14579,7 @@ export const V2_OPERATIONS = { pathParamDocs: { sandboxId: 'Unique sandbox identifier.' }, responseMode: 'json', summary: 'Update Sandbox', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the sandbox.' }, name: { @@ -14619,7 +14616,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Skill', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, name: { kind: 'string', describe: 'New kebab-case skill name.' }, @@ -14759,7 +14756,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Update Workflow MCP Server', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { name: { kind: 'string', describe: 'Server display name, shown to connecting MCP clients.' }, description: { kind: 'string', describe: 'New server description, or null to clear it.' }, @@ -14776,7 +14773,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Update Workflow Public API Access', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { isPublicApi: { kind: 'boolean', @@ -14826,7 +14823,7 @@ export const V2_OPERATIONS = { pathParamDocs: { fileId: 'File identifier.' }, responseMode: 'json', summary: 'Enable or Disable File Share', - personalKeyOnly: true, + workspaceKeyUnsupported: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, isActive: { diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index a40341e5bd1..ace92c62804 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -23,12 +23,14 @@ function client(options: { apiKey?: string } = { apiKey: 'key' }): SimClient { return new SimClient({ name: 'default', endpoint: 'https://sim.example', + authProfile: 'default', apiKey: options.apiKey ?? null, + oauth: null, workspaceId: 'ws_1', output: 'json', sources: { endpoint: 'default', - apiKey: 'env', + credential: 'env', workspaceId: 'env', output: 'default', }, @@ -503,7 +505,7 @@ describe('request identity', () => { }) }) -describe('personal-key-only operations', () => { +describe('workspace-key refusals', () => { it('appends the remedy, keyed off the code the API actually nests', async () => { // The envelope this asserts is the one staging returns: `error.code` is the // status class, and the actionable code rides in `error.details.code`. @@ -526,7 +528,7 @@ describe('personal-key-only operations', () => { await expect(client().request('/api/v2/secrets')).rejects.toMatchObject({ message: - 'Workspace API key cannot perform this operation — this operation needs a personal API key: sim login --profile default', + 'Workspace API key cannot perform this operation — this operation does not support workspace API keys; use an OAuth login or personal API key: sim login --profile default', code: 'FORBIDDEN', }) }) @@ -555,7 +557,7 @@ describe('personal-key-only operations', () => { await expect(client().request('/api/v2/audit-logs')).rejects.toMatchObject({ message: - 'Principal kind workspace_api_key cannot perform operation audit_logs.list — this operation needs a personal API key: sim login --profile default', + 'Principal kind workspace_api_key cannot perform operation audit_logs.list — this operation does not support workspace API keys; use an OAuth login or personal API key: sim login --profile default', }) }) @@ -598,12 +600,14 @@ describe('API errors', () => { const client = new SimClient({ name: 'default', endpoint: 'https://sim.example', + authProfile: 'default', apiKey: 'key', + oauth: null, workspaceId: 'ws_1', output: 'json', sources: { endpoint: 'default', - apiKey: 'env', + credential: 'env', workspaceId: 'env', output: 'default', }, @@ -1111,3 +1115,199 @@ describe('destructive operations are gated', () => { } }) }) + +describe('OAuth bearer credentials', () => { + const NOW = 1_700_000_000_000 + + function oauthClient( + expiresAt: number, + refreshOAuth = vi.fn(), + names = { name: 'default', authProfile: 'default' } + ) { + const client = new SimClient( + { + name: names.name, + endpoint: 'https://sim.example', + authProfile: names.authProfile, + apiKey: null, + oauth: { + accessToken: 'sim_oat_live', + refreshToken: 'sim_ort_live', + expiresAt, + issuer: 'https://sim.example/api/auth', + loginId: 'login-1', + scope: 'offline_access api:read api:write', + }, + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + credential: 'credentials', + workspaceId: 'env', + output: 'default', + }, + }, + { refreshOAuth } + ) + return { client, refreshOAuth } + } + + function jsonReply(status: number, body: unknown, headers: Record = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) + } + + afterEach(() => { + vi.useRealTimers() + }) + + it('sends a stored login as a bearer token and never as x-api-key', async () => { + vi.useFakeTimers({ now: NOW }) + const fetchMock = vi.fn(async () => jsonReply(200, { data: {} })) + vi.stubGlobal('fetch', fetchMock) + const { client, refreshOAuth } = oauthClient(NOW + 60 * 60 * 1000) + + await client.request('/api/v2/meta') + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.headers).toMatchObject({ authorization: 'Bearer sim_oat_live' }) + expect(init.headers).not.toHaveProperty('x-api-key') + expect(refreshOAuth).not.toHaveBeenCalled() + }) + + it('renews a login that is about to lapse before using it', async () => { + vi.useFakeTimers({ now: NOW }) + const fetchMock = vi.fn(async () => jsonReply(200, { data: {} })) + vi.stubGlobal('fetch', fetchMock) + const refresh = vi.fn(async () => ({ + accessToken: 'sim_oat_fresh', + refreshToken: 'sim_ort_fresh', + expiresAt: NOW + 60 * 60 * 1000, + })) + const { client } = oauthClient(NOW + 60 * 1000, refresh) + + await client.request('/api/v2/meta') + await client.request('/api/v2/meta') + + expect(refresh).toHaveBeenCalledTimes(1) + for (const call of fetchMock.mock.calls) { + const [, init] = call as unknown as [string, RequestInit] + expect(init.headers).toMatchObject({ authorization: 'Bearer sim_oat_fresh' }) + } + }) + + it('retries exactly once after a 401 that names the token invalid', async () => { + vi.useFakeTimers({ now: NOW }) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonReply( + 401, + { error: { code: 'UNAUTHORIZED', message: 'Invalid access token' } }, + { 'www-authenticate': 'Bearer realm="Sim API", error="invalid_token"' } + ) + ) + .mockResolvedValueOnce(jsonReply(200, { data: { ok: true } })) + vi.stubGlobal('fetch', fetchMock) + const refresh = vi.fn(async () => ({ + accessToken: 'sim_oat_fresh', + refreshToken: 'sim_ort_fresh', + expiresAt: NOW + 60 * 60 * 1000, + })) + const { client } = oauthClient(NOW + 60 * 60 * 1000, refresh) + + await expect(client.request('/api/v2/meta')).resolves.toEqual({ data: { ok: true } }) + expect(refresh).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('does not refresh on a 401 that is not about the token', async () => { + vi.useFakeTimers({ now: NOW }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => + jsonReply( + 401, + { error: { code: 'UNAUTHORIZED', message: 'Bearer tokens are not accepted' } }, + { + 'www-authenticate': + 'SimApiKey realm="Sim API", header="x-api-key", Bearer realm="Sim API"', + } + ) + ) + ) + const refresh = vi.fn() + const { client } = oauthClient(NOW + 60 * 60 * 1000, refresh) + + await expect(client.request('/api/v2/meta')).rejects.toThrow('run: sim login') + expect(refresh).not.toHaveBeenCalled() + }) + + it('does not refresh when invalid_token appears only in the challenge description', async () => { + vi.useFakeTimers({ now: NOW }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => + jsonReply( + 401, + { error: { code: 'FORBIDDEN', message: 'Scope refused' } }, + { + 'www-authenticate': + 'Bearer realm="Sim API", error="insufficient_scope", error_description="not an invalid_token failure"', + } + ) + ) + ) + const refresh = vi.fn() + const { client } = oauthClient(NOW + 60 * 60 * 1000, refresh) + + await expect(client.request('/api/v2/meta')).rejects.toThrow('Scope refused') + expect(refresh).not.toHaveBeenCalled() + }) + + it('directs a workspace alias to its authentication profile after a 401', async () => { + vi.useFakeTimers({ now: NOW }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonReply(401, { error: { message: 'Login rejected' } })) + ) + const { client } = oauthClient(NOW + 60 * 60 * 1000, vi.fn(), { + name: 'workspace-alias', + authProfile: 'default', + }) + + await expect(client.request('/api/v2/meta')).rejects.toThrow('sim login --profile default') + }) + + it('prefers an explicit API key over the stored login', async () => { + vi.useFakeTimers({ now: NOW }) + const fetchMock = vi.fn(async () => jsonReply(200, { data: {} })) + vi.stubGlobal('fetch', fetchMock) + const client = new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + authProfile: 'default', + apiKey: 'sim_from_env', + /** Keep a live stored login present so the explicit-key precedence is observable. */ + oauth: { + accessToken: 'sim_oat_live', + refreshToken: 'sim_ort_live', + expiresAt: NOW + 60 * 60 * 1000, + issuer: 'https://sim.example/api/auth', + loginId: 'login-1', + scope: 'offline_access api:read api:write', + }, + workspaceId: 'ws_1', + output: 'json', + sources: { endpoint: 'default', credential: 'env', workspaceId: 'env', output: 'default' }, + }) + + await client.request('/api/v2/meta') + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.headers).toMatchObject({ 'x-api-key': 'sim_from_env' }) + expect(init.headers).not.toHaveProperty('authorization') + }) +}) diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 1cbd55885fc..509405c0349 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' -import type { ResolvedProfile } from '../config/index' +import type { ResolvedProfile, StoredCredential, StoredOAuthCredential } from '../config/index' import { USER_AGENT } from '../version' -import { warnIfKeyOverCleartext, warnIfProxyIgnored } from './environment' +import { warnIfCredentialOverCleartext, warnIfProxyIgnored } from './environment' /** * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed @@ -424,18 +424,77 @@ export function formatApiErrorDetails(details: unknown): string[] { return lines } +/** + * Renews an OAuth login and returns the new pair; injected so the HTTP client + * does not import the OAuth flow, which imports the client. + */ +export type OAuthRefresher = ( + profile: ResolvedProfile, + current: StoredOAuthCredential +) => Promise + +export interface SimClientOptions { + refreshOAuth?: OAuthRefresher +} + +/** + * Renew this long before the access token lapses. A request that starts with + * a few seconds left can still land after expiry; five minutes is the margin + * the AWS CLI and WorkOS's guidance settle on, and well inside the hour a Sim + * access token lives. + */ +const REFRESH_AHEAD_MS = 5 * 60 * 1000 + +/** Reads the OAuth error parameter from Sim's Bearer challenge. */ +function bearerChallengeError(header: string | null): string | null { + if (!header?.trimStart().toLowerCase().startsWith('bearer')) return null + const match = /(?:^|,)\s*error\s*=\s*(?:"([^"]*)"|([^,\s]+))/i.exec( + header.replace(/^\s*Bearer\s*/i, '') + ) + return match?.[1] ?? match?.[2] ?? null +} + export class SimClient { - constructor(private readonly profile: ResolvedProfile) {} + private oauth: StoredOAuthCredential | null + private refreshing: Promise | null = null + + constructor( + private readonly profile: ResolvedProfile, + private readonly options: SimClientOptions = {} + ) { + this.oauth = profile.oauth ?? null + } - private resolveApiKey(auth: AuthRequirement = 'required'): string | undefined { - if (!this.profile.apiKey) { - if (auth === 'optional') return undefined + private resolveCredential(auth: AuthRequirement = 'required'): StoredCredential | undefined { + if (this.profile.apiKey) return { kind: 'api_key', apiKey: this.profile.apiKey } + if (this.oauth) return { kind: 'oauth', oauth: this.oauth } + if (auth === 'optional') return undefined + throw new SimApiError( + `Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.authProfile}`, + 0 + ) + } + + /** + * One refresh at a time per process, shared by every request that finds the + * token expiring; the cross-process half lives behind the refresher. + */ + private async refreshOAuth(current: StoredOAuthCredential): Promise { + const refresh = this.options.refreshOAuth + if (!refresh) { throw new SimApiError( - `Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.name}`, - 0 + `Your Sim login has expired. Run sim logout --profile ${this.profile.name}, then sim login --profile ${this.profile.name}.`, + 401 ) } - return this.profile.apiKey + if (!this.refreshing) { + this.refreshing = refresh(this.profile, current).finally(() => { + this.refreshing = null + }) + } + const next = await this.refreshing + this.oauth = next + return next } /** @@ -447,7 +506,7 @@ export class SimClient { * is logging in. Auth-disabled self-hosted protocols opt out explicitly. */ requireWorkspace(explicit?: string, options: WorkspaceOptions = {}): string { - this.resolveApiKey(options.auth) + this.resolveCredential(options.auth) const workspaceId = explicit ?? this.profile.workspaceId if (!workspaceId) { throw new SimApiError( @@ -484,16 +543,23 @@ export class SimClient { private async send( path: string, - options: RequestOptions + options: RequestOptions, + retriedAfterRefresh = false ): Promise<{ response: Response; url: string }> { - const apiKey = this.resolveApiKey(options.auth) + let credential = this.resolveCredential(options.auth) + if ( + credential?.kind === 'oauth' && + credential.oauth.expiresAt - Date.now() < REFRESH_AHEAD_MS + ) { + credential = { kind: 'oauth', oauth: await this.refreshOAuth(credential.oauth) } + } const url = buildUrl(this.profile.endpoint, path, options.query) const hasBody = options.body !== undefined const method = options.method ?? 'GET' warnIfProxyIgnored() - warnIfKeyOverCleartext(this.profile.endpoint, Boolean(apiKey)) + warnIfCredentialOverCleartext(this.profile.endpoint, Boolean(credential)) // The caller's signal still cancels; the timeout only adds a second reason // to abort, so neither can mask the other. @@ -509,7 +575,10 @@ export class SimClient { response = await fetch(url, { method, headers: { - ...(apiKey ? { 'x-api-key': apiKey } : {}), + ...(credential?.kind === 'api_key' ? { 'x-api-key': credential.apiKey } : {}), + ...(credential?.kind === 'oauth' + ? { authorization: `Bearer ${credential.oauth.accessToken}` } + : {}), accept: 'application/json', 'user-agent': USER_AGENT, ...(hasBody ? { 'content-type': 'application/json' } : {}), @@ -540,14 +609,31 @@ export class SimClient { if (REDIRECT_STATUSES.has(response.status)) throw this.toRedirectError(url, path, response) + /** + * A token the server no longer accepts — revoked, or expired on a clock + * this process disagrees with — is renewed once and the request repeated. + * Only for `invalid_token` (RFC 6750 §3.1): any other 401 means the + * refresh would not change the answer. + */ + if ( + response.status === 401 && + credential?.kind === 'oauth' && + !retriedAfterRefresh && + bearerChallengeError(response.headers.get('www-authenticate')) === 'invalid_token' + ) { + await response.body?.cancel() + await this.refreshOAuth(credential.oauth) + return this.send(path, options, true) + } + if (!response.ok) { const raw = await response.text() const error = toApiError(url, response.status, response.headers.get('content-type'), raw) if (response.status === 401) { - error.message = `${error.message} — run: sim login --profile ${this.profile.name}` + error.message = `${error.message} — run: sim login --profile ${this.profile.authProfile}` } if (namesKeyScopeRefusal(error)) { - error.message = `${error.message} — this operation needs a personal API key: sim login --profile ${this.profile.name}` + error.message = `${error.message} — this operation does not support workspace API keys; use an OAuth login or personal API key: sim login --profile ${this.profile.authProfile}` } throw error } diff --git a/packages/sim-cli/src/http/environment.test.ts b/packages/sim-cli/src/http/environment.test.ts index 4b4c2006f56..000140805d3 100644 --- a/packages/sim-cli/src/http/environment.test.ts +++ b/packages/sim-cli/src/http/environment.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { resetEnvironmentNotices, warnIfKeyOverCleartext, warnIfProxyIgnored } from './environment' +import { + resetEnvironmentNotices, + warnIfCredentialOverCleartext, + warnIfProxyIgnored, +} from './environment' let writes: string[] let originalWrite: typeof process.stderr.write @@ -70,7 +74,7 @@ describe('a proxy the request will not go through', () => { describe('an API key crossing the network in the clear', () => { it('reports a key sent to a remote host over http', () => { - warnIfKeyOverCleartext('http://sim.internal.example', true) + warnIfCredentialOverCleartext('http://sim.internal.example', true) expect(writes.join('')).toContain('sim.internal.example') expect(writes.join('')).toContain('over http') }) @@ -78,14 +82,14 @@ describe('an API key crossing the network in the clear', () => { it('stays silent for the documented local development case', () => { // `http://localhost:3000` is what the README and the login example use. for (const host of ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://api.localhost']) { - warnIfKeyOverCleartext(host, true) + warnIfCredentialOverCleartext(host, true) } expect(writes).toEqual([]) }) it('stays silent over https, and when there is no key to leak', () => { - warnIfKeyOverCleartext('https://sim.example', true) - warnIfKeyOverCleartext('http://sim.internal.example', false) + warnIfCredentialOverCleartext('https://sim.example', true) + warnIfCredentialOverCleartext('http://sim.internal.example', false) expect(writes).toEqual([]) }) }) diff --git a/packages/sim-cli/src/http/environment.ts b/packages/sim-cli/src/http/environment.ts index f8c30b05cff..421a9d39d34 100644 --- a/packages/sim-cli/src/http/environment.ts +++ b/packages/sim-cli/src/http/environment.ts @@ -91,8 +91,8 @@ function isLoopback(hostname: string): boolean { * the documented case; anything else means the key is on the wire in the clear, * which is worth one line. */ -export function warnIfKeyOverCleartext(endpoint: string, hasApiKey: boolean): void { - if (!hasApiKey) return +export function warnIfCredentialOverCleartext(endpoint: string, hasCredential: boolean): void { + if (!hasCredential) return let url: URL try { @@ -104,6 +104,6 @@ export function warnIfKeyOverCleartext(endpoint: string, hasApiKey: boolean): vo once( 'cleartext', - `sending your API key to ${url.host} over http. Anything on the path can read it — use https unless this network is trusted.` + `sending your Sim credentials to ${url.host} over http. Anything on the path can read them — use https unless this network is trusted.` ) } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 82c992b7ec4..a4517f56845 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -224,10 +224,10 @@ describe('commands parsed through commander', () => { describe('a command whose operation refuses a workspace API key', () => { it('says so in the help line it falls back to from the spec summary', () => { expect(commandAt('secrets', 'list').helpInformation()).toContain( - '(personal API key required)' + '(OAuth login or personal API key required)' ) expect(commandAt('mcp-servers', 'tools', 'list').description()).toContain( - '(personal API key required)' + '(OAuth login or personal API key required)' ) }) @@ -238,7 +238,7 @@ describe('commands parsed through commander', () => { */ it('says so on a command carrying a hand-written describe', () => { expect(commandAt('workflows', 'undeploy').description()).toBe( - 'Take a workflow out of deployment (personal API key required)' + 'Take a workflow out of deployment (OAuth login or personal API key required)' ) }) @@ -261,7 +261,7 @@ describe('commands parsed through commander', () => { ['credentials', 'reconnect'], ]) { expect(`${path.join(' ')}: ${builtCommandAt(...path).description()}`).toContain( - '(personal API key required)' + '(OAuth login or personal API key required)' ) } }) @@ -290,7 +290,7 @@ describe('commands parsed through commander', () => { const text = readFileSync(source, 'utf8') for (const [, operation] of text.matchAll(/V2_OPERATIONS\.([A-Za-z]+)/g)) { const spec = (V2_OPERATIONS as Record)[operation] - if (!spec?.personalKeyOnly) continue + if (!spec?.workspaceKeyUnsupported) continue const suffixed = new RegExp(`describeOperation\\(\\s*V2_OPERATIONS\\.${operation}\\b`) if (suffixed.test(text)) continue unsuffixed.push(`${source.slice(root.length + 1)} calls ${operation}`) @@ -959,7 +959,9 @@ describe('commands parsed through commander', () => { it('supports organization-wide audit listing explicitly', async () => { const help = commandAt('audit-logs', 'list').helpInformation() - expect(help).toMatch(/--organization .*personal API key required.*required/s) + expect(help).toMatch( + /--organization .*OAuth login or personal API key required.*required/s + ) expect(help).toContain('--all-workspaces') expect(help).toContain('--actor-email') expect(help).not.toContain('--actor-id') @@ -2161,8 +2163,8 @@ describe('flags the root program already owns', () => { describe('the billing ledger a key can see', () => { /** - * The defect was silence, not the scoping: a personal key reports the calling - * user's own events and a workspace key the whole workspace ledger, and the + * The defect was silence, not the scoping: a user credential reports the + * caller's own events and a workspace key the whole workspace ledger, and the * two answers were indistinguishable — same workspace, same window, same * flags, a strictly smaller result and nothing saying why. */ diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 92f7e961bdb..b4f91523487 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -33,7 +33,7 @@ const GROUP_ALIASES: Readonly> = { } /** - * States the personal-key restriction the way every generated command states it. + * States the workspace-key restriction the way every generated command states it. * * The suffix lives here, once, because a fully hand-written command renders its * own `.description()` and never reaches the generated path — three commands @@ -43,7 +43,9 @@ const GROUP_ALIASES: Readonly> = { * caller has to name the operation it actually calls, so the two cannot drift. */ export function describeOperation(operationSpec: OperationSpec, described: string): string { - return operationSpec.personalKeyOnly ? `${described} (personal API key required)` : described + return operationSpec.workspaceKeyUnsupported + ? `${described} (OAuth login or personal API key required)` + : described } function argumentSyntax(command: Command): string { diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index c05b9d8bfa0..831adf47b4f 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -233,7 +233,7 @@ export function addOperationOptions( if (commandSpec.allWorkspaces) { command.option( '--all-workspaces', - 'Do not filter to the configured workspace (personal API key required for account-wide access)' + 'Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access)' ) } diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts index 9bd155d1584..51ea1cd7744 100644 --- a/packages/sim-cli/src/runtime/types.ts +++ b/packages/sim-cli/src/runtime/types.ts @@ -14,11 +14,11 @@ export interface OperationSpec { opaqueBody?: boolean summary?: string /** - * The operation rejects a workspace API key; only a personal one works. + * The operation rejects a workspace API key; an OAuth login or personal key works. * * Emitted by `scripts/generate-v2-cli-api.ts` from the OpenAPI description so * `--help` states the restriction the caller would otherwise meet as a `403`. */ - personalKeyOnly?: true + workspaceKeyUnsupported?: true responseMode?: 'json' | 'binary' | 'stream' } diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index f2c6362efb0..1c7b2994b0f 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -110,6 +110,7 @@ export const auditMock = { MEMBER_REMOVED: 'member.removed', MEMBER_ROLE_CHANGED: 'member.role_changed', OAUTH_DISCONNECTED: 'oauth.disconnected', + OAUTH_APP_REVOKED: 'oauth_app.revoked', PASSWORD_RESET: 'password.reset', PASSWORD_RESET_REQUESTED: 'password.reset_requested', ORGANIZATION_CREATED: 'organization.created', @@ -216,6 +217,7 @@ export const auditMock = { KNOWLEDGE_BASE: 'knowledge_base', MCP_SERVER: 'mcp_server', OAUTH: 'oauth', + OAUTH_CLIENT: 'oauth_client', ORGANIZATION: 'organization', PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 471798c780c..8216632014b 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -29,6 +29,7 @@ export interface EnvFlagsMockState { isTriggerDevEnabled: boolean isEnterpriseEnabled: boolean isSsoEnabled: boolean + isOAuthProviderEnabled: boolean isUsageMonitoringEnabled: boolean isAccessControlEnabled: boolean isOrganizationsEnabled: boolean @@ -80,6 +81,8 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isTriggerDevEnabled: false, isEnterpriseEnabled: false, isSsoEnabled: false, + /** OAuth-aware route behavior is available unless a suite overrides it. */ + isOAuthProviderEnabled: true, isUsageMonitoringEnabled: false, isAccessControlEnabled: false, isOrganizationsEnabled: false, diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 229c5962dc6..545c1c35045 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1150,6 +1150,85 @@ export const schemaMock = { domainVerified: 'ssoProvider.domainVerified', jitProvisioningEnabled: 'ssoProvider.jitProvisioningEnabled', }, + oauthClient: { + id: 'oauthClient.id', + clientId: 'oauthClient.clientId', + clientSecret: 'oauthClient.clientSecret', + disabled: 'oauthClient.disabled', + skipConsent: 'oauthClient.skipConsent', + enableEndSession: 'oauthClient.enableEndSession', + subjectType: 'oauthClient.subjectType', + scopes: 'oauthClient.scopes', + userId: 'oauthClient.userId', + createdAt: 'oauthClient.createdAt', + updatedAt: 'oauthClient.updatedAt', + name: 'oauthClient.name', + uri: 'oauthClient.uri', + icon: 'oauthClient.icon', + contacts: 'oauthClient.contacts', + tos: 'oauthClient.tos', + policy: 'oauthClient.policy', + softwareId: 'oauthClient.softwareId', + softwareVersion: 'oauthClient.softwareVersion', + softwareStatement: 'oauthClient.softwareStatement', + redirectUris: 'oauthClient.redirectUris', + postLogoutRedirectUris: 'oauthClient.postLogoutRedirectUris', + tokenEndpointAuthMethod: 'oauthClient.tokenEndpointAuthMethod', + grantTypes: 'oauthClient.grantTypes', + responseTypes: 'oauthClient.responseTypes', + public: 'oauthClient.public', + type: 'oauthClient.type', + requirePKCE: 'oauthClient.requirePKCE', + referenceId: 'oauthClient.referenceId', + metadata: 'oauthClient.metadata', + }, + oauthRefreshToken: { + id: 'oauthRefreshToken.id', + token: 'oauthRefreshToken.token', + clientId: 'oauthRefreshToken.clientId', + sessionId: 'oauthRefreshToken.sessionId', + userId: 'oauthRefreshToken.userId', + referenceId: 'oauthRefreshToken.referenceId', + expiresAt: 'oauthRefreshToken.expiresAt', + createdAt: 'oauthRefreshToken.createdAt', + revoked: 'oauthRefreshToken.revoked', + authTime: 'oauthRefreshToken.authTime', + scopes: 'oauthRefreshToken.scopes', + familyId: 'oauthRefreshToken.familyId', + generation: 'oauthRefreshToken.generation', + }, + oauthTokenFamily: { + id: 'oauthTokenFamily.id', + clientId: 'oauthTokenFamily.clientId', + sessionId: 'oauthTokenFamily.sessionId', + userId: 'oauthTokenFamily.userId', + referenceId: 'oauthTokenFamily.referenceId', + consentId: 'oauthTokenFamily.consentId', + currentGeneration: 'oauthTokenFamily.currentGeneration', + createdAt: 'oauthTokenFamily.createdAt', + expiresAt: 'oauthTokenFamily.expiresAt', + }, + oauthAccessToken: { + id: 'oauthAccessToken.id', + token: 'oauthAccessToken.token', + clientId: 'oauthAccessToken.clientId', + sessionId: 'oauthAccessToken.sessionId', + userId: 'oauthAccessToken.userId', + referenceId: 'oauthAccessToken.referenceId', + refreshId: 'oauthAccessToken.refreshId', + expiresAt: 'oauthAccessToken.expiresAt', + createdAt: 'oauthAccessToken.createdAt', + scopes: 'oauthAccessToken.scopes', + }, + oauthConsent: { + id: 'oauthConsent.id', + clientId: 'oauthConsent.clientId', + userId: 'oauthConsent.userId', + referenceId: 'oauthConsent.referenceId', + scopes: 'oauthConsent.scopes', + createdAt: 'oauthConsent.createdAt', + updatedAt: 'oauthConsent.updatedAt', + }, ssoDomain: { id: 'ssoDomain.id', organizationId: 'ssoDomain.organizationId', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 378d7ce4aa6..b4ed445bb1d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -61,6 +61,11 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/tools/docusign/route.ts', // Better Auth handles its own validation for the catch-all route below. 'apps/sim/app/api/auth/[...all]/route.ts', + /** OAuth protocol routes use bounded form or bearer parsing instead of JSON contracts. */ + 'apps/sim/app/api/auth/oauth2/revoke/route.ts', + 'apps/sim/app/api/auth/oauth2/token/route.ts', + /** Input-less RFC 8414 aliases return Better Auth metadata with Sim's supported surface. */ + 'apps/sim/app/api/auth/.well-known/oauth-authorization-server/route.ts', // Better Auth handles validation for the Stripe webhook handler. 'apps/sim/app/api/auth/webhook/stripe/route.ts', // Routes with no client-supplied input that previously had no-op @@ -89,6 +94,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts', 'apps/sim/app/api/cron/cleanup-stale-executions/route.ts', 'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts', + 'apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts', 'apps/sim/app/api/cron/renew-subscriptions/route.ts', 'apps/sim/app/api/cron/billing-cycle-close/route.ts', 'apps/sim/app/api/cron/reconcile-billing-seats/route.ts', diff --git a/scripts/check-principal-kind-parity.test.ts b/scripts/check-principal-kind-parity.test.ts new file mode 100644 index 00000000000..226d8bb7f93 --- /dev/null +++ b/scripts/check-principal-kind-parity.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { auditSource, parsePrincipalKindLiterals } from './check-principal-kind-parity' + +const FILE = 'apps/sim/lib/things/application/operations.ts' + +describe('principalKinds literal parsing', () => { + it('reads single-line and multi-line literals, including type-level ones', () => { + const literals = parsePrincipalKindLiterals(` + readonly principalKinds: readonly ['session', 'personal_api_key', 'oauth_access_token'] + const A = defineWorkspaceOperation({ + principalKinds: [ + 'session', + 'delegated', + ], + }) + principalKinds?: readonly ['session'] + `) + + expect(literals.map((literal) => literal.kinds)).toEqual([ + ['session', 'personal_api_key', 'oauth_access_token'], + ['session', 'delegated'], + ['session'], + ]) + }) +}) + +describe('assertion A — the two user-credential kinds travel together', () => { + it('accepts a policy that names both, and one that names neither', () => { + const { findings, pairs } = auditSource( + FILE, + ` + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + principalKinds: ['session', 'delegated'], + ` + ) + + expect(findings).toEqual([]) + expect(pairs).toBe(1) + }) + + it('reports a policy that admits the key but not the token', () => { + const { findings } = auditSource(FILE, "principalKinds: ['session', 'personal_api_key'],") + + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ file: FILE, line: 1 }) + expect(findings[0].message).toContain("without 'oauth_access_token'") + }) + + it('reports a policy that admits the token but not the key', () => { + const { findings } = auditSource(FILE, "principalKinds: ['oauth_access_token'],") + + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain("without 'personal_api_key'") + }) + + it('does not count a spread constant as naming a bare kind', () => { + const { findings, pairs } = auditSource( + FILE, + "principalKinds: ['session', ...USER_CREDENTIAL_PRINCIPAL_KINDS]," + ) + + expect(findings).toEqual([]) + expect(pairs).toBe(0) + }) +}) diff --git a/scripts/check-principal-kind-parity.ts b/scripts/check-principal-kind-parity.ts new file mode 100644 index 00000000000..537a6afe097 --- /dev/null +++ b/scripts/check-principal-kind-parity.ts @@ -0,0 +1,174 @@ +#!/usr/bin/env bun +/** + * Keeps `personal_api_key` and `oauth_access_token` admitted together in every + * operation policy. + * + * The two kinds are one authorization class: a person reaching the API through + * a bearer credential of their own. An OAuth access token is the personal key + * narrowed by scope and bounded by expiry, and `authorizeWorkspaceOperation` + * walks the same sequence for both. A policy that names one without the other + * is therefore never a decision — it is an operation written before the second + * kind existed, or a copy of one, and the token is refused (or admitted) by + * accident for a reason no reviewer chose. + * + * It asserts, over every `application/operations.ts` under `apps/sim/lib`: + * + * A every `principalKinds` array literal that names one of the pair names + * both. + * B at least one policy naming the pair was found. The assertions are + * source-text matches, so a refactor into a form this cannot read would + * otherwise be indistinguishable from a clean tree. + * + * ## What this audit does not cover + * + * Read this before trusting the gate: it covers less than it looks like it does. + * + * - Only array literals written directly after `principalKinds:` are read. A + * policy assembled from a named constant (`principalKinds: HUMAN_KINDS`) is + * invisible to assertion A. + * - Only `operations.ts` files under `apps/sim/lib` are scanned. Anything + * declared elsewhere is out of reach. + * - Nothing here sees a `switch (principal.kind)` or a + * `principal.kind === '...'` comparison, which is the class of site that + * actually mis-admits a principal silently, and the class this pair had to + * be threaded through by hand. + * - Assertion B proves only that *some* policy names both kinds, not that any + * particular domain does. One paired policy anywhere keeps it green. + * + * Type-level `readonly principalKinds: readonly [...]` declarations do match + * the same pattern, so a domain's operation interface is held to the rule. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const SCAN_ROOT = 'apps/sim/lib' +const OPERATIONS_FILE = 'operations.ts' + +/** The pair that must travel together. */ +export const USER_CREDENTIAL_PRINCIPAL_KINDS = ['personal_api_key', 'oauth_access_token'] as const + +interface Finding { + file: string + line: number + message: string +} + +function walk(directory: string, into: string[]): string[] { + for (const entry of readdirSync(directory)) { + if (entry === 'node_modules' || entry === '.next') continue + const full = join(directory, entry) + if (statSync(full).isDirectory()) walk(full, into) + else if (entry === OPERATIONS_FILE) into.push(full) + } + return into +} + +function lineOf(source: string, index: number): number { + return source.slice(0, index).split('\n').length +} + +/** + * Every `principalKinds` array literal in one file, with the kinds it names. + * Multi-line literals are read to their closing bracket; a literal spread from + * a constant contributes the spread's text, which never names a bare kind and + * so never trips assertion A on its own. + */ +export function parsePrincipalKindLiterals( + source: string +): Array<{ line: number; kinds: string[] }> { + const literals: Array<{ line: number; kinds: string[] }> = [] + for (const match of source.matchAll(/principalKinds\??:\s*(?:readonly\s+)?\[/g)) { + const open = match.index + match[0].length - 1 + let depth = 0 + let close = -1 + for (let index = open; index < source.length; index++) { + const char = source[index] + if (char === '[') depth++ + else if (char === ']') { + depth-- + if (depth === 0) { + close = index + break + } + } + } + if (close === -1) continue + const body = source.slice(open + 1, close) + const kinds = [...body.matchAll(/'([a-z_]+)'/g)].map((kind) => kind[1]) + literals.push({ line: lineOf(source, match.index), kinds }) + } + return literals +} + +/** One operations file's findings, so the assertion is testable without a tree on disk. */ +export function auditSource(file: string, source: string): { findings: Finding[]; pairs: number } { + const findings: Finding[] = [] + let pairs = 0 + const [personal, oauth] = USER_CREDENTIAL_PRINCIPAL_KINDS + + for (const literal of parsePrincipalKindLiterals(source)) { + const hasPersonal = literal.kinds.includes(personal) + const hasOauth = literal.kinds.includes(oauth) + if (hasPersonal && hasOauth) { + pairs++ + continue + } + if (!hasPersonal && !hasOauth) continue + const named = hasPersonal ? personal : oauth + const missing = hasPersonal ? oauth : personal + findings.push({ + file, + line: literal.line, + message: + `principalKinds names '${named}' without '${missing}'. A personal API key and an OAuth ` + + 'access token are the same authorization class — a person acting through their own ' + + 'bearer credential — so an operation admits both or neither. Add the missing kind.', + }) + } + + return { findings, pairs } +} + +function main(): void { + const files = walk(join(ROOT, SCAN_ROOT), []) + .map((file) => relative(ROOT, file)) + .sort() + + const findings: Finding[] = [] + let pairs = 0 + for (const file of files) { + const result = auditSource(file, readFileSync(join(ROOT, file), 'utf8')) + findings.push(...result.findings) + pairs += result.pairs + } + + if (pairs === 0 && findings.length === 0) { + findings.push({ + file: SCAN_ROOT, + line: 1, + message: + `no principalKinds literal naming both ${USER_CREDENTIAL_PRINCIPAL_KINDS.join(' and ')} ` + + `was found under ${SCAN_ROOT}. Either no operation admits user credentials any more, or ` + + 'policies are now written in a form this audit cannot read — both mean it is passing ' + + 'without checking anything.', + }) + } + + if (findings.length > 0) { + console.error( + `check:principal-kind-parity — ${findings.length} finding${findings.length === 1 ? '' : 's'}:\n` + ) + for (const finding of findings) { + console.error(` ${finding.file}:${finding.line}\n ${finding.message}\n`) + } + process.exit(1) + } + + console.log( + `check:principal-kind-parity — ${files.length} operations files, ${pairs} policies admit both user-credential kinds.` + ) +} + +if (import.meta.main) main() diff --git a/scripts/generate-v2-cli-api.test.ts b/scripts/generate-v2-cli-api.test.ts index 84fcfaabfbc..ee075d37002 100644 --- a/scripts/generate-v2-cli-api.test.ts +++ b/scripts/generate-v2-cli-api.test.ts @@ -66,7 +66,7 @@ describe('request headers reaching the CLI as flags', () => { * and this recomputes the expected set, so a generator holding a stale copy of * it goes red instead of silently unmarking a family. */ -function personalKeyMarkers(): string[] { +function workspaceKeyDenialMarkers(): string[] { const source = readFileSync( path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/shared.ts'), 'utf8' @@ -96,20 +96,22 @@ describe('operations that refuse a workspace API key', () => { * pinned pair stayed green. */ it('emits the marker for every operation the specs say refuses one', () => { - const marked = [...loadSummaries(personalKeyMarkers()).values()].filter( - (doc) => doc.personalKeyOnly + const marked = [...loadSummaries(workspaceKeyDenialMarkers()).values()].filter( + (doc) => doc.workspaceKeyUnsupported ) expect(marked.length).toBeGreaterThan(0) - expect(generatedSource().match(/personalKeyOnly: true/g)?.length ?? 0).toBe(marked.length) + expect(generatedSource().match(/workspaceKeyUnsupported: true/g)?.length ?? 0).toBe( + marked.length + ) }) it('marks restricted operations and leaves workspace-key-capable siblings alone', () => { const source = generatedSource() for (const name of ['listMcpServerTools', 'listSecrets', 'undeployWorkflow']) { - expect(generatedEntry(source, name)).toContain('personalKeyOnly: true') + expect(generatedEntry(source, name)).toContain('workspaceKeyUnsupported: true') } for (const name of ['listMcpServers', 'getMcpServer', 'listWorkflows']) { - expect(generatedEntry(source, name)).not.toContain('personalKeyOnly') + expect(generatedEntry(source, name)).not.toContain('workspaceKeyUnsupported') } }) }) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index d8edc0e1f36..a1b2378e4e9 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -72,7 +72,7 @@ export interface OperationDoc { * Carried so `--help` can say so before the request goes out; without it the * caller learns the restriction from a `403` after the fact. */ - personalKeyOnly?: true + workspaceKeyUnsupported?: true } /** @@ -84,7 +84,7 @@ export interface OperationDoc { * resolves through the `@/` alias, which exists under `bun` but not under the * root `vitest` that imports this file's pure helpers. */ -export async function loadPersonalKeyMarkers(): Promise { +export async function loadWorkspaceKeyDenialMarkers(): Promise { const shared: Record = await import( path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/shared.ts') ) @@ -108,7 +108,9 @@ export async function loadPersonalKeyMarkers(): Promise { * `description` is read for the same reason — it is where the workspace-key * denial is already stated. */ -export function loadSummaries(personalKeyMarkers: readonly string[]): Map { +export function loadSummaries( + workspaceKeyDenialMarkers: readonly string[] +): Map { const docs = new Map() for (const file of specFiles()) { @@ -128,11 +130,11 @@ export function loadSummaries(personalKeyMarkers: readonly string[]): Map description.includes(marker)) + workspaceKeyDenialMarkers.some((marker) => description.includes(marker)) ) { - doc.personalKeyOnly = true + doc.workspaceKeyUnsupported = true } - if (doc.summary || doc.personalKeyOnly) { + if (doc.summary || doc.workspaceKeyUnsupported) { docs.set(`${method.toUpperCase()} ${specPath}`, doc) } } @@ -571,7 +573,7 @@ function render(operations: Operation[], docs: Map): strin out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI") out.push(' * specs so `--help` reuses prose that is already written and already checked.') out.push(' *') - out.push(' * `personalKeyOnly` marks an operation whose spec description says a workspace') + out.push(' * `workspaceKeyUnsupported` marks an operation whose spec says a workspace') out.push(' * API key is rejected, so `--help` can say so before the request is sent.') out.push(' */') out.push('export const V2_OPERATIONS = {') @@ -595,7 +597,7 @@ function render(operations: Operation[], docs: Map): strin `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` ) if (doc?.summary) out.push(` summary: ${JSON.stringify(doc.summary)},`) - if (doc?.personalKeyOnly) out.push(` personalKeyOnly: true,`) + if (doc?.workspaceKeyUnsupported) out.push(` workspaceKeyUnsupported: true,`) for (const slot of ['query', 'body'] as const) { const map = renderSlotMap(op.contract[slot], ' ') if (map) out.push(` ${slot}: ${map},`) @@ -656,7 +658,7 @@ async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() - const generated = format(render(operations, loadSummaries(await loadPersonalKeyMarkers()))) + const generated = format(render(operations, loadSummaries(await loadWorkspaceKeyDenialMarkers()))) if (args.has('--check')) { let current = '' diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 988bad712c9..242d70d9ca2 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -22,6 +22,7 @@ import { generateOpenApiDocument, serializeOpenApiDocument } from './generator' type JsonObject = Record const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']) +const MAX_DESCRIPTION_WORDS = 80 const DOCUMENTS = [ workflowsOpenApiDocument, @@ -69,6 +70,26 @@ function operations(spec: JsonObject): JsonObject[] { return result } +function oversizedDescriptions(value: unknown, location: string, out: string[]): void { + if (!value || typeof value !== 'object') return + if (Array.isArray(value)) { + value.forEach((item, index) => oversizedDescriptions(item, `${location}[${index}]`, out)) + return + } + + for (const [key, nested] of Object.entries(value as JsonObject)) { + const nestedLocation = `${location}.${key}` + if (key === 'description' && typeof nested === 'string') { + const wordCount = nested.trim() ? nested.trim().split(/\s+/).length : 0 + if (wordCount > MAX_DESCRIPTION_WORDS) { + out.push(`${nestedLocation} (${wordCount} words)`) + } + continue + } + oversizedDescriptions(nested, nestedLocation, out) + } +} + function isStructuredObject(schema: JsonObject): boolean { return schema.type === 'object' || schema.properties !== undefined } @@ -152,6 +173,15 @@ function anonymousTopLevelResponseObjects(spec: JsonObject): string[] { } describe('generated OpenAPI documents', () => { + it('keeps every description concise without padding simple fields', () => { + const outliers: string[] = [] + for (const document of DOCUMENTS) { + oversizedDescriptions(generatedDocument(document), document.output, outliers) + } + + expect(outliers).toEqual([]) + }) + it('covers the complete public v2 operation surface with canonical errors', () => { const outputs = DOCUMENTS.map((document) => document.output) expect(new Set(outputs).size).toBe(DOCUMENTS.length) @@ -200,7 +230,7 @@ describe('generated OpenAPI documents', () => { 'Workflow Runs', ]) expect(execute.tags).toEqual(['Workflows']) - expect(execute.security).toEqual([{ apiKey: [] }, {}]) + expect(execute.security).toEqual([{ apiKey: [] }, { oauthBearer: [] }, {}]) expect(Object.keys(executeOkContent).sort()).toEqual(['application/json', 'text/event-stream']) expect(Object.keys(executeQueuedContent)).toEqual(['application/json'])