diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 378c9b68799..091a8307c31 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -993,11 +993,15 @@ After adding or changing one, run: ```bash bun run scripts/generate-docs.ts bun run integration-catalog:check +bun run docs:check ``` The catalog check independently derives deployment metadata from the executable block registry and -compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated -diff and keep only intentional changes. +compares it with the committed `apps/sim/lib/integrations/integrations.json`. `docs:check` re-renders +every generated docs artifact in memory and fails on any committed file that differs — it runs in CI +via `check:audits`, so commit the full generator output. If the generator also trues up pages an +earlier PR left stale, commit that catch-up too; reverting it as "unrelated drift" makes `docs:check` +fail. ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -1018,6 +1022,7 @@ diff and keep only intentional changes. - [ ] If any tool was added, changed or removed alongside the block: ran `bun run tool-metadata:generate` and committed the artifacts - [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes - [ ] `bun run integration-catalog:check` passes +- [ ] `bun run docs:check` passes (CI gate — fails on any stale generated docs page) - [ ] If icon missing: asked user to provide SVG - [ ] If triggers exist: `triggers` config set, trigger subBlocks spread - [ ] Optional/rarely-used fields set to `mode: 'advanced'` diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 3369828d6f2..22ed2ef4195 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -561,6 +561,7 @@ Run the documentation generator: ```bash bun run scripts/generate-docs.ts bun run integration-catalog:check +bun run docs:check ``` This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`). @@ -651,6 +652,9 @@ If creating V2 versions (API-aligned outputs): - [ ] Verified docs file created - [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change - [ ] `bun run integration-catalog:check` passes +- [ ] `bun run docs:check` passes — CI fails on stale generated docs, so commit the full generator + output, including catch-up regeneration for pages another PR left stale (never revert it as + "unrelated drift") ### Final Validation (Required) - [ ] Read every tool file and cross-referenced inputs/outputs against the API docs diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 734c03bcd9c..6ae100ce86b 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -475,6 +475,9 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet` - [ ] Index.ts exports all tools and re-exports types (`export * from './types'`) - [ ] Tools registered in `tools/registry.ts` - [ ] `bun run tool-metadata:generate` run and the regenerated artifacts committed +- [ ] `bun run scripts/generate-docs.ts` run and the refreshed docs committed — the integration's + docs page is rendered from each tool's description, params, and outputs, and CI's + `bun run docs:check` fails on stale pages - [ ] Block wired: `tools.access`, dropdown options, subBlocks, `tools.config`, outputs, inputs - [ ] Model, durable-storage, and internal-execution boundaries use the shared provenance mechanisms only where a concrete Sim `{{...}}` resolution path requires them diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index 2720d94f99d..3175d46e1c8 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -511,3 +511,6 @@ Add to `helm/sim/values.yaml` under the existing polling cron jobs: - [ ] `bun run type-check` passes - [ ] Manually verify output keys match trigger `outputs` keys - [ ] Trigger UI shows correctly in the block +- [ ] Ran `bun run scripts/generate-docs.ts` and committed the refreshed pages — trigger sections + render into the owning integration's docs page, and CI's `bun run docs:check` fails on stale + pages diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index e7f0039e84b..6caa5ff7c57 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -78,6 +78,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -270,6 +307,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index da5ac1dd984..68ed9ee7620 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -388,6 +388,7 @@ Several files are generated from tool and block definitions. Editing a tool or b bun run tool-metadata:generate # repo root — apps/sim/tools/generated/* bun run scripts/generate-docs.ts # docs .mdx + lib/integrations/integrations.json + docs icons bun run integration-catalog:check # registry ↔ committed deployment metadata drift +bun run docs:check # committed docs ↔ what the generator renders today ``` - **`tool-metadata:generate`** — required whenever a tool's `outputs`, `params`, or descriptions change. CI enforces this with `bun run tool-metadata:check`, which fails with *"Generated tool metadata is stale"*. This is the easiest gate to miss, because nothing in the tool file hints that a generated artifact mirrors it. @@ -395,8 +396,17 @@ bun run integration-catalog:check # registry ↔ committed deployment metadat - **`integration-catalog:check`** — loads the executable block registry, derives visible integration deployment fields, and compares them with the committed catalog. It catches missing/unexpected entries and stale auth/service IDs without loading the executable registry in client code. - -**Always diff the regen output before committing.** These generators rewrite every file they own, so they will also sweep in unrelated drift that accumulated on the base branch — pages losing sections, unrelated icons appearing. Keep only the hunks belonging to the integration under validation and `git checkout --` the rest, otherwise an unrelated doc regression rides along in the PR. Verify no page was silently dropped by comparing the directory listing before and after. +- **`docs:check`** — check mode of `generate-docs.ts`: renders every generated docs artifact in + memory and fails listing any committed file that differs. Runs in CI via `check:audits`. + +**Always diff the regen output before committing — but commit all of it.** These generators rewrite +every file they own, so they also true up drift that accumulated on the base branch (pages whose +source changed without a regen). That catch-up is correct output, not a regression: `docs:check` +fails CI on any page left stale, so reverting swept-in hunks with `git checkout --` reintroduces the +failure. Review the diff to confirm each hunk is explained by a real source change (yours or an +upstream PR that skipped regeneration), and investigate anything that looks like content loss — a +page losing a section usually means its source block moved or a generator input broke, not that the +hunk should be reverted. If an icon changed, `apps/sim/components/icons.tsx` is the source of truth and `apps/docs/components/icons.tsx` is its generated mirror — they must end up byte-identical for that component. @@ -408,9 +418,10 @@ After fixing, confirm: 3. The integration's tests pass, and any test you added actually fails without its fix (revert it once and watch it go red) 4. Derived artifacts regenerated and their diffs reviewed (see above) 5. `bun run integration-catalog:check` passes -6. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes -7. Re-read all modified files to verify fixes are correct -8. Any remaining unknown response schemas were explicitly reported to the user instead of guessed +6. `bun run docs:check` passes +7. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes +8. Re-read all modified files to verify fixes are correct +9. Any remaining unknown response schemas were explicitly reported to the user instead of guessed ## Checklist Summary @@ -439,7 +450,7 @@ After fixing, confirm: - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run tool-metadata:generate` if any tool outputs/params changed, and confirmed `bun run tool-metadata:check` passes -- [ ] Ran `bun run generate-docs` if any block metadata changed, and reverted unrelated drift the generator swept in +- [ ] Ran `bun run generate-docs` if any block metadata changed, and committed the full generated diff — including stale-page catch-up for other integrations (`bun run docs:check` fails CI on reverted generator output) - [ ] Ran `bun run lint` after fixes - [ ] Verified TypeScript compiles clean - [ ] Verified added tests fail without their fix diff --git a/.claude/commands/add-block-preview.md b/.claude/commands/add-block-preview.md deleted file mode 100644 index 8345170767f..00000000000 --- a/.claude/commands/add-block-preview.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -description: Gate a block's visibility — ship an unreleased block as a preview (hidden until revealed via AppConfig/env), reveal it to admins/orgs, GA it, or kill-switch a shipped block -argument-hint: ---- - -# Add Block Preview Skill - -You manage **block visibility gating** in Sim — hiding blocks from every discovery surface (toolbar, cmd+K search, copilot @-mentions, agent tool picker, mothership VFS/metadata/tools, Access Control list, public docs/catalog) while **never** gating execution of already-placed instances. - -## The model - -Three levers, evaluated in `apps/sim/lib/core/config/block-visibility.ts` and folded into the registry accessors (`apps/sim/blocks/registry.ts`): - -1. **`preview: true`** on the `BlockConfig` (static, in code) — the block is default-hidden EVERYWHERE (hosted, self-hosted, dev, SSR) until revealed. Fail-closed. -2. **The hosted `block-visibility` AppConfig document** — per-block rule keyed by the existing block type: - - ```jsonc - { - "": { - "enabled": false, // required. true = GA (visible to everyone) - "orgIds": ["org_..."], // optional allowlist clauses (any match reveals) - "userIds": ["user_..."], - "adminEnabled": true // platform admins (user.role === 'admin') - } - } - ``` - -3. **`PREVIEW_BLOCKS` env** (comma-separated block types) — the off-AppConfig reveal path for self-hosters and local dev. - -A revealed block that is not globally GA (`enabled !== true`, or env-revealed) renders with a **" (Preview)"** name suffix on discovery surfaces. `getBlock()` stays pure, so placed instances keep their canonical name and always execute. - -## Lifecycle of a preview block - -1. **Author** the block normally (`/add-block` etc.) and set `preview: true` on its `BlockConfig`. **Ship no `BlockMeta` and no docs until GA** — `check-block-registry` deliberately skips preview blocks in meta coverage, and `generate-docs` skips them at every gate. -2. **Local dev:** set `PREVIEW_BLOCKS=` in your env to see it (with the suffix). -3. **Merge/deploy.** The block's code is live everywhere but visible nowhere — no AppConfig rule exists and self-hosters have no env entry. -4. **Hosted preview:** add a rule to the `block-visibility` AppConfig document and start a deployment (no code deploy): - - Admins only: `{ "enabled": false, "adminEnabled": true }` - - Design-partner org: `{ "enabled": false, "orgIds": ["org_123"] }` - - GA via config (code cleanup pending): `{ "enabled": true }` — suffix disappears everywhere within ~30s (AppConfig TTL) + client refetch. - - Same runbook as `feature-flags`: edit the hosted document, `aws appconfig start-deployment` with the `sim--fast` strategy (see the infra README). -5. **GA cleanup:** delete `preview: true` from the block (now visible to self-hosters on their next upgrade), add its `BlockMeta` + regen docs, and drop the AppConfig entry. For a v2 upgrade, this is also when v1 gets `hideFromToolbar: true` (the superseded-version paradigm). - -## Kill switch (shipped blocks) - -To pull an already-GA block from discovery surfaces on hosted (incident, deprecation): add `{ "": { "enabled": false } }` to the document. Allowlist clauses can carve out exceptions. **Execution is NOT stopped** — workflows already using the block keep running; the kill switch only prevents new placement/discovery. - -## Invariants (do not violate) - -- **Execution is never gated.** The executor, serializer, drop-naming, and `isBlockTypeAccessControlExempt` resolve via pure `getBlock`. Do not add visibility checks to execution paths. -- **Clone-not-remove:** gated blocks stay in `getAllBlocks()` output as clones with `hideFromToolbar: true` — `.find`-by-type consumers rely on this. Never filter them out. -- **Keys are registry block types.** Never `custom_block_*` (parse drops them — custom blocks have their own enabled/disabled lifecycle). -- **The shared hidden-predicate is `isHiddenUnder`** (`apps/sim/blocks/visibility/context.ts`). Never restate the preview/disabled rule inline at a new consumer. -- **Process-global caches stay ungated.** `getStaticComponentFiles` (VFS) and `getExposedIntegrationTools` build the ungated universe; per-viewer filtering happens at stamp/consumer time. Never move gating into a shared builder. -- Gating is **surface hiding, not secrecy** — the full config ships in the client JS bundle. Anything truly secret cannot be a registered block. - -## Tests - -Evaluation semantics: `apps/sim/lib/core/config/block-visibility.test.ts`. Registry projection: `apps/sim/blocks/visibility/visibility.test.ts`. When gating behavior changes, extend those — mock `isPlatformAdmin` for the admin clause; use the local `withAppConfig` harness. diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md deleted file mode 100644 index 1f9a8554af8..00000000000 --- a/.claude/commands/add-block.md +++ /dev/null @@ -1,1042 +0,0 @@ ---- -description: Create or update a Sim integration block with correct subBlocks, conditions, dependsOn, modes, canonicalParamId usage, outputs, and tool wiring. Use when working on `apps/sim/blocks/blocks/{service}.ts` or aligning a block with its tools. -argument-hint: ---- - -# Add Block Skill - -You are an expert at creating block configurations for Sim. You understand the serializer, subBlock types, conditions, dependsOn, modes, and all UI patterns. - -## Your Task - -When the user asks you to create a block: -1. Create the block file in `apps/sim/blocks/blocks/{service}.ts` -2. Configure all subBlocks with proper types, conditions, and dependencies -3. Wire up tools correctly - -## Hard Rule: No Guessed Tool Outputs - -Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs. - -- Do NOT invent block outputs for undocumented tool responses -- Do NOT describe unknown JSON shapes as if they were confirmed -- Do NOT wire fields into the block just because they seem likely to exist - -If the tool outputs are not known, do one of these instead: -1. Ask the user for sample tool responses -2. Ask the user for test credentials so the tool responses can be verified -3. Limit the block to operations whose outputs are documented -4. Leave uncertain outputs out and explicitly tell the user what remains unknown - -## Block Configuration Structure - -```typescript -import { {ServiceName}Icon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' -import { AuthMode, IntegrationType } from '@/blocks/types' -import { getScopesForService } from '@/lib/oauth/utils' - -export const {ServiceName}Block: BlockConfig = { - type: '{service}', // snake_case identifier - name: '{Service Name}', // Human readable - description: 'Brief description', // One sentence - longDescription: 'Detailed description for docs', - docsLink: 'https://docs.sim.ai/integrations/{service}', - category: 'tools', // 'tools' | 'blocks' | 'triggers' - integrationType: IntegrationType.X, // Primary category (see IntegrationType enum) - tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type) - bgColor: '#HEXCOLOR', // Brand color - icon: {ServiceName}Icon, - - // Auth mode - authMode: AuthMode.OAuth, // or AuthMode.ApiKey - - // Card summary sentences — see "Canvas Sentences" below - canvasPresentation: { - defaultTitle: '{Default Operation}', - sentences: { byOperation: { /* one per operation dropdown option id */ } }, - }, - - subBlocks: [ - // Define all UI fields here - ], - - tools: { - access: ['tool_id_1', 'tool_id_2'], // Array of tool IDs this block can use - config: { - tool: (params) => `{service}_${params.operation}`, // Tool selector function - params: (params) => ({ - // Transform subBlock values to tool params - }), - }, - }, - - inputs: { - // Optional: define expected inputs from other blocks - }, - - outputs: { - // Define outputs available to downstream blocks - }, -} -``` - -## SubBlock Types Reference - -**Critical:** Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions. - -### Text Inputs -```typescript -// Single-line input -{ id: 'field', title: 'Label', type: 'short-input', placeholder: '...' } - -// Multi-line input -{ id: 'field', title: 'Label', type: 'long-input', placeholder: '...', rows: 6 } - -// Password input -{ id: 'apiKey', title: 'API Key', type: 'short-input', password: true } -``` - -### Selection Inputs -```typescript -// Dropdown (static options) -{ - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Create', id: 'create' }, - { label: 'Update', id: 'update' }, - ], - value: () => 'create', // Default value function -} - -// Combobox (searchable dropdown) -{ - id: 'field', - title: 'Label', - type: 'combobox', - options: [...], - searchable: true, -} -``` - -### Code/JSON Inputs -```typescript -{ - id: 'code', - title: 'Code', - type: 'code', - language: 'javascript', // 'javascript' | 'json' | 'python' - placeholder: '// Enter code...', -} -``` - -### OAuth/Credentials -```typescript -{ - id: 'credential', - title: 'Account', - type: 'oauth-input', - serviceId: '{service}', // Must match OAuth provider service key - requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils - placeholder: 'Select account', - required: true, -} -``` - -**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`. - -**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. - -**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: - -```typescript -{ - id: 'credential', - title: 'Account', - type: 'oauth-input', - serviceId: '{service}', - requiredScopes: getScopesForService('{service}'), - credentialKind: 'any', // omit | 'service-account' | 'any' -} -``` - -- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. -- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. -- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). - -Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. - -### OAuth deployment availability (required for integration blocks) - -A visible tools-category block with OAuth is deployment-gated. Its `oauth-input.serviceId` is -projected into `apps/sim/lib/integrations/integrations.json`, then resolved through -`resolveOAuthClientCapabilityId()` in `apps/sim/lib/core/config/env-capabilities.ts`. - -When adding or changing an OAuth integration block: - -1. Keep exactly one distinct OAuth `serviceId` across the block's `oauth-input` subBlocks. -2. Confirm that service ID resolves to an entry in `OAUTH_CLIENT_CAPABILITIES`. Google and - Microsoft service IDs intentionally share their provider-level capability; do not add duplicate - entries for those aliases. -3. For a new capability, add its required client fields to `OAUTH_CLIENT_CAPABILITIES` and ensure - every referenced field exists in the env schema in `apps/sim/lib/core/config/env.ts`. Then add - the matching `text` or `secret` input modes to `OAUTH_CLIENT_SETUP_FIELDS` in - `scripts/setup/capability-config.ts`. The CLI catalog is exhaustively typed and checked against - the runtime field list; do not infer secrecy from the field name. -4. If the canonical OAuth service declares `serviceAccountProviderId`, keep - `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in - `apps/sim/lib/integrations/service-account-metadata.ts` aligned. Set - `deploymentRequirement` only when the service-account path is preview-gated or depends on the - OAuth client fields; otherwise omit it. - -Missing capability metadata is a runtime configuration error, not a reason to make the integration -silently available. - -### Selectors (with dynamic options) -```typescript -// Channel selector (Slack, Discord, etc.) -{ - id: 'channel', - title: 'Channel', - type: 'channel-selector', - serviceId: '{service}', - placeholder: 'Select channel', - dependsOn: ['credential'], -} - -// Project selector (Jira, etc.) -{ - id: 'project', - title: 'Project', - type: 'project-selector', - serviceId: '{service}', - dependsOn: ['credential'], -} - -// File selector (Google Drive, etc.) -{ - id: 'file', - title: 'File', - type: 'file-selector', - serviceId: '{service}', - mimeType: 'application/pdf', - dependsOn: ['credential'], -} - -// User selector -{ - id: 'user', - title: 'User', - type: 'user-selector', - serviceId: '{service}', - dependsOn: ['credential'], -} -``` - -### Other Types -```typescript -// Switch/toggle -{ id: 'enabled', type: 'switch' } - -// Slider -{ id: 'temperature', title: 'Temperature', type: 'slider', min: 0, max: 2, step: 0.1 } - -// Table (key-value pairs) -{ id: 'headers', title: 'Headers', type: 'table', columns: ['Key', 'Value'] } - -// File upload -{ - id: 'files', - title: 'Attachments', - type: 'file-upload', - multiple: true, - acceptedTypes: 'image/*,application/pdf', -} -``` - -## File Input Handling - -When your block accepts file uploads, use the basic/advanced mode pattern with `normalizeFileInput`. - -### Basic/Advanced File Pattern - -```typescript -// Basic mode: Visual file upload -{ - id: 'uploadFile', - title: 'File', - type: 'file-upload', - canonicalParamId: 'file', // Both map to 'file' param - placeholder: 'Upload file', - mode: 'basic', - multiple: false, - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -// Advanced mode: Reference from other blocks -{ - id: 'fileRef', - title: 'File', - type: 'short-input', - canonicalParamId: 'file', // Both map to 'file' param - placeholder: 'Reference file (e.g., {{file_block.output}})', - mode: 'advanced', - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -``` - -**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to -a file from a previous block. Gmail attachments are the reference implementation -(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). - -Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a -path). A subblock whose meaning changes based on what the string looks like is impossible to reason -about, forces the params function to sniff the value, and makes the field's type meaningless. Give -each alternative its own subblock outside the pair: - -```typescript -// ✓ Good — the pair is "a file"; other sources are their own fields -{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, -{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, -{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept -{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept - -// ✗ Bad — one field meaning three things, resolved by guessing -{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', - placeholder: 'File reference, media ID, or public URL' }, -``` - -When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce -"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the -other paths ever get a chance to supply the value. - -**Critical constraints:** -- `canonicalParamId` must NOT match any subblock's `id` in the same block -- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by - `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations - that each need a file pair need two distinct `canonicalParamId` values. -- All members of a group must share the same `required` status - -### Normalizing File Input in tools.config - -Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at -serialization, before variable resolution, so a `` file reference is not yet a value -there. - -```typescript -import { normalizeFileInput } from '@/blocks/utils' - -tools: { - access: ['service_upload'], - config: { - tool: (params) => `service_${params.operation}`, - params: (params) => { - // Read the CANONICAL id, not the subblock ids - const { file: fileParam, ...rest } = params - const file = normalizeFileInput(fileParam, { single: true }) - return { - ...rest, - ...(file ? { file } : {}), - } - }, - }, -} -``` - -**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, -but it is not what the params function receives. `extractBlockParams` -(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: - -```typescript -const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) -sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted -if (chosen !== undefined) params[group.canonicalId] = chosen -``` - -So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), -`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a -subblock id there yields `undefined` and silently sends no file. - -Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode -and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can -never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution -produces. - -Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so -omitting a key from the returned object does not strip it from what the tool receives. Tools simply -ignore params they do not declare. - -### File Input Types in `inputs` - -Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: - -```typescript -inputs: { - file: { type: 'json', description: 'File to upload (UserFile or reference)' }, - // Legacy field for backwards compatibility - fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, -} -``` - -### Multiple Files - -For multiple file uploads: - -```typescript -{ - id: 'attachments', - title: 'Attachments', - type: 'file-upload', - multiple: true, // Allow multiple files - maxSize: 25, // Max size in MB per file - acceptedTypes: 'image/*,application/pdf,.doc,.docx', -} - -// In tools.config: -const normalizedFiles = normalizeFileInput( - params.attachments || params.attachmentRefs, - // No { single: true } - returns array -) -if (normalizedFiles) { - params.files = normalizedFiles -} -``` - -## Condition Syntax - -Controls when a field is shown based on other field values. - -### Simple Condition -```typescript -condition: { field: 'operation', value: 'create' } -// Shows when operation === 'create' -``` - -### Multiple Values (OR) -```typescript -condition: { field: 'operation', value: ['create', 'update'] } -// Shows when operation is 'create' OR 'update' -``` - -### Negation -```typescript -condition: { field: 'operation', value: 'delete', not: true } -// Shows when operation !== 'delete' -``` - -### Compound (AND) -```typescript -condition: { - field: 'operation', - value: 'send', - and: { - field: 'type', - value: 'dm', - not: true, - } -} -// Shows when operation === 'send' AND type !== 'dm' -``` - -### Complex Example -```typescript -condition: { - field: 'operation', - value: ['list', 'search'], - not: true, - and: { - field: 'authMethod', - value: 'oauth', - } -} -// Shows when operation NOT in ['list', 'search'] AND authMethod === 'oauth' -``` - -## DependsOn Pattern - -Controls when a field is enabled and when its options are refetched. - -### Simple Array (all must be set) -```typescript -dependsOn: ['credential'] -// Enabled only when credential has a value -// Options refetch when credential changes - -dependsOn: ['credential', 'projectId'] -// Enabled only when BOTH have values -``` - -### Complex (all + any) -```typescript -dependsOn: { - all: ['authMethod'], // All must be set - any: ['credential', 'apiKey'] // At least one must be set -} -// Enabled when authMethod is set AND (credential OR apiKey is set) -``` - -## Required Pattern - -Can be boolean or condition-based. - -### Simple Boolean -```typescript -required: true -required: false -``` - -### Conditional Required -```typescript -required: { field: 'operation', value: 'create' } -// Required only when operation === 'create' - -required: { field: 'operation', value: ['create', 'update'] } -// Required when operation is 'create' OR 'update' -``` - -## Mode Pattern (Basic vs Advanced) - -Controls which UI view shows the field. - -### Mode Options -- `'basic'` - Only in basic view (default UI) -- `'advanced'` - Only in advanced view -- `'both'` - Both views (default if not specified) -- `'trigger'` - Only in trigger configuration - -### canonicalParamId Pattern - -Maps multiple UI fields to a single serialized parameter: - -```typescript -// Basic mode: Visual selector -{ - id: 'channel', - title: 'Channel', - type: 'channel-selector', - mode: 'basic', - canonicalParamId: 'channel', // Both map to 'channel' param - dependsOn: ['credential'], -} - -// Advanced mode: Manual input -{ - id: 'channelId', - title: 'Channel ID', - type: 'short-input', - mode: 'advanced', - canonicalParamId: 'channel', // Both map to 'channel' param - placeholder: 'Enter channel ID manually', -} -``` - -**How it works:** -- In basic mode: `channel` selector value → `params.channel` -- In advanced mode: `channelId` input value → `params.channel` -- The serializer consolidates based on current mode - -**Critical constraints:** -- `canonicalParamId` must NOT match any other subblock's `id` in the same block (causes conflicts) -- A `canonicalParamId` links exactly one basic/advanced pair for a single logical parameter. Do NOT reuse the same `canonicalParamId` for different parameters, even under mutually-exclusive conditions/operations -- ONLY use `canonicalParamId` to link basic/advanced mode alternatives for the same logical parameter -- Do NOT use it for any other purpose - -## WandConfig Pattern - -Enables AI-assisted field generation. - -```typescript -{ - id: 'query', - title: 'Query', - type: 'code', - language: 'json', - wandConfig: { - enabled: true, - prompt: 'Generate a query based on the user request. Return ONLY the JSON.', - placeholder: 'Describe what you want to query...', - generationType: 'json-object', // Optional: affects AI behavior - maintainHistory: true, // Optional: keeps conversation context - }, -} -``` - -### Generation Types -- `'javascript-function-body'` - JS code generation -- `'json-object'` - Raw JSON (adds "no markdown" instruction) -- `'json-schema'` - JSON Schema definitions -- `'sql-query'` - SQL statements -- `'timestamp'` - Adds current date/time context - -## Tools Configuration - -**Important:** `tools.config.tool` runs during serialization before variable resolution. Put `Number()` and other type coercions in `tools.config.params` instead, which runs at execution time after variables are resolved. - -**Preferred:** Use tool names directly as dropdown option IDs to avoid switch cases: -```typescript -// Dropdown options use tool IDs directly -options: [ - { label: 'Create', id: 'service_create' }, - { label: 'Read', id: 'service_read' }, -] - -// Tool selector just returns the operation value -tool: (params) => params.operation, -``` - -### With Parameter Transformation -```typescript -tools: { - access: ['service_action'], - config: { - tool: (params) => 'service_action', - params: (params) => ({ - id: params.resourceId, - data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data, - }), - }, -} -``` - -### V2 Versioned Tool Selector -```typescript -import { createVersionedToolSelector } from '@/blocks/utils' - -tools: { - access: [ - 'service_create_v2', - 'service_read_v2', - 'service_update_v2', - ], - config: { - tool: createVersionedToolSelector({ - baseToolSelector: (params) => `service_${params.operation}`, - suffix: '_v2', - fallbackToolId: 'service_create_v2', - }), - }, -} -``` - -## Outputs Definition - -**IMPORTANT:** Block outputs have a simpler schema than tool outputs. Block outputs do NOT support: -- `optional: true` - This is only for tool outputs -- `items` property - This is only for tool outputs with array types - -Block outputs only support: -- `type` - The data type ('string', 'number', 'boolean', 'json', 'array') -- `description` - Human readable description -- `condition` - Optional visibility condition -- `hiddenFromDisplay` - Optional flag to hide from the output display - -**Nested object/`properties` outputs are tool-output-only and will fail TypeScript at build time on block outputs.** For complex shapes use `type: 'json'` and describe the inner fields in the `description` string. - -```typescript -outputs: { - // Simple outputs - id: { type: 'string', description: 'Resource ID' }, - success: { type: 'boolean', description: 'Whether operation succeeded' }, - - // Use type: 'json' for complex objects or arrays (NOT type: 'array' with items) - items: { type: 'json', description: 'List of items' }, - metadata: { type: 'json', description: 'Response metadata' }, - - // Nested outputs (for structured data) - user: { - id: { type: 'string', description: 'User ID' }, - name: { type: 'string', description: 'User name' }, - email: { type: 'string', description: 'User email' }, - }, -} -``` - -### Typed JSON Outputs - -When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. Block outputs have no nested `properties` form — always keep the output flat and put the shape in the `description`: - -```typescript -outputs: { - // BAD: Opaque json with no info about what's inside - plan: { type: 'json', description: 'Zone plan information' }, - - // GOOD: Describe the known fields in the description - plan: { - type: 'json', - description: 'Zone plan information (id, name, price, currency, frequency, is_subscribed)', - }, -} -``` - -Nested object outputs (`plan: { id: { type: 'string' }, ... }`) are a **tool-output** feature only — `OutputFieldDefinition` for blocks does not allow them and they fail TypeScript at build time. - -If the output shape is unknown because the underlying tool response is undocumented, you MUST tell the user and stop. Unknown is not the same as variable. Never guess block outputs. - -## V2 Block Pattern - -When creating V2 blocks (alongside legacy V1): - -```typescript -// V1 Block - mark as legacy -export const ServiceBlock: BlockConfig = { - type: 'service', - name: 'Service (Legacy)', - hideFromToolbar: true, // Hide from toolbar - // ... rest of config -} - -// V2 Block - visible, uses V2 tools -export const ServiceV2Block: BlockConfig = { - type: 'service_v2', - name: 'Service', // Clean name - hideFromToolbar: false, // Visible - subBlocks: ServiceBlock.subBlocks, // Reuse UI - tools: { - access: ServiceBlock.tools?.access?.map(id => `${id}_v2`) || [], - config: { - tool: createVersionedToolSelector({ - baseToolSelector: (params) => (ServiceBlock.tools?.config as any)?.tool(params), - suffix: '_v2', - fallbackToolId: 'service_default_v2', - }), - params: ServiceBlock.tools?.config?.params, - }, - }, - outputs: { - // Flat, API-aligned outputs (not wrapped in content/metadata) - }, -} -``` - -## Registering Blocks - -After creating the block, remind the user to register it in `apps/sim/blocks/registry-maps.ts` (the data maps live here; `registry.ts` holds only the accessor functions). Add the import and an entry to each map alphabetically: - -```typescript -import { ServiceBlock, ServiceBlockMeta } from '@/blocks/blocks/service' - -export const BLOCK_REGISTRY: Record = { - // ... existing blocks ... - service: ServiceBlock, -} - -export const BLOCK_META_REGISTRY: Record = { - // ... existing metas ... - service: ServiceBlockMeta, -} -``` - -## Complete Example - -```typescript -import { ServiceIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' -import { AuthMode, IntegrationType } from '@/blocks/types' -import { getScopesForService } from '@/lib/oauth/utils' - -export const ServiceBlock: BlockConfig = { - type: 'service', - name: 'Service', - description: 'Integrate with Service API', - longDescription: 'Full description for documentation...', - docsLink: 'https://docs.sim.ai/integrations/service', - category: 'tools', - integrationType: IntegrationType.DeveloperTools, - tags: ['oauth', 'api'], - bgColor: '#FF6B6B', - icon: ServiceIcon, - authMode: AuthMode.OAuth, - - subBlocks: [ - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Create', id: 'create' }, - { label: 'Read', id: 'read' }, - { label: 'Update', id: 'update' }, - { label: 'Delete', id: 'delete' }, - ], - value: () => 'create', - }, - { - id: 'credential', - title: 'Service Account', - type: 'oauth-input', - serviceId: 'service', - requiredScopes: getScopesForService('service'), - placeholder: 'Select account', - required: true, - }, - { - id: 'resourceId', - title: 'Resource ID', - type: 'short-input', - placeholder: 'Enter resource ID', - condition: { field: 'operation', value: ['read', 'update', 'delete'] }, - required: { field: 'operation', value: ['read', 'update', 'delete'] }, - }, - { - id: 'name', - title: 'Name', - type: 'short-input', - placeholder: 'Resource name', - condition: { field: 'operation', value: ['create', 'update'] }, - required: { field: 'operation', value: 'create' }, - }, - ], - - tools: { - access: ['service_create', 'service_read', 'service_update', 'service_delete'], - config: { - tool: (params) => `service_${params.operation}`, - }, - }, - - outputs: { - id: { type: 'string', description: 'Resource ID' }, - name: { type: 'string', description: 'Resource name' }, - createdAt: { type: 'string', description: 'Creation timestamp' }, - }, -} -``` - -## Connecting Blocks with Triggers - -If the service supports webhooks, connect the block to its triggers. - -```typescript -import { getTrigger } from '@/triggers' - -export const ServiceBlock: BlockConfig = { - // ... basic config ... - - triggers: { - enabled: true, - available: ['service_event_a', 'service_event_b', 'service_webhook'], - }, - - subBlocks: [ - // Tool subBlocks first... - { id: 'operation', /* ... */ }, - - // Then spread trigger subBlocks - ...getTrigger('service_event_a').subBlocks, - ...getTrigger('service_event_b').subBlocks, - ...getTrigger('service_webhook').subBlocks, - ], -} -``` - -See the `/add-trigger` skill for creating triggers. - -## Icon Requirement - -If the icon doesn't already exist in `@/components/icons.tsx`, **do NOT search for it yourself**. After completing the block, ask the user to provide the SVG: - -``` -The block is complete, but I need an icon for {Service}. -Please provide the SVG and I'll convert it to a React component. - -You can usually find this in the service's brand/press kit page, or copy it from their website. -``` - -When converting the SVG: a **monochrome** logo (single white or black mark) must -use `fill='currentColor'`, never a hardcoded `#fff`/`#000000`. Block icons render -both inside their `bgColor` tile and "bare" on a neutral page (the home Suggested -actions list) in light and dark mode; a hardcoded white/black mark goes invisible -bare on the matching background. Multi-color brand logos keep their own fills. -Verify with `bun run check:bare-icons`. - -## Advanced Mode for Optional Fields - -Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes: -- Pagination tokens -- Time range filters (start/end time) -- Sort order options -- Reply settings -- Rarely used IDs (e.g., reply-to tweet ID, quote tweet ID) -- Max results / limits - -```typescript -{ - id: 'startTime', - title: 'Start Time', - type: 'short-input', - placeholder: 'ISO 8601 timestamp', - condition: { field: 'operation', value: ['search', 'list'] }, - mode: 'advanced', // Rarely used, hide from basic view -} -``` - -## WandConfig for Complex Inputs - -Use `wandConfig` for fields that are hard to fill out manually, such as timestamps, comma-separated lists, and complex query strings. This gives users an AI-assisted input experience. - -```typescript -// Timestamps - use generationType: 'timestamp' to inject current date context -{ - id: 'startTime', - title: 'Start Time', - type: 'short-input', - mode: 'advanced', - wandConfig: { - enabled: true, - prompt: 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', - generationType: 'timestamp', - }, -} - -// Comma-separated lists - simple prompt without generationType -{ - id: 'mediaIds', - title: 'Media IDs', - type: 'short-input', - mode: 'advanced', - wandConfig: { - enabled: true, - prompt: 'Generate a comma-separated list of media IDs. Return ONLY the comma-separated values.', - }, -} -``` - -## Naming Convention - -All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MUST use `snake_case` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase. - -## BlockMeta (Required) - -Every block file must export a `{Service}BlockMeta` alongside the block — **minimum 7 templates**. Look at existing examples in `apps/sim/blocks/blocks/` (e.g. `browser_use.ts`, `google_sheets.ts`) for the pattern. - -```typescript -import type { BlockMeta } from '@/blocks/types' - -export const {Service}BlockMeta = { - tags: ['tag1', 'tag2'], // IntegrationTag[] - url: 'https://{service}.com', // external service homepage (verify it resolves) — NOT docs.sim.ai - templates: [ - { - icon: {Service}Icon, - title: '{Service} ', // 2–5 words - prompt: 'Build a workflow that...', // specific use case, 1–3 sentences - modules: ['agent', 'workflows'], // 'agent' | 'workflows' | 'tables' | 'files' | 'scheduled' | 'knowledge-base' - category: 'operations', // 'operations' | 'marketing' | 'sales' | 'engineering' | 'productivity' | 'support' | 'popular' - tags: ['automation'], - alsoIntegrations: ['slack'], // optional — other block IDs referenced in the prompt - featured: true, // optional - }, - // ... at least 6 more - ], - skills: [ // SuggestedSkill[] — 3–5 mainstream, 2–3 niche - { - name: 'summarize-thread', // kebab-case, ≤64 chars, unique, verb-led - description: 'One line: what it does and when to use it.', // ≤1024 chars - content: - '# Summarize Thread\n\n...\n\n## Steps\n1. ...\n\n## Output\n...', // markdown - }, - // ... more - ], -} as const satisfies BlockMeta -``` - -Derive templates from the service's real use cases. Each prompt should name a concrete trigger, transformation, and output — not a generic description of what the service does. - -`skills` are curated, ready-to-add agent skills shown on the integration's detail page (users click **Add** to create them in their workspace). Two hard rules: - -- **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. -- **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. - -## Canvas Sentences - -Every block declares a one-line prose summary that replaces its card's field rows: - -``` -Slack ← header (already names the block) -Posts ⟨Ship it 🚀⟩ to ⟨#eng⟩ ← the sentence; ⟨…⟩ are live value chips -``` - -Write one `byOperation` entry per operation dropdown option (or a single `default` -when the block has no operation dropdown). - -**The full authoring contract — voice, structure, and the two mistakes that break -cards silently — is `apps/sim/blocks/AGENTS.md` → "Canvas sentences". Read it -before writing any.** The two failures worth repeating here, because both are -invisible at runtime: - -1. A clause naming only one member of a `canonicalParamId` pair drops the sentence - for every advanced-mode user. List all members: - `field: ['channelSelector', 'manualChannel']`. -2. A clause referencing a subblock whose `condition` excludes that operation can - never render. - -Validate before finishing: - -```bash -bun run apps/sim/scripts/check-canvas-sentences.ts --block={service} -``` - -## Generated artifacts - -Adding a block on its own needs no **tool metadata** regeneration — a block references existing -tool IDs through `tools.access` and does not change any tool's shape. - -But if the same change also adds, edits **or removes** a tool, run `bun run tool-metadata:generate` and commit the result, or CI fails on stale artifacts. That matters here because a block's `outputs` are authored to match its tools' outputs, and the UI now reads those from the generated metadata rather than the executable registry — an unregenerated tool change makes the block's outputs disagree with what the panel renders. See `.agents/skills/tool-registry-boundary/SKILL.md`. - -A visible integration block does require the generated integration catalog and docs to be refreshed. -After adding or changing one, run: - -```bash -bun run scripts/generate-docs.ts -bun run integration-catalog:check -``` - -The catalog check independently derives deployment metadata from the executable block registry and -compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated -diff and keep only intentional changes. -## Checklist Before Finishing - -- [ ] `integrationType` is set to the correct `IntegrationType` enum value -- [ ] `tags` array includes all applicable `IntegrationTag` values -- [ ] All subBlocks have `id`, `title` (except switch), and `type` -- [ ] Conditions use correct syntax (field, value, not, and) -- [ ] DependsOn set for fields that need other values -- [ ] Required fields marked correctly (boolean or condition) -- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)` -- [ ] Every OAuth `serviceId` resolves through `resolveOAuthClientCapabilityId()` to the correct `OAUTH_CLIENT_CAPABILITIES` entry -- [ ] Any new OAuth capability fields exist in `apps/sim/lib/core/config/env.ts` -- [ ] If the OAuth service supports service accounts, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` matches its canonical `serviceAccountProviderId` and deployment requirement -- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes -- [ ] Tools.access lists all tool IDs (snake_case) -- [ ] Tools.config.tool returns correct tool ID (snake_case) -- [ ] Outputs match tool outputs -- [ ] Block + meta registered in registry-maps.ts (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`) -- [ ] If any tool was added, changed or removed alongside the block: ran `bun run tool-metadata:generate` and committed the artifacts -- [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes -- [ ] `bun run integration-catalog:check` passes -- [ ] If icon missing: asked user to provide SVG -- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread -- [ ] Optional/rarely-used fields set to `mode: 'advanced'` -- [ ] Timestamps and complex inputs have `wandConfig` enabled -- [ ] Exported `{Service}BlockMeta` with at least 7 templates -- [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) -- [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) -- [ ] `canvasPresentation.sentences` covers every operation, and `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` passes with 100% coverage - -## Final Validation (Required) - -After creating the block, you MUST validate it against every tool it references: - -1. **Read every tool definition** that appears in `tools.access` — do not skip any -2. **For each tool, verify the block has correct:** - - SubBlock inputs that cover all required tool params (with correct `condition` to show for that operation) - - SubBlock input types that match the tool param types (e.g., dropdown for enums, short-input for strings) - - `tools.config.params` correctly maps subBlock IDs to tool param names (if they differ) - - Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse()) -3. **Verify block outputs** cover the key fields returned by all tools -4. **Verify conditions** — each subBlock should only show for the operations that actually use it -5. **Verify `{Service}BlockMeta` is exported** with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` -6. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs diff --git a/.claude/commands/add-column-type.md b/.claude/commands/add-column-type.md deleted file mode 100644 index 7b362016218..00000000000 --- a/.claude/commands/add-column-type.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -description: Add a new table column type to Sim — registry entry, icon, storage shape, coercion, and the behavioral hooks the grid and API read. Use when adding a value kind under `apps/sim/lib/table/column-types/`. -argument-hint: ---- - -# Adding a Table Column Type - -A column type is **one file** in `apps/sim/lib/table/column-types/` plus a registry entry. Everything that varies per type — label, icon, storage cast, coercion, validation, conversion compatibility, formatting, editor, filter operators — lives on that one object, so no consumer needs editing. - -This was not always true: adding `currency` originally took ~40 edits across 32 `switch` arms and 26 UI branches, each of which failed **silently** when missed. The registry exists to make that impossible, so the rule is absolute: **if you find yourself adding a `case 'yourtype':` anywhere outside `column-types/`, the registry is missing a field. Add the field instead.** - -## Hard Rule: the compiler tells you what to do - -Do **not** hunt for places to edit. Add your type to the `ColumnType` union first and let `tsc` produce the list: - -```bash -cd apps/sim && bun run type-check -``` - -You will get two errors, naming `column-types/registry.ts` and `column-types/registry.server.ts`. Register in both. - -If your type owns metadata, adding its key to `TYPE_SPECIFIC_COLUMN_KEYS` produces two more legitimate errors — `FOREIGN_METADATA_VERB` in `validation.ts` (a `Record` over those keys) and the key's absence from `ColumnDefinition`. Those are the gate working, not sites to "fix". - -Any error beyond those four is a site reading a hardcoded type list that should read the registry — fix that site, don't work around it. - -## Directory Structure - -``` -apps/sim/lib/table/column-types/ -├── types.ts # ColumnTypeDefinition — the contract you implement -├── types.server.ts # ColumnTypeServerDefinition — cell migrations only -├── registry.ts # Record ← client-safe, the gate -├── registry.server.ts # Record ← adds migrations (drizzle) -├── index.ts # barrel + accessors (columnTypeOf, columnTypeById, …) -└── {type}.ts # one file per type — what you write -``` - -## Step 1: Pick the storage shape - -Decide what a cell literally holds in `user_table_rows.data` (JSONB). This drives almost everything else: - -| Storage | `jsonbCast` | Notes | -|---------|-------------|-------| -| number | `'numeric'` | Filters/sorts compare numerically. `currency` does this. | -| ISO string | `'timestamptz'` | `date` does this. | -| string / bool / object | `null` | Text comparison is correct. | - -**Prefer an existing primitive over a new shape.** `currency` stores a plain number and keeps its ISO code as *display metadata* — which is why filtering, sorting, uniqueness, and CSV export all reuse the numeric paths untouched, and why re-denominating a column rewrites zero rows. - -## Step 2: Add the icon - -Create `packages/emcn/src/icons/type-{name}.tsx`, copying the geometry conventions of its siblings exactly: - -```tsx -import type { SVGProps } from 'react' - -/** - * Type {name} icon component - {what the glyph is} for {name} columns - * @param props - SVG properties including className, fill, etc. - */ -export function Type{Pascal}(props: SVGProps) { - return ( - - ) -} -``` - -- `viewBox='-1.75 -1.5 24 24'` is the **`type-*` family** value, not the set-wide default. Match the family. -- Center the glyph on the viewBox's optical center (**y = 10.5**, **x = 10.25**) — every sibling does, and a few tenths off is visible at `size-[14px]`. -- Export alphabetically **by component name** in `packages/emcn/src/icons/index.ts`. - -## Step 3: Write the type file - -`apps/sim/lib/table/column-types/{name}.ts`. Copy the closest existing type and change what differs. Every field is required by the interface, so the compiler enumerates them for you — read the TSDoc in `types.ts` rather than guessing. - -The three that are easy to get wrong: - -- **`coerce`** is the *single* write-path implementation. The server runs it before persisting **and** the grid runs it to fill the optimistic cache. Accept every shape the value legitimately arrives in (paste, CSV, tool write), because rejecting means the cell is nulled. -- **`isCompatibleWith`** gates type conversion and must read the value **exactly as `coerce` will**, or a conversion will pass its check and then null the cell. -- **`ownedMetadata`** lists the `ColumnDefinition` keys your type owns. Anything you add must also be added to `TYPE_SPECIFIC_COLUMN_KEYS` in `types.ts` and given a phrase in `FOREIGN_METADATA_VERB` in `validation.ts` — both are `Record`-typed, so the compiler will tell you. - -## Step 4: Register - -Add the entry to `COLUMN_TYPE_REGISTRY` in `registry.ts` **and** `COLUMN_TYPE_SERVER_REGISTRY` in `registry.server.ts`. - -`COLUMN_TYPES` is declared in `types.ts` (not derived from the registry — the registry is annotated `Record` against it, which is the gate). `constants.ts` re-exports it, so `columnTypeSchema = z.enum(COLUMN_TYPES)` picks your type up with no edit. **Type-specific metadata does not** — see the next step. - -## Step 5: Migrations (only if the stored bytes change) - -If converting an existing column **to** your type must rewrite cells, add `migrateCellsTo` in `registry.server.ts`; if converting **away** must rewrite them, add `migrateCellsFrom`. - -This is load-bearing, not cosmetic: filters and sorts apply `jsonbCast` to whatever is stored, so leaving a non-castable string behind makes **every query on that column fail** — not merely render oddly. - -Prefer set-based SQL. When the transform genuinely needs JS (`currency`'s separator disambiguation), compute the values during the compatibility scan and pass them through `resolved`, then apply them in one batched statement. - -## Naming Convention - -- Type id: lowercase, singular — `currency`, not `Currency` or `currencies` -- File: `column-types/{id}.ts`, export `const {id}ColumnType` -- Icon: `type-{kebab}.tsx`, export `Type{Pascal}` - -## Watch out - -- **Import cycles.** `column-types/select.ts` imports `select-values.ts`, so `select-values.ts` must **not** import the registry — that closes a cycle and fails at module init. Inside a type's own helper module the string literal is the implementation, not a config leak. -- **The client-safe boundary.** `registry.ts` and everything it imports must stay free of `@sim/db`, `drizzle-orm`, and `next/server` — the tables grid imports it directly. A React icon is fine (it's a component *reference*, never called server-side). Only `registry.server.ts` may touch drizzle. -- **Don't re-export the registry from `@/lib/table`.** 44 server modules import that barrel; routing this through it pulls `@sim/emcn/icons` into all of them. Deep-import `@/lib/table/column-types`. -- **`import.ts`'s `coerceValue` is a SECOND write path and is not opt-in.** Importing into a column of your type always hits it, and its `default` arm silently `String(value)`s — so a missing `case` stores text in a column whose `jsonbCast` is numeric, and then every filter and sort on that column errors in Postgres. Add a `case`, even though the switch compiles without one. (It is deliberately separate from the registry's `coerce`: an import wants an unparseable value to survive as its raw string so the row error can name it.) -- **CSV inference** is an ordered heuristic in `import.ts`, deliberately not registry-driven. A new type is not inferred from a CSV unless you extend `inferColumnType` — usually you should not, since inference cannot supply configuration (an option set, a currency code). - -## If your type owns metadata, read this - -Registering the *type* is compiler-enforced. Registering its *metadata* is not, and that is where the remaining manual work lives. A key like `precision` has to be added in each of these, none of which will fail to compile if you forget: - -| Where | What happens if you forget | -|---|---| -| `lib/table/types.ts` `ColumnDefinition` | (this one DOES fail — the ownership loop indexes it) | -| `column-types/types.ts` `TYPE_SPECIFIC_COLUMN_KEYS` | it is never stripped on conversion, and poisons the target type | -| `lib/api/contracts/tables.ts` — the schema slot in all three column schemas, plus `refineColumnOptions` | zod strips it at the boundary; silently never saved | -| `columns/service.ts` `addTableColumn` param type | callers cannot pass it | -| A metadata-only update path (`updateColumnCurrency` is the model) + a branch in both column routes + the copilot tool | changing it on an existing column is a silent 200 no-op | -| `column-config-sidebar.tsx` | no UI to set it | -| `table-grid.tsx` delete-column undo + `use-table-undo.ts` restore | undo silently resets it to the default | - -`normalizeColumn`, `buildConvertedColumn`, and the undo snapshot read `TYPE_SPECIFIC_COLUMN_KEYS` generically, so those three are already zero-edit. - -**Known gap:** the metadata-only update path is ~6 near-identical copies (service + 2 routes + copilot). A `metadataUpdate` descriptor on `ColumnTypeServerDefinition` would collapse them; until that exists, copy `currency`'s. - -## Checklist Before Finishing - -- [ ] Added to the `ColumnType` union in `column-types/types.ts` -- [ ] `column-types/{id}.ts` created, every interface field filled in -- [ ] Registered in **both** `registry.ts` and `registry.server.ts` -- [ ] Icon added, centered on the family's optical center, exported alphabetically -- [ ] `migrateCellsTo` / `migrateCellsFrom` added if the stored bytes change -- [ ] New metadata keys added to `TYPE_SPECIFIC_COLUMN_KEYS` + `FOREIGN_METADATA_VERB` -- [ ] Unit tests for `coerce` / `isCompatibleWith` round-trips, verified to fail without the code -- [ ] Docs row added to `apps/docs/content/docs/en/tables/index.mdx` - -## Final Validation (Required) - -1. **`cd apps/sim && bun run type-check`** — must be clean. If any file *outside* `column-types/` errors, that file has a hardcoded type list; fix it to read the registry. -2. **Grep for leaks** — `grep -rnE "(===|!==) '{id}'|case '{id}':" apps/sim --include='*.ts' --include='*.tsx' | grep -v column-types/`. (All three forms: a plain `!==` and a `case` are how half of `currency`'s real branches are written.) Hits are expected; judge each. A hit is fine when it mounts a specific React component or encodes a genuinely one-off behavior (`json`'s mono textarea, `date`'s timezone-aware parsing). A hit is a **leak** when it restates something the registry could answer — an icon, a label, a colour, an operator set, a cast, a coercion. Leaks get a registry field, not a new branch. -3. **Run the suite** — `bunx vitest run lib/table 'app/workspace/[workspaceId]/tables' lib/api app/api/table app/api/v1 lib/copilot/tools/server/table`. Existing tests must pass **unchanged**; needing to edit one means you changed behavior for the other types. -4. **`bun run lint:check`, `bun run check:api-validation`, `bun run check:client-boundary`** from the repo root. -5. **Exercise it in the running app** on a table with one column of every type: create, edit inline / in the expanded popover / in the row modal, paste from a spreadsheet, filter, sort, convert to and from other types, export CSV, undo a column delete. diff --git a/.claude/commands/add-connector.md b/.claude/commands/add-connector.md deleted file mode 100644 index c73921b22fc..00000000000 --- a/.claude/commands/add-connector.md +++ /dev/null @@ -1,625 +0,0 @@ ---- -description: Add or update a Sim knowledge base connector for syncing documents from an external source, including auth mode, config fields, pagination, document mapping, tags, and registry wiring. Use when working in `apps/sim/connectors/{service}/` or adding a new external document source. -argument-hint: [api-docs-url] ---- - -# Add Connector Skill - -You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base. - -## Your Task - -When the user asks you to create a connector: -1. Use Context7 or WebFetch to read the service's API documentation -2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth) -3. Create the connector directory: a client-safe `meta.ts` (declarative metadata) plus the runtime module that spreads it -4. Register it in BOTH the server registry and the client-safe meta registry - -## Hard Rule: No Guessed Response Or Document Schemas - -If the service docs do not clearly show the document list response, document fetch response, pagination shape, or metadata fields, you MUST tell the user instead of guessing. - -- Do NOT invent document fields -- Do NOT guess pagination cursors or next-page fields -- Do NOT infer metadata/tag mappings from unrelated endpoints -- Do NOT fabricate `ExternalDocument` content structure from partial docs - -If the source schema is unknown, do one of these instead: -1. Ask the user for sample API responses -2. Ask the user for test credentials so you can verify live payloads -3. Implement only the documented parts of the connector -4. Leave the connector incomplete and explicitly say which fields remain unknown - -## Directory Structure - -Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the `XBlockMeta` / `BLOCK_META_REGISTRY` split in `apps/sim/blocks` — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like `input-validation.server` → `undici` → `node:net`) must stay out of the client bundle. - -Create files in `apps/sim/connectors/{service}/`: -``` -connectors/{service}/ -├── index.ts # Barrel export (re-exports the runtime connector) -├── meta.ts # ConnectorMeta — client-safe declarative metadata -└── {service}.ts # ConnectorConfig — spreads the meta + adds runtime functions -``` - -- `meta.ts` exports `{service}ConnectorMeta: ConnectorMeta`. It imports ONLY the icon from `@/components/icons`, `import type { ConnectorMeta } from '@/connectors/types'`, and any pure-data constants. It must NEVER import server/runtime code. -- `{service}.ts` exports `{service}Connector: ConnectorConfig`. It imports the meta via `import { {service}ConnectorMeta } from '@/connectors/{service}/meta'`, spreads it as the first property, and holds the runtime functions (which may import server-only helpers like `@/lib/knowledge/documents/utils`). - -## Authentication - -Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`): - -```typescript -type ConnectorAuthConfig = - | { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] } - | { mode: 'apiKey'; label?: string; placeholder?: string } -``` - -### OAuth mode -For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The `provider` must match an `OAuthService`. The modal shows a credential picker and handles token refresh automatically. - -### API key mode -For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`. - -## Connector Structure (meta.ts + runtime) - -The declarative metadata lives in `meta.ts` (`ConnectorMeta`). The runtime functions live in `{service}.ts` (`ConnectorConfig`), which spreads the meta as its first property. - -### `meta.ts` — client-safe metadata - -```typescript -import { {Service}Icon } from '@/components/icons' -import type { ConnectorMeta } from '@/connectors/types' - -export const {service}ConnectorMeta: ConnectorMeta = { - id: '{service}', - name: '{Service}', - description: 'Sync documents from {Service} into your knowledge base', - version: '1.0.0', - icon: {Service}Icon, - - auth: { - mode: 'oauth', - provider: '{service}', // Must match OAuthService in lib/oauth/types.ts - requiredScopes: ['read:...'], - }, - - configFields: [ - // Rendered dynamically by the add-connector modal UI - // Supports 'short-input', 'dropdown', and 'selector' types — see ConfigField Types below - ], - - // Optional: tag definitions are metadata too — declare them here - // tagDefinitions: [ ... ], -} -``` - -Keep `meta.ts` free of any server/runtime import. Only the icon, the `ConnectorMeta` type, and pure-data constants belong here. - -### `{service}.ts` — runtime (OAuth example) - -```typescript -import { createLogger } from '@sim/logger' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' -import { {service}ConnectorMeta } from '@/connectors/{service}/meta' -import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' - -const logger = createLogger('{Service}Connector') - -export const {service}Connector: ConnectorConfig = { - ...{service}ConnectorMeta, - - listDocuments: async (accessToken, sourceConfig, cursor) => { - // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed) - // Or full documents with content (if list API returns content inline) - // Return { documents: ExternalDocument[], nextCursor?, hasMore } - }, - - getDocument: async (accessToken, sourceConfig, externalId) => { - // Fetch full content for a single document - // Return ExternalDocument with contentDeferred: false, or null - }, - - validateConfig: async (accessToken, sourceConfig) => { - // Return { valid: true } or { valid: false, error: 'message' } - }, - - // Optional: map source metadata to semantic tag keys (translated to slots by sync engine) - mapTags: (metadata) => { - // Return Record with keys matching tagDefinitions[].id - }, -} -``` - -Only map fields in `listDocuments`, `getDocument`, `validateConfig`, and `mapTags` when the source payload shape is documented or live-verified. If not, tell the user and stop rather than guessing. - -### API key connector example - -The split is identical — `auth` lives in `meta.ts`, runtime functions in `{service}.ts`. - -```typescript -// meta.ts -export const {service}ConnectorMeta: ConnectorMeta = { - id: '{service}', - name: '{Service}', - description: 'Sync documents from {Service} into your knowledge base', - version: '1.0.0', - icon: {Service}Icon, - - auth: { - mode: 'apiKey', - label: 'API Key', // Shown above the input field - placeholder: 'Enter your {Service} API key', // Input placeholder - }, - - configFields: [ /* ... */ ], -} - -// {service}.ts -export const {service}Connector: ConnectorConfig = { - ...{service}ConnectorMeta, - listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ }, - getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ }, - validateConfig: async (accessToken, sourceConfig) => { /* ... */ }, -} -``` - -## ConfigField Types - -The add-connector modal renders these automatically — no custom UI needed. - -Three field types are supported: `short-input`, `dropdown`, and `selector`. - -```typescript -// Text input -{ - id: 'domain', - title: 'Domain', - type: 'short-input', - placeholder: 'yoursite.example.com', - required: true, -} - -// Dropdown (static options) -{ - id: 'contentType', - title: 'Content Type', - type: 'dropdown', - required: false, - options: [ - { label: 'Pages only', id: 'page' }, - { label: 'Blog posts only', id: 'blogpost' }, - { label: 'All content', id: 'all' }, - ], -} -``` - -## Dynamic Selectors (Canonical Pairs) - -Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`. - -The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`. - -### Rules - -1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`. -2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required. -3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`. -4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children. - -### Selector canonical pair example (Airtable base → table cascade) - -```typescript -configFields: [ - // Base: selector (basic) + manual (advanced) - { - id: 'baseSelector', - title: 'Base', - type: 'selector', - selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts - canonicalParamId: 'baseId', - mode: 'basic', - placeholder: 'Select a base', - required: true, - }, - { - id: 'baseId', - title: 'Base ID', - type: 'short-input', - canonicalParamId: 'baseId', - mode: 'advanced', - placeholder: 'e.g. appXXXXXXXXXXXXXX', - required: true, - }, - // Table: selector depends on base (basic) + manual (advanced) - { - id: 'tableSelector', - title: 'Table', - type: 'selector', - selectorKey: 'airtable.tables', - canonicalParamId: 'tableIdOrName', - mode: 'basic', - dependsOn: ['baseSelector'], // References the selector field ID - placeholder: 'Select a table', - required: true, - }, - { - id: 'tableIdOrName', - title: 'Table Name or ID', - type: 'short-input', - canonicalParamId: 'tableIdOrName', - mode: 'advanced', - placeholder: 'e.g. Tasks', - required: true, - }, - // Non-selector fields stay as-is - { id: 'maxRecords', title: 'Max Records', type: 'short-input', ... }, -] -``` - -### Selector with domain dependency (Jira/Confluence pattern) - -When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`. - -```typescript -configFields: [ - { - id: 'domain', - title: 'Jira Domain', - type: 'short-input', - placeholder: 'yoursite.atlassian.net', - required: true, - }, - { - id: 'projectSelector', - title: 'Project', - type: 'selector', - selectorKey: 'jira.projects', - canonicalParamId: 'projectKey', - mode: 'basic', - dependsOn: ['domain'], - placeholder: 'Select a project', - required: true, - }, - { - id: 'projectKey', - title: 'Project Key', - type: 'short-input', - canonicalParamId: 'projectKey', - mode: 'advanced', - placeholder: 'e.g. ENG, PROJ', - required: true, - }, -] -``` - -### How `dependsOn` maps to `SelectorContext` - -The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`): - -``` -oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId, -siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId -``` - -### Available selector keys - -Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors: - -| SelectorKey | Context Deps | Returns | -|-------------|-------------|---------| -| `airtable.bases` | credential | Base ID + name | -| `airtable.tables` | credential, `baseId` | Table ID + name | -| `slack.channels` | credential | Channel ID + name | -| `gmail.labels` | credential | Label ID + name | -| `google.calendar` | credential | Calendar ID + name | -| `linear.teams` | credential | Team ID + name | -| `linear.projects` | credential, `teamId` | Project ID + name | -| `jira.projects` | credential, `domain` | Project key + name | -| `confluence.spaces` | credential, `domain` | Space key + name | -| `notion.databases` | credential | Database ID + name | -| `asana.workspaces` | credential | Workspace GID + name | -| `microsoft.teams` | credential | Team ID + name | -| `microsoft.channels` | credential, `teamId` | Channel ID + name | -| `webflow.sites` | credential | Site ID + name | -| `outlook.folders` | credential | Folder ID + name | - -## ExternalDocument Shape - -Every document returned from `listDocuments`/`getDocument` must include: - -```typescript -{ - externalId: string // Source-specific unique ID - title: string // Document title - content: string // Extracted plain text (or '' if contentDeferred) - contentDeferred?: boolean // true = content will be fetched via getDocument - mimeType: 'text/plain' // Always text/plain (content is extracted) - contentHash: string // Metadata-based hash for change detection - sourceUrl?: string // Link back to original (stored on document record) - metadata?: Record // Source-specific data (fed to mapTags) -} -``` - -## Content Deferral (Required for file/content-download connectors) - -**All connectors that require per-document API calls to fetch content MUST use `contentDeferred: true`.** This is the standard pattern — `listDocuments` returns lightweight metadata stubs, and content is fetched lazily by the sync engine via `getDocument` only for new/changed documents. - -This pattern is critical for reliability: the sync engine processes documents in batches and enqueues each batch for processing immediately. If a sync times out, all previously-batched documents are already queued. Without deferral, content downloads during listing can exhaust the sync task's time budget before any documents are saved. - -### When to use `contentDeferred: true` - -- The service's list API does NOT return document content (only metadata) -- Content requires a separate download/export API call per document -- Examples: Google Drive, OneDrive, SharePoint, Dropbox, Notion, Confluence, Gmail, Obsidian, Evernote, GitHub - -### When NOT to use `contentDeferred` - -- The list API already returns the full content inline (e.g., Slack messages, Reddit posts, HubSpot notes) -- No per-document API call is needed to get content - -### Content Hash Strategy - -Use a **metadata-based** `contentHash` — never a content-based hash. The hash must be derivable from the list response metadata alone, so the sync engine can detect changes without downloading content. - -Good metadata hash sources: -- `modifiedTime` / `lastModifiedDateTime` — changes when file is edited -- Git blob SHA — unique per content version -- API-provided content hash (e.g., Dropbox `content_hash`) -- Version number (e.g., Confluence page version) - -Format: `{service}:{id}:{changeIndicator}` - -```typescript -// Google Drive: modifiedTime changes on edit -contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}` - -// GitHub: blob SHA is a content-addressable hash -contentHash: `gitsha:${item.sha}` - -// Dropbox: API provides content_hash -contentHash: `dropbox:${entry.id}:${entry.content_hash ?? entry.server_modified}` - -// Confluence: version number increments on edit -contentHash: `confluence:${page.id}:${page.version.number}` -``` - -**Critical invariant:** The `contentHash` MUST be identical whether produced by `listDocuments` (stub) or `getDocument` (full doc). Both should use the same stub function to guarantee this. - -### Implementation Pattern - -```typescript -// 1. Create a stub function (sync, no API calls) -function fileToStub(file: ServiceFile): ExternalDocument { - return { - externalId: file.id, - title: file.name || 'Untitled', - content: '', - contentDeferred: true, - mimeType: 'text/plain', - sourceUrl: `https://service.com/file/${file.id}`, - contentHash: `service:${file.id}:${file.modifiedTime ?? ''}`, - metadata: { /* fields needed by mapTags */ }, - } -} - -// 2. listDocuments returns stubs (fast, metadata only) -listDocuments: async (accessToken, sourceConfig, cursor) => { - const response = await fetchWithRetry(listUrl, { ... }) - const files = (await response.json()).files - const documents = files.map(fileToStub) - return { documents, nextCursor, hasMore } -} - -// 3. getDocument fetches content and returns full doc with SAME contentHash -getDocument: async (accessToken, sourceConfig, externalId) => { - const metadata = await fetchWithRetry(metadataUrl, { ... }) - const file = await metadata.json() - if (file.trashed) return null - - try { - const content = await fetchContent(accessToken, file) - if (!content.trim()) return null - const stub = fileToStub(file) - return { ...stub, content, contentDeferred: false } - } catch (error) { - logger.warn(`Failed to fetch content for: ${file.name}`, { error }) - return null - } -} -``` - -### Reference Implementations - -- **Google Drive**: `connectors/google-drive/google-drive.ts` — file download/export with `modifiedTime` hash -- **GitHub**: `connectors/github/github.ts` — git blob SHA hash -- **Notion**: `connectors/notion/notion.ts` — blocks API with `last_edited_time` hash -- **Confluence**: `connectors/confluence/confluence.ts` — version number hash - -## tagDefinitions — Declared Tag Definitions - -Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes. -On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names. - -```typescript -tagDefinitions: [ - { id: 'labels', displayName: 'Labels', fieldType: 'text' }, - { id: 'version', displayName: 'Version', fieldType: 'number' }, - { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, -], -``` - -Each entry has: -- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`) -- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified") -- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from - -Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`. -The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`. - -## `@/connectors/utils` Helpers - -Reuse these instead of inlining the same logic (the validator enforces them): - -- `htmlToPlainText(html)` — strip HTML to plain text before indexing `ExternalDocument.content`. Never index raw HTML. -- `computeContentHash(content)` — stable content hash for change detection. -- `parseTagDate(value)` — parse to a valid `Date` or `undefined` (guards Invalid Date). Use in `mapTags` for date fields. -- `joinTagArray(value)` — validate an array and join to a comma-separated string, or `undefined`. Use in `mapTags` for array/label fields. -- `parseMultiValue(value)` — normalize a value into a `string[]`. - -## mapTags — Metadata to Semantic Keys - -Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set. -The sync engine calls this automatically and translates semantic keys to actual DB slots -using the `tagSlotMapping` stored on the connector. - -Return keys must match the `id` values declared in `tagDefinitions`. - -Use the `@/connectors/utils` helpers for the common transforms — don't hand-roll date/array validation: - -```typescript -import { joinTagArray, parseTagDate } from '@/connectors/utils' - -mapTags: (metadata: Record): Record => { - const result: Record = {} - - // joinTagArray validates the array and joins to a comma-separated string (undefined if empty) - const labels = joinTagArray(metadata.labels) - if (labels) result.labels = labels - - // Validate numbers — guard against NaN - if (metadata.version != null) { - const num = Number(metadata.version) - if (!Number.isNaN(num)) result.version = num - } - - // parseTagDate returns a valid Date or undefined (guards against Invalid Date) - const lastModified = parseTagDate(metadata.lastModified) - if (lastModified) result.lastModified = lastModified - - return result -} -``` - -## External API Calls — Use `fetchWithRetry` - -All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged. - -For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max). - -```typescript -import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils' - -// Background sync — use defaults -const response = await fetchWithRetry(url, { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, -}) - -// validateConfig — tighter retry budget -const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS) -``` - -## sourceUrl - -If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path). - -## Capped or Incomplete Listings — `syncContext.listingCapped` (REQUIRED) - -If `listDocuments` can ever return **less than the full source set** on a non-incremental sync — a `maxItems`/`maxDocuments`-style cap, or a transient per-item error that drops a still-existing document from the listing — it MUST set `syncContext.listingCapped = true` when that happens. - -The sync engine reconciles deletions by comparing the full listing against stored documents: anything not seen is **hard-deleted** (sync-engine.ts, gated on `!syncContext?.listingCapped`). A truncated listing without this flag deletes every real document beyond the cap. This was the single most common bug found when auditing connectors — do not omit it. - -```typescript -if (hitLimit && syncContext) { - syncContext.listingCapped = true -} -``` - -Rules: -- Set it when a user-configured cap truncates the listing while more documents exist -- Set it when a thrown error caused a still-present document to be skipped during listing -- Do NOT set it when the source is genuinely exhausted (deleted documents must still reconcile) -- Do NOT set it for intentional scope filters (e.g. a date cutoff) — out-of-scope documents should be reconciled normally - -## Sync Engine Behavior (Do Not Modify) - -The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It: -1. Calls `listDocuments` with pagination until `hasMore` is false -2. Compares `contentHash` to detect new/changed/unchanged documents -3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically -4. Handles soft-delete of removed documents -5. Resolves access tokens automatically — OAuth tokens are refreshed, API keys are decrypted from the `encryptedApiKey` column - -You never need to modify the sync engine when adding a connector. - -## Icon - -The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain. - -If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG. - -## Registering - -Register in BOTH registries, keeping the same alphabetical-by-id ordering in each. - -1. **Server registry** — `apps/sim/connectors/registry.server.ts` (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes): - -```typescript -import { {service}Connector } from '@/connectors/{service}' - -export const CONNECTOR_REGISTRY: ConnectorRegistry = { - // ... existing connectors ... - {service}: {service}Connector, -} -``` - -2. **Client-safe meta registry** — `apps/sim/connectors/registry.ts` (imports each connector's `meta.ts` only, so client components can use it without pulling server-only code; the metadata counterpart to `BLOCK_META_REGISTRY`): - -```typescript -import { {service}ConnectorMeta } from '@/connectors/{service}/meta' - -export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { - // ... existing connector metas ... - {service}: {service}ConnectorMeta, -} -``` - -`registry.ts` exports `CONNECTOR_META_REGISTRY: ConnectorMetaRegistry` plus the helpers `getConnectorMeta(id)` and `getAllConnectorMeta()`, importing each `@/connectors/{service}/meta` directly — never the runtime module. `registry.server.ts` exports `CONNECTOR_REGISTRY: ConnectorRegistry`. - -## Reference Implementations - -- **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination -- **OAuth + contentDeferred (blocks API)**: `apps/sim/connectors/notion/notion.ts` — complex block content extraction deferred to `getDocument` -- **OAuth + contentDeferred (git)**: `apps/sim/connectors/github/github.ts` — blob SHA hash, tree listing -- **OAuth + inline content**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching -- **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth - -## Checklist - -- [ ] Created `connectors/{service}/meta.ts` with `{service}ConnectorMeta: ConnectorMeta` (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports -- [ ] Created `connectors/{service}/{service}.ts` with `{service}Connector: ConnectorConfig` spreading the meta + runtime functions -- [ ] Created `connectors/{service}/index.ts` barrel export -- [ ] **Auth configured correctly:** - - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts` - - API key: `auth.label` and `auth.placeholder` set appropriately -- [ ] **Selector fields configured correctly (if applicable):** - - Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`) - - `required` is identical on both fields in each canonical pair - - `selectorKey` exists in `hooks/selectors/registry.ts` - - `dependsOn` references selector field IDs (not `canonicalParamId`) - - Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS` -- [ ] `listDocuments` handles pagination with metadata-based content hashes -- [ ] `syncContext.listingCapped = true` set whenever the listing is truncated (max-items cap or transient per-item error) — required to prevent the engine's deletion reconciliation from removing unseen documents -- [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch) -- [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument` -- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative) -- [ ] `metadata` includes source-specific data for tag mapping -- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags` -- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions) -- [ ] `validateConfig` verifies the source is accessible -- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`) -- [ ] All optional config fields validated in `validateConfig` -- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG) -- [ ] Registered the full connector in `connectors/registry.server.ts` -- [ ] Registered the meta in `connectors/registry.ts` (same alphabetical-by-id ordering as registry.server.ts) diff --git a/.claude/commands/add-enrichment.md b/.claude/commands/add-enrichment.md deleted file mode 100644 index b8da9bb3bb4..00000000000 --- a/.claude/commands/add-enrichment.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -description: Add a code-defined table enrichment (registry entry) under `apps/sim/enrichments/` backed by an ordered provider cascade, ensuring every provider tool it calls has hosted-key support. Use when adding a per-row table enrichment that fills cells via existing Sim tools. -argument-hint: ---- - -# Adding a Table Enrichment - -Enrichments are code-defined entries in `apps/sim/enrichments/` that run **directly per table row** (no workflow). Each enrichment declares inputs, outputs, and an ordered list of **providers**; the cascade runner tries providers in order and the first non-empty result fills the cell. Each provider calls one existing Sim tool via `executeTool`, which injects the workspace's BYOK key or a **hosted key** and bills usage automatically. - -Because enrichments run on Sim's hosted keys by default, **every provider tool you reference must have hosted-key support** — otherwise it can only run when the workspace brings its own key. This command makes that check a required step. - -## Overview - -| Step | What | Where | -|------|------|-------| -| 1 | Pick the data-source tool(s) for each output | `tools/{service}/` + `tools/registry.ts` | -| 2 | **Verify each tool has `hosting`; if not, run `/add-hosted-key`** | `tools/{service}/{action}.ts` | -| 3 | Write the enrichment definition | `enrichments/{name}/{name}.ts` + `index.ts` | -| 4 | Register it | `enrichments/registry.ts` | -| 5 | Verify | tsc / biome / manual run | - -## Architecture (what you're plugging into) - -- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, mapOutput }`. Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe. -- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `firstNonEmpty(arr)`, `splitName(fullName)`. -- **`enrichments/run.ts`** — the server-only cascade runner. Calls `executeTool(provider.toolId, { ...params, _context: { workspaceId } })`, accumulates hosted-key cost, returns the first non-empty mapped result. **You do not edit this** — it works for any registry entry. -- **`enrichments/registry.ts`** — `ENRICHMENT_REGISTRY` / `ALL_ENRICHMENTS` / `getEnrichment`. Register new entries here. - -Outputs automatically become table columns; billing, the catalog/sidebar UI, the column meta-header icon, and per-row execution all work with no extra wiring. - -## Step 1: Pick the data-source tool(s) - -For each output the enrichment produces, decide which existing tool provides it. Look up the service's API and the tool in `apps/sim/tools/{service}/` (e.g. `hunter_email_finder`, `pdl_person_enrich`, `pdl_company_enrich`). Confirm: - -- The tool id is registered in `apps/sim/tools/registry.ts`. -- Its `params` accept what you can derive from table columns (read the tool's `params`). -- Its `outputs` / `transformResponse` actually expose the field you need (read the real output shape — don't assume). - -Order providers **cheapest / most-likely-to-hit first**; the cascade stops at the first non-empty result. Apollo / LinkedIn are not hosted-safe (ToS) — don't use them. - -## Step 2: Verify hosted-key support — chain to `/add-hosted-key` if missing - -**This is the required gate.** For every tool a provider calls, open `apps/sim/tools/{service}/{action}.ts` and check for a `hosting` block: - -```typescript -hosting: { - envKeyPrefix: 'SERVICE_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'service', - pricing: { /* ... */ }, - rateLimit: { /* ... */ }, -} -``` - -- **If `hosting` is present** — good. Note the `envKeyPrefix`; the deployment needs `{PREFIX}_COUNT` + `{PREFIX}_1..N` env vars set for the hosted key to actually resolve at runtime (ops concern, not code). If those env vars aren't set in the target environment, the provider will only run with a workspace BYOK key. -- **If `hosting` is absent** — the tool can't use a Sim-provided key, so the enrichment would silently produce blank cells on hosted Sim. **Stop and run `/add-hosted-key `** to add hosted-key support to that tool first, then come back. Do this for every provider tool that lacks it. - -Why it matters: the cascade runner only bills (and only reads `output.cost.total`) when `executeTool` injected a hosted key, which requires the tool's `hosting` config. No `hosting` → no hosted key → the enrichment depends entirely on per-workspace BYOK. - -## Step 3: Write the enrichment definition - -Create `apps/sim/enrichments/{name}/{name}.ts` and a barrel `index.ts`. Mirror the existing entries (`work-email`, `phone-number`, `company-domain`, `company-info`). - -```typescript -import { SomeIcon } from '@sim/emcn/icons' -import { filterUndefined } from '@sim/utils/object' -import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers' -import type { EnrichmentConfig } from '@/enrichments/types' - -export const myEnrichment: EnrichmentConfig = { - id: 'my-enrichment', - name: 'My Enrichment', - description: 'One concise sentence describing what it finds.', - icon: SomeIcon, - inputs: [ - // Person enrichments take a single canonical `fullName` (Clay-style); - // split it with splitName() for tools that need first/last. - { id: 'fullName', name: 'Full name', type: 'string', required: true }, - { id: 'companyDomain', name: 'Company domain', type: 'string' }, - ], - outputs: [{ id: 'value', name: 'value', type: 'string' }], - providers: [ - toolProvider({ - id: 'provider-a', - label: 'Provider A', - toolId: 'service_action', // must have `hosting` (Step 2) - buildParams: (inputs) => { - // Return null when there aren't enough inputs → cascade skips this provider. - const name = splitName(inputs.fullName) - const domain = normalizeDomain(inputs.companyDomain) - if (!name || !domain) return null - return { domain, first_name: name.firstName, last_name: name.lastName } - }, - mapOutput: (output) => { - // Return { [outputId]: value } on a hit, or null to fall through. - const value = str(output.value) - return value ? { value } : null - }, - }), - // ...additional fallback providers, in priority order. - ], -} -``` - -```typescript -// apps/sim/enrichments/{name}/index.ts -export { myEnrichment } from './my-enrichment' -``` - -Rules: -- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call. -- `buildParams` returns `null` when inputs are insufficient (provider skipped). `mapOutput` returns `null`/empty for a miss (falls through). Use `filterUndefined` when assembling optional tool params; coerce numbers explicitly (don't pass `''` to number outputs). -- Output `id`s are the keys `mapOutput` returns; output `name`s are the default column names (the user can rename them in the config). - -## Step 4: Register it - -In `apps/sim/enrichments/registry.ts`, import and add the entry (catalog order is registration order): - -```typescript -import { myEnrichment } from '@/enrichments/my-enrichment' - -export const ENRICHMENT_REGISTRY: EnrichmentRegistry = { - // ...existing - [myEnrichment.id]: myEnrichment, -} -``` - -## Step 5: Verify - -1. `bun run type-check` (from `apps/sim`) and `bunx biome check` on the changed files. -2. In a table → **+ New column → Enrichments** → pick the new enrichment, map its inputs to columns, name the output column(s), Save. Confirm it appears in the catalog with its icon/description. -3. With hosted keys (or a workspace BYOK key) configured for each provider's service, run a row and confirm the cell fills; the dev-server log shows `Enrichment hit { provider }`. A row whose providers all miss completes blank; a row where every provider errored shows an error cell. - -## Checklist - -- [ ] Each output mapped to a real tool field (verified against the tool's `params`/`outputs`) -- [ ] **Every provider tool has a `hosting` block — ran `/add-hosted-key` for any that didn't** -- [ ] Providers ordered cheapest / most-likely-first; Apollo/LinkedIn not used -- [ ] Enrichment file is client-safe (no `@/tools` import); uses `toolProvider` + shared helpers -- [ ] `buildParams` returns `null` on insufficient inputs; `mapOutput` returns `null` on a miss -- [ ] Registered in `enrichments/registry.ts` -- [ ] tsc + biome clean; created and ran the column end-to-end diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md deleted file mode 100644 index 07e38a50443..00000000000 --- a/.claude/commands/add-feature-flag.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -description: Add a runtime feature flag (AppConfig-backed on prod, secret fallback off-prod), global by default or optionally gated by org id, user id, or platform admin -argument-hint: ---- - -# Add Feature Flag Skill - -You add a **runtime feature flag** to Sim that can change on prod with no redeploy (AWS AppConfig). Prefer a global on/off flag unless the rollout actually needs per-organization, per-user, or platform-admin targeting. When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only). - -## When to use this vs `env-flags.ts` - -- **Feature flag** (`@/lib/core/config/feature-flags.ts`): runtime global on/off by default, optionally scoped by `userId`/`orgId`/admin. This skill. -- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.** - -If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead. - -## The flag model - -A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any configured clause matches: - -```ts -interface FeatureFlagRule { - enabled?: boolean // global default for everyone - orgIds?: string[] // allowlisted organization ids - userIds?: string[] // allowlisted user ids - adminEnabled?: boolean // platform admins (user.role === 'admin') -} -``` - -Critically, **none of this is expressible in code** — gating (especially `adminEnabled`) can only be set through AppConfig, so no environment can grant access from a code literal. Off-AppConfig (self-hosted/OSS/local), a flag is simply on or off, derived from its fallback secret. - -## Steps - -1. **Confirm the granularity before editing code.** If the user has not already specified it, stop and ask: - - > Should `` be a global on/off flag (recommended), or does it need rollout targeting by organization, user, and/or platform admin? - - - Recommend **global**. Do not infer scoped gating merely because the call site already has a user or organization id. - - If the user chooses scoped gating but does not name the dimensions, ask which of organization, user, and platform admin it needs. Wire only the selected dimensions. - - If the user wants a fixed per-deployment toggle rather than a runtime AppConfig flag, use `env-flags.ts` instead. - -2. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally): - - ```ts - const FEATURE_FLAGS = { - '': { - description: '', - fallback: '', - }, - } - ``` - - `fallback` is the env/secret key (typed as `keyof typeof env`), so add `` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. - -3. **Gate the call site at the chosen granularity.** For the recommended global mode, pass no context: - - ```ts - import { isFeatureEnabled } from '@/lib/core/config/feature-flags' - - if (await isFeatureEnabled('')) { - // gated behavior - } - ``` - - Do not fetch, resolve, or thread through user or organization context solely for a global flag. - - For scoped rollout, pass only the dimensions the user selected. Admin status is resolved internally, so ordinary callers pass `userId`, not a role: - - ```ts - import { isFeatureEnabled } from '@/lib/core/config/feature-flags' - - if (await isFeatureEnabled('', { userId, orgId })) { - // gated behavior - } - ``` - - - Organization targeting uses `orgId`; user and platform-admin targeting require `userId`. - - Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read. - - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. - - **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig. - -4. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag to the hosted `feature-flags` document using `enabled` for global rollout or only the selected `orgIds`/`userIds`/`adminEnabled` clauses for scoped rollout, then start a `sim--fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled. - -5. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts` that matches the chosen granularity. For a global flag, exercise `isFeatureEnabled('')` with an AppConfig `enabled` rule and toggle the fallback secret for the off-AppConfig path. For scoped rollout, cover only the selected clauses and mock `isPlatformAdmin` when testing `adminEnabled`. - -6. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. - -## Notes - -- Flag keys are `kebab-case`. -- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. -- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only. -- Never add or propagate request context unless the user chose scoped rollout. -- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `adminEnabled` is the deciding clause. diff --git a/.claude/commands/add-hosted-key.md b/.claude/commands/add-hosted-key.md deleted file mode 100644 index cf1b4a4b4b1..00000000000 --- a/.claude/commands/add-hosted-key.md +++ /dev/null @@ -1,301 +0,0 @@ ---- -description: Add hosted API key support to a tool so Sim provides the key (metered and billed to the workspace) when a user has not brought their own. Use when adding a `hosting` config to a tool under `apps/sim/tools/{service}/`. -argument-hint: ---- - -# Adding Hosted Key Support to a Tool - -When a tool has hosted key support, Sim provides its own API key if the user hasn't configured one (via BYOK or env var). Usage is metered and billed to the workspace. - -## Overview - -| Step | What | Where | -|------|------|-------| -| 1 | Register BYOK provider ID | `tools/types.ts`, `lib/api/contracts/byok-keys.ts` | -| 2 | Research the API's pricing and rate limits | API docs / pricing page (before writing any code) | -| 3 | Add `hosting` config to the tool | `tools/{service}/{action}.ts` | -| 4 | Hide API key field when hosted | `blocks/blocks/{service}.ts` | -| 5 | Add to BYOK settings UI | BYOK settings component (`byok.tsx`) | -| 6 | Summarize pricing and throttling comparison | Output to user (after all code changes) | - -## Step 1: Register the BYOK Provider ID - -Add the new provider to the `BYOKProviderId` union in `tools/types.ts`: - -```typescript -export type BYOKProviderId = - | 'openai' - | 'anthropic' - // ...existing providers - | 'your_service' -``` - -Then add the same provider id to the `byokProviderIdSchema` enum in `lib/api/contracts/byok-keys.ts` (this is what the byok-keys route validates against): - -```typescript -export const byokProviderIdSchema = z.enum([ - 'openai', - 'anthropic', - // ...existing providers - 'your_service', -]) -``` - -## Step 2: Research the API's Pricing Model and Rate Limits - -**Before writing any `getCost` or `rateLimit` code**, look up the service's official documentation for both pricing and rate limits. You need to understand: - -### Pricing - -1. **How the API charges** — per request, per credit, per token, per step, per minute, etc. -2. **Whether the API reports cost in its response** — look for fields like `creditsUsed`, `costDollars`, `tokensUsed`, or similar in the response body or headers -3. **Whether cost varies by endpoint/options** — some APIs charge more for certain features (e.g., Firecrawl charges 1 credit/page base but +4 for JSON format, +4 for enhanced mode) -4. **The dollar-per-unit rate** — what each credit/token/unit costs in dollars on our plan - -### Rate Limits - -1. **What rate limits the API enforces** — requests per minute/second, tokens per minute, concurrent requests, etc. -2. **Whether limits vary by plan tier** — free vs paid vs enterprise often have different ceilings -3. **Whether limits are per-key or per-account** — determines whether adding more hosted keys actually increases total throughput -4. **What the API returns when rate limited** — HTTP 429, `Retry-After` header, error body format, etc. -5. **Whether there are multiple dimensions** — some APIs limit both requests/min AND tokens/min independently - -Search the API's docs/pricing page (use WebSearch/WebFetch). Capture the pricing model as a comment in `getCost` so future maintainers know the source of truth. - -### Setting Our Rate Limits - -Our rate limiter (`lib/core/rate-limiter/hosted-key/`) uses a token-bucket algorithm applied **per billing actor** (workspace). It supports two modes: - -- **`per_request`** — simple; just `requestsPerMinute`. Good when the API charges flat per-request or cost doesn't vary much. -- **`custom`** — `requestsPerMinute` plus additional `dimensions` (e.g., `tokens`, `search_units`). Each dimension has its own `limitPerMinute` and an `extractUsage` function that reads actual usage from the response. Use when the API charges on a variable metric (tokens, credits) and you want to cap that metric too. - -When choosing values for `requestsPerMinute` and any dimension limits: - -- **Stay well below the API's per-key limit** — our keys are shared across all workspaces. If the API allows 60 RPM per key and we have 3 keys, the global ceiling is ~180 RPM. Set the per-workspace limit low enough (e.g., 20-60 RPM) that many workspaces can coexist without collectively hitting the API's ceiling. -- **Account for key pooling** — our round-robin distributes requests across `N` hosted keys, so the effective API-side rate per key is `(total requests) / N`. But per-workspace limits are enforced *before* key selection, so they apply regardless of key count. -- **Prefer conservative defaults** — it's easy to raise limits later but hard to claw back after users depend on high throughput. - -## Step 3: Add `hosting` Config to the Tool - -Add a `hosting` object to the tool's `ToolConfig`. This tells the execution layer how to acquire hosted keys, calculate cost, and rate-limit. - -```typescript -hosting: { - envKeyPrefix: 'YOUR_SERVICE_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'your_service', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Response missing creditsUsed field') - } - const creditsUsed = output.creditsUsed as number - const cost = creditsUsed * 0.001 // dollars per credit - return { cost, metadata: { creditsUsed } } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, -}, -``` - -### Hosted Key Env Var Convention - -Keys use a numbered naming pattern driven by a count env var: - -``` -YOUR_SERVICE_API_KEY_COUNT=3 -YOUR_SERVICE_API_KEY_1=sk-... -YOUR_SERVICE_API_KEY_2=sk-... -YOUR_SERVICE_API_KEY_3=sk-... -``` - -The `envKeyPrefix` value (`YOUR_SERVICE_API_KEY`) determines which env vars are read at runtime. Adding more keys only requires bumping the count and adding the new env var. - -### Pricing: Prefer API-Reported Cost - -Always prefer using cost data returned by the API (e.g., `creditsUsed`, `costDollars`). This is the most accurate because it accounts for variable pricing tiers, feature modifiers, and plan-level discounts. - -**When the API reports cost** — use it directly and throw if missing: - -```typescript -pricing: { - type: 'custom', - getCost: (params, output) => { - if (output.creditsUsed == null) { - throw new Error('Response missing creditsUsed field') - } - // $0.001 per credit — from https://example.com/pricing - const cost = (output.creditsUsed as number) * 0.001 - return { cost, metadata: { creditsUsed: output.creditsUsed } } - }, -}, -``` - -**When the API does NOT report cost** — compute it from params/output based on the pricing docs, but still validate the data you depend on: - -```typescript -pricing: { - type: 'custom', - getCost: (params, output) => { - if (!Array.isArray(output.searchResults)) { - throw new Error('Response missing searchResults, cannot determine cost') - } - // Serper: 1 credit for <=10 results, 2 credits for >10 — from https://serper.dev/pricing - const credits = Number(params.num) > 10 ? 2 : 1 - return { cost: credits * 0.001, metadata: { credits } } - }, -}, -``` - -**`getCost` must always throw** if it cannot determine cost. Never silently fall back to a default — this would hide billing inaccuracies. - -### Capturing Cost Data from the API - -If the API returns cost info, capture it in `transformResponse` so `getCost` can read it from the output: - -```typescript -transformResponse: async (response: Response) => { - const data = await response.json() - return { - success: true, - output: { - results: data.results, - creditsUsed: data.creditsUsed, // pass through for getCost - }, - } -}, -``` - -For async/polling tools, capture it in `postProcess` when the job completes: - -```typescript -if (jobData.status === 'completed') { - result.output = { - data: jobData.data, - creditsUsed: jobData.creditsUsed, - } -} -``` - -## Step 4: Hide the API Key Field When Hosted - -In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` to the API key subblock. This hides the field on hosted Sim since the platform provides the key: - -```typescript -{ - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - required: true, - hideWhenHosted: true, -}, -``` - -The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`). - -### Excluding Specific Operations from Hosted Key Support - -When a block has multiple operations but some operations should **not** use a hosted key (e.g., the underlying API is deprecated, unsupported, or too expensive), use the **duplicate apiKey subblock** pattern. This is the same pattern Exa uses for its `research` operation: - -1. **Remove the `hosting` config** from the tool definition for that operation — it must not have a `hosting` object at all. -2. **Duplicate the `apiKey` subblock** in the block config with opposing conditions: - -```typescript -// API Key — hidden when hosted for operations with hosted key support -{ - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - required: true, - hideWhenHosted: true, - condition: { field: 'operation', value: 'unsupported_op', not: true }, -}, -// API Key — always visible for unsupported_op (no hosted key support) -{ - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - required: true, - condition: { field: 'operation', value: 'unsupported_op' }, -}, -``` - -Both subblocks share the same `id: 'apiKey'`, so the same value flows to the tool. The conditions ensure only one is visible at a time. The first has `hideWhenHosted: true` and shows for all hosted operations; the second has no `hideWhenHosted` and shows only for the excluded operation — meaning users must always provide their own key for that operation. - -To exclude multiple operations, use an array: `{ field: 'operation', value: ['op_a', 'op_b'] }`. - -**Reference implementations:** -- **Exa** (`blocks/blocks/exa.ts`): `exa_research` operation excluded from hosting — duplicate `apiKey` pair around lines ~348-365 -- **Google Maps** (`blocks/blocks/google_maps.ts`): `speed_limits` operation excluded from hosting (deprecated Roads API) - -## Step 5: Add to the BYOK Settings UI - -Add an entry to the `PROVIDERS` array in the BYOK settings component so users can bring their own key. You need the service icon from `components/icons.tsx`: - -```typescript -{ - id: 'your_service', - name: 'Your Service', - icon: YourServiceIcon, - description: 'What this service does', - placeholder: 'Enter your API key', -}, -``` - -## Step 6: Summarize Pricing and Throttling Comparison - -After all code changes are complete, output a detailed summary to the user covering: - -### What to include - -1. **API's pricing model** — how the service charges (per token, per credit, per request, etc.), the specific rates found in docs, and whether the API reports cost in responses. -2. **Our `getCost` approach** — how we calculate cost, what fields we depend on, and any assumptions or estimates (especially when the API doesn't report exact dollar cost). -3. **API's rate limits** — the documented limits (RPM, TPM, concurrent, etc.), which plan tier they apply to, and whether they're per-key or per-account. -4. **Our `rateLimit` config** — what we set for `requestsPerMinute` (and dimensions if custom mode), why we chose those values, and how they compare to the API's limits. -5. **Key pooling impact** — how many hosted keys we expect, and how round-robin distribution affects the effective per-key rate at the API. -6. **Gaps or risks** — anything the API charges for that we don't meter, rate limit dimensions we chose not to enforce, or pricing that may be inaccurate due to variable model/tier costs. - -### Format - -Present this as a structured summary with clear headings. Example: - -``` -### Pricing -- **API charges**: $X per 1M tokens (input), $Y per 1M tokens (output) — varies by model -- **Response reports cost?**: No — only token counts in `usage` field -- **Our getCost**: Estimates cost at $Z per 1M total tokens based on median model pricing -- **Risk**: Actual cost varies by model; our estimate may over/undercharge for cheap/expensive models - -### Throttling -- **API limits**: 300 RPM per key (paid tier), 60 RPM (free tier) -- **Per-key or per-account**: Per key — more keys = more throughput -- **Our config**: 60 RPM per workspace (per_request mode) -- **With N keys**: Effective per-key rate is (total RPM across workspaces) / N -- **Headroom**: Comfortable — even 10 active workspaces at full rate = 600 RPM / 3 keys = 200 RPM per key, under the 300 RPM API limit -``` - -This summary helps reviewers verify that the pricing and rate limiting are well-calibrated and surfaces any risks that need monitoring. - -## Checklist - -- [ ] Provider added to `BYOKProviderId` in `tools/types.ts` -- [ ] Provider added to `byokProviderIdSchema` enum in `lib/api/contracts/byok-keys.ts` -- [ ] API pricing docs researched — understand per-unit cost and whether the API reports cost in responses -- [ ] API rate limits researched — understand RPM/TPM limits, per-key vs per-account, and plan tiers -- [ ] `hosting` config added to the tool with `envKeyPrefix`, `apiKeyParam`, `byokProviderId`, `pricing`, and `rateLimit` -- [ ] `getCost` throws if required cost data is missing from the response -- [ ] Cost data captured in `transformResponse` or `postProcess` if API provides it -- [ ] `hideWhenHosted: true` added to the API key subblock in the block config -- [ ] Provider entry added to the BYOK settings UI with icon and description -- [ ] Env vars documented: `{PREFIX}_COUNT` and `{PREFIX}_1..N` -- [ ] Pricing and throttling summary provided to reviewer diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md deleted file mode 100644 index 08d8fc92f08..00000000000 --- a/.claude/commands/add-integration.md +++ /dev/null @@ -1,1000 +0,0 @@ ---- -description: Add a complete Sim integration from API docs, covering tools, block, icon, optional triggers, registrations, resolved-secret/model-input safety, and integration conventions. Use when introducing a new service under `apps/sim/tools`, `apps/sim/blocks`, and `apps/sim/triggers`. -argument-hint: [api-docs-url] ---- - -# Add Integration Skill - -You are an expert at adding complete integrations to Sim. This skill orchestrates the full process of adding a new service integration. - -## Overview - -Adding an integration involves these steps in order: -1. **Research** - Read the service's API documentation -2. **Create Tools** - Build tool configurations for each API operation -3. **Create Block** - Build the block UI configuration -4. **Add Icon** - Add the service's brand icon -5. **Create Triggers** (optional) - If the service supports webhooks -6. **Register** - Register tools, block, and triggers in their registries -7. **Configure Deployment Availability** - Wire OAuth client and service-account metadata -8. **Generate and Validate the Catalog** - Regenerate docs/catalog artifacts and run drift checks - -## Step 1: Research the API - -Before writing any code: -1. Use Context7 to find official documentation: `mcp__context7__resolve-library-id`, then fetch with `mcp__context7__query-docs` -2. Or use WebFetch to read API docs directly -3. Identify: - - Authentication method (OAuth, API Key, both) - - Available operations (CRUD, search, etc.) - - Required vs optional parameters - - Response structures - -### Hard Rule: No Guessed Response Schemas - -If the official docs do not clearly show the response JSON shape for an endpoint, you MUST stop and tell the user exactly which outputs are unknown. - -- Do NOT guess response field names -- Do NOT infer nested JSON paths from related endpoints -- Do NOT invent output properties just because they seem likely -- Do NOT implement `transformResponse` against unverified payload shapes - -If response schemas are missing or incomplete, do one of the following before proceeding: -1. Ask the user for sample responses -2. Ask the user for test credentials so you can verify the live payload -3. Reduce the scope to only endpoints whose response shapes are documented -4. Leave the tool unimplemented and explicitly report why - -## Step 2: Create Tools - -### Directory Structure -``` -apps/sim/tools/{service}/ -├── index.ts # Barrel exports -├── types.ts # TypeScript interfaces -├── {action1}.ts # Tool for action 1 -├── {action2}.ts # Tool for action 2 -└── ... -``` - -### Key Patterns - -**types.ts:** -```typescript -import type { ToolResponse } from '@/tools/types' - -export interface {Service}{Action}Params { - accessToken: string // For OAuth services - // OR - apiKey: string // For API key services - - requiredParam: string - optionalParam?: string -} - -export interface {Service}Response extends ToolResponse { - output: { - // Define output structure - } -} -``` - -**Tool file pattern:** -```typescript -export const {service}{Action}Tool: ToolConfig = { - id: '{service}_{action}', - name: '{Service} {Action}', - description: '...', - version: '1.0.0', - - oauth: { required: true, provider: '{service}' }, // If OAuth - - params: { - accessToken: { type: 'string', required: true, visibility: 'hidden', description: '...' }, - // ... other params - }, - - request: { url, method, headers, body }, - - transformResponse: async (response) => { - const data = await response.json() - return { - success: true, - output: { - field: data.field ?? null, // Always handle nullables - }, - } - }, - - outputs: { /* ... */ }, -} -``` - -### Critical Rules -- `visibility: 'hidden'` for OAuth tokens -- `visibility: 'user-only'` for API keys and user credentials -- `visibility: 'user-or-llm'` for operation parameters -- Always use `?? null` for nullable API response fields -- Always use `?? []` for optional array fields -- Set `optional: true` for outputs that may not exist -- Never output raw JSON dumps - extract meaningful fields -- When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic -- If you do not know the response JSON shape from docs or verified examples, you MUST tell the user and stop. Never guess outputs or response mappings. - -### Resolved Secrets at Model and Persistence Boundaries - -Classify every request field before implementing the tool: - -This is opt-in, not a blanket integration migration. Add a model-input declaration only when the -service's official documentation or an unambiguous local execution path proves that the exact -field is consumed by an AI model. If that cannot be established, preserve existing tool behavior -and leave the field unannotated. - -- **Ordinary provider/API input:** leave it unchanged. Explicit `{{...}}` references resolve and are - sent with their normal request semantics. A URL, domain, resource ID, control field, or opaque - payload is not model-visible merely because the provider is AI-backed or may process the - referenced resource later. -- **Text or structured content consumed by an AI model:** declare `request.modelInput` with - `mode: 'project'` and select only the exact model-visible fields. The shared executor replaces - activated Sim secrets with canonical `{{NAME}}` labels before request formatting. For nested or - JSON-string fields, use a small shared selector plus `applyProjected`; verify that selecting the - rebuilt params reproduces the projected selection. -- **Serialized model content sent directly to an external provider:** include the serialized - top-level param in `request.modelInput`. Project the private copy before the existing request - formatter parses it; keep formatter behavior deterministic when a whole-value placeholder is not - valid in the serialized grammar. Do not introduce a second hard-rejection path. -- **Opaque model input owned by an authenticated internal route** such as inline audio, image, - video, or document bytes: add `privateProvenance` to a projected request, or use - `mode: 'private-provenance'` when there is no textual projection. Do not select storage keys, - paths, signed URLs, or ordinary remote URLs as byte provenance; the owning route must authorize - stored bytes independently at model egress. The route must call - `validateOpaqueModelInputProvenance` before downloading or sending content to the model and must - apply the workspace-file provenance guard before reading a persisted workspace file. -- **Sim-owned durable storage or internal execution handoff** that can later enter a workflow/model - (table cells, Agent memory, knowledge documents/chunks, workspace-file contents, or child-workflow - input): transport encrypted field-scoped provenance with `request.secretProvenance`. The - authenticated receiver validates the exact selection and scope, strips the private envelope, and - persists, imports, or propagates it at the owning boundary. Preserve shared legacy behavior for - headerless internal calls and rows/files whose provenance marker is `NULL`; never invent a - tool-local migration rule. - -Hard rules: - -- Never substitute secret plaintext into source or serialize plaintext provenance. -- Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns - transport and strips private metadata from functional results. -- Never attach private provenance to an external URL or to `directExecution`. Project proven - model-visible external fields with `request.modelInput`; otherwise preserve ordinary request - semantics. Use an authenticated internal route when encrypted provenance must cross the boundary. -- Never sanitize arbitrary third-party tool results. Projection applies only to secrets activated - by Sim's resolved-secret provenance for that execution/tool call. -- Do not add provenance merely because a value is persisted, returned by a tool, or appears in a - filename. Require a concrete Sim `{{...}}` resolution path and a later model/log boundary. If an - unsupported field can resolve a secret but does not justify durable tracking (for example a - `file_write` path), reject it at that exact ingress. -- At diagnostic boundaries, project only values carrying execution-scoped provenance. Ordinary - provider responses, filenames, URLs, and errors remain unchanged when Sim did not resolve a - secret into them. - -Add focused tests covering named projection, ordinary identical text without provenance, nested and -serialized shape handling, unchanged ordinary external inputs, malformed/incomplete private metadata -failing closed, headerless legacy requests, and absence of private metadata in the public tool result. -For durable sinks, also cover legacy `NULL` markers, exact-empty new writes, tracked secret writes, -stale/missing sidecars, and scope isolation. - -## Step 3: Create Block - -### File Location -`apps/sim/blocks/blocks/{service}.ts` - -### Block Structure -```typescript -import { {Service}Icon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' -import { AuthMode, IntegrationType } from '@/blocks/types' -import { getScopesForService } from '@/lib/oauth/utils' - -export const {Service}Block: BlockConfig = { - type: '{service}', - name: '{Service}', - description: '...', - longDescription: '...', - docsLink: 'https://docs.sim.ai/integrations/{service}', - category: 'tools', - integrationType: IntegrationType.X, // Primary category (see IntegrationType enum) - tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type) - bgColor: '#HEXCOLOR', - icon: {Service}Icon, - authMode: AuthMode.OAuth, // or AuthMode.ApiKey - - subBlocks: [ - // Operation dropdown - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Operation 1', id: 'action1' }, - { label: 'Operation 2', id: 'action2' }, - ], - value: () => 'action1', - }, - // Credential field - { - id: 'credential', - title: '{Service} Account', - type: 'oauth-input', - serviceId: '{service}', - requiredScopes: getScopesForService('{service}'), - required: true, - }, - // Conditional fields per operation - // ... - ], - - tools: { - access: ['{service}_action1', '{service}_action2'], - config: { - tool: (params) => `{service}_${params.operation}`, - }, - }, - - outputs: { /* ... */ }, -} -``` - -### Key SubBlock Patterns - -**Condition-based visibility:** -```typescript -{ - id: 'resourceId', - title: 'Resource ID', - type: 'short-input', - condition: { field: 'operation', value: ['read', 'update', 'delete'] }, - required: { field: 'operation', value: ['read', 'update', 'delete'] }, -} -``` - -**DependsOn for cascading selectors:** -```typescript -{ - id: 'project', - type: 'project-selector', - dependsOn: ['credential'], -}, -{ - id: 'issue', - type: 'file-selector', - dependsOn: ['credential', 'project'], -} -``` - -**Basic/Advanced mode for dual UX:** -```typescript -// Basic: Visual selector -{ - id: 'channelSelector', - type: 'channel-selector', - mode: 'basic', - canonicalParamId: 'channel', - dependsOn: ['credential'], -}, -// Advanced: Manual input -{ - id: 'channelId', - type: 'short-input', - mode: 'advanced', - canonicalParamId: 'channel', -} -``` - -Note neither subblock `id` is `channel` — the canonical id is a third name that both members map -onto, and it is the only one that survives serialization. - -**Critical Canonical Param Rules:** -- `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys - groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two - operations that each need their own pair must use two different canonical ids -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. - A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as - in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate - identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the - mutually exclusive sources `required: false`, and enforce "exactly one" at execution -- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent -- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions -- **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) -- **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs (e.g., `fileSelector`, `manualFileId`) -- **Params function:** Must use canonical param IDs, NOT raw subblock IDs (raw IDs are deleted after canonical transformation) - -### BlockMeta (Required) - -Export a `{Service}BlockMeta` in the same file as the block — **minimum 7 templates**. See `.agents/skills/add-block/SKILL.md` → "BlockMeta (Required)" for valid `modules` and `category` values and the full pattern. - -```typescript -export const {Service}BlockMeta = { - tags: ['tag1', 'tag2'], - templates: [ - { - icon: {Service}Icon, - title: '{Service} ', - prompt: 'Build a workflow that...', // concrete trigger → transformation → output - modules: ['agent', 'workflows'], - category: 'operations', - tags: ['automation'], - alsoIntegrations: ['slack'], // when the prompt references another service - }, - // ... at least 6 more - ], -} as const satisfies BlockMeta -``` - -## Step 4: Add Icon - -### File Location -`apps/sim/components/icons.tsx` - -### Pattern -```typescript -export function {Service}Icon(props: SVGProps) { - return ( - - {/* SVG paths from user-provided SVG */} - - ) -} -``` - -### Getting Icons -**Do NOT search for icons yourself.** At the end of implementation, ask the user to provide the SVG: - -``` -I've completed the integration. Before I can add the icon, please provide the SVG for {Service}. -You can usually find this in the service's brand/press kit page, or copy it from their website. - -Paste the SVG code here and I'll convert it to a React component. -``` - -Once the user provides the SVG: -1. Extract the SVG paths/content -2. Create a React component that spreads props -3. Ensure viewBox is preserved from the original SVG - -### Theme-safety (bare rendering) — REQUIRED - -The icon renders both inside its colored `bgColor` tile AND "bare" (no tile) on a -neutral page — e.g. the home **Suggested actions** list — in both light and dark -mode. A monochrome logo whose paths hardcode a single near-white or near-black -fill is invisible bare on the matching background (white-on-white in light mode, -black-on-black in dark mode). - -Rules when adding the SVG: - -- **Monochrome logos** (a single white or black mark): draw the shape with - `fill='currentColor'`, not `fill='#fff'` / `fill='#000000'`. It then inherits - white inside dark tiles, near-black inside light tiles (via - `getTileIconColorClass`), and the theme-aware `var(--text-icon)` bare — legible - everywhere. Do NOT set `iconColor` for these. -- **Multi-color brand logos** (their own vivid fills): keep the hardcoded fills. - They read on any background. Only set `iconColor` (a vivid brand hex, never a - near-black/near-white tile color) if the bare icon should adopt a brand tint. -- A large white shape with a tiny vivid accent (e.g. a logo where the body is the - white negative space) still vanishes bare — convert the body to `currentColor`. - -Verify with `bun run check:bare-icons` (also runs in CI). It flags purely -monochrome hazards; for partial-accent logos, eyeball the suggested-actions list -in both light and dark mode. - -## Step 5: Create Triggers (Optional) - -If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper. - -### Directory Structure -``` -apps/sim/triggers/{service}/ -├── index.ts # Barrel exports -├── utils.ts # Trigger options, setup instructions, extra fields -├── {event_a}.ts # Primary trigger (includes dropdown) -├── {event_b}.ts # Secondary triggers (no dropdown) -└── webhook.ts # Generic webhook (optional) -``` - -### Key Pattern - -```typescript -import { buildTriggerSubBlocks } from '@/triggers' -import { {service}TriggerOptions, {service}SetupInstructions, build{Service}ExtraFields } from './utils' - -// Primary trigger - includeDropdown: true -export const {service}EventATrigger: TriggerConfig = { - id: '{service}_event_a', - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_a', - triggerOptions: {service}TriggerOptions, - includeDropdown: true, // Only for primary trigger! - setupInstructions: {service}SetupInstructions('Event A'), - extraFields: build{Service}ExtraFields('{service}_event_a'), - }), - // ... -} - -// Secondary triggers - no dropdown -export const {service}EventBTrigger: TriggerConfig = { - id: '{service}_event_b', - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_b', - triggerOptions: {service}TriggerOptions, - // No includeDropdown! - setupInstructions: {service}SetupInstructions('Event B'), - extraFields: build{Service}ExtraFields('{service}_event_b'), - }), - // ... -} -``` - -### Connect to Block -```typescript -import { getTrigger } from '@/triggers' - -export const {Service}Block: BlockConfig = { - triggers: { - enabled: true, - available: ['{service}_event_a', '{service}_event_b'], - }, - subBlocks: [ - // Tool fields... - ...getTrigger('{service}_event_a').subBlocks, - ...getTrigger('{service}_event_b').subBlocks, - ], -} -``` - -See `/add-trigger` skill for complete documentation. - -## Step 6: Register Everything - -### Tools Registry (`apps/sim/tools/registry.ts`) - -```typescript -// Add import (alphabetically) -import { - {service}Action1Tool, - {service}Action2Tool, -} from '@/tools/{service}' - -// Add to tools object (alphabetically) -export const tools: Record = { - // ... existing tools ... - {service}_action1: {service}Action1Tool, - {service}_action2: {service}Action2Tool, -} -``` - -Then regenerate the generated tool metadata and commit it: - -```bash -bun run tool-metadata:generate -``` - -Client code reads `params`/`outputs` from these artifacts rather than importing -the registry, so a tool you add, change or remove is invisible to the UI until they are regenerated, -and CI fails on stale ones. See `.agents/skills/tool-registry-boundary/SKILL.md`. - -### Block Registry (`apps/sim/blocks/registry-maps.ts`) - -The data maps (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`) live in `registry-maps.ts`; `registry.ts` holds only the accessor functions. Add the import and an entry to each map alphabetically: - -```typescript -// Add import (alphabetically) -import { {Service}Block, {Service}BlockMeta } from '@/blocks/blocks/{service}' - -// Add to the config map (alphabetically) -export const BLOCK_REGISTRY: Record = { - // ... existing blocks ... - {service}: {Service}Block, -} - -// Add to the catalog-meta map (alphabetically) -export const BLOCK_META_REGISTRY: Record = { - // ... existing metas ... - {service}: {Service}BlockMeta, -} -``` - -### Trigger Registry (`apps/sim/triggers/registry.ts`) - If triggers exist - -```typescript -// Add import (alphabetically) -import { - {service}EventATrigger, - {service}EventBTrigger, - {service}WebhookTrigger, -} from '@/triggers/{service}' - -// Add to TRIGGER_REGISTRY (alphabetically) -export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing triggers ... - {service}_event_a: {service}EventATrigger, - {service}_event_b: {service}EventBTrigger, - {service}_webhook: {service}WebhookTrigger, -} -``` - -## Step 7: Configure Deployment Availability - -Do this for every visible OAuth integration. API-key and unauthenticated integrations do not need -an OAuth client capability. - -The block's `oauth-input.serviceId` is the canonical link between the generated integration catalog, -the OAuth service configuration, deployment availability, and the setup CLI. - -1. Ensure the block has exactly one distinct OAuth `serviceId` and that it matches the canonical - service entry in `apps/sim/lib/oauth/oauth.ts`. -2. Confirm `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended provider entry in - `OAUTH_CLIENT_CAPABILITIES` in `apps/sim/lib/core/config/env-capabilities.ts`. Google and - Microsoft service IDs deliberately share provider-level capabilities. -3. For a new OAuth provider, add the required client fields to `OAUTH_CLIENT_CAPABILITIES`, add - every referenced field to the env schema in `apps/sim/lib/core/config/env.ts`, and add the - matching `text` or `secret` entries to `OAUTH_CLIENT_SETUP_FIELDS` in - `scripts/setup/capability-config.ts`. Do not create integration-specific setup logic or infer - secret fields from naming; the CLI mapping is exhaustively checked against the runtime fields. -4. If the canonical OAuth service has `serviceAccountProviderId`, add the matching projection to - `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in - `apps/sim/lib/integrations/service-account-metadata.ts`. Use: - - no `deploymentRequirement` when the service-account path works independently of OAuth client fields; - - `'oauth-client'` when it requires the same deployment OAuth client fields; - - `'preview-gated'` when availability is controlled by the service-account preview block. - -Never add a permissive fallback for missing capability metadata. A visible OAuth integration without -a resolvable capability must fail validation. - -## Step 8: Generate and Validate the Catalog - -Run the documentation generator: -```bash -bun run scripts/generate-docs.ts -bun run integration-catalog:check -``` - -This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`). - -The same generator refreshes `apps/sim/lib/integrations/integrations.json`. The catalog check then -derives the deployment-relevant fields from the executable block registry and compares them with the -committed projection. Review the generated diff and keep only intentional changes. - -## V2 Integration Pattern - -If creating V2 versions (API-aligned outputs): - -1. **V2 Tools** - Add `_v2` suffix, version `2.0.0`, flat outputs -2. **V2 Block** - Add `_v2` type, use `createVersionedToolSelector` -3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true` -4. **Registry** - Register both versions - -```typescript -// In registry -{service}: {Service}Block, // V1 (legacy, hidden) -{service}_v2: {Service}V2Block, // V2 (visible) -``` - -## Complete Checklist - -### Tools -- [ ] Created `tools/{service}/` directory -- [ ] Created `types.ts` with all interfaces -- [ ] Created tool file for each operation -- [ ] All params have correct visibility -- [ ] All nullable fields use `?? null` -- [ ] All optional outputs have `optional: true` -- [ ] Created `index.ts` barrel export -- [ ] Registered all tools in `tools/registry.ts` -- [ ] Ran `bun run tool-metadata:generate` and committed the regenerated artifacts -- [ ] Classified every model-visible, opaque, Sim-durable, and internal-execution request field -- [ ] Added shared model-input projection or private provenance only where required; ordinary - external resource locators and control inputs retain their request semantics -- [ ] Confirmed ordinary third-party tool results are not generically sanitized -- [ ] Added provenance compatibility and fail-closed boundary tests where applicable - -### Block -- [ ] Created `blocks/blocks/{service}.ts` -- [ ] Set `integrationType` to the correct `IntegrationType` enum value -- [ ] Set `tags` array with all applicable `IntegrationTag` values -- [ ] Defined operation dropdown with all operations -- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')` -- [ ] Added conditional fields per operation -- [ ] Set up dependsOn for cascading selectors -- [ ] Configured tools.access with all tool IDs -- [ ] Configured tools.config.tool selector -- [ ] Defined outputs matching tool outputs -- [ ] Registered block + meta in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`) -- [ ] If triggers: set `triggers.enabled` and `triggers.available` -- [ ] If triggers: spread trigger subBlocks with `getTrigger()` -- [ ] Exported `{Service}BlockMeta` with at least 7 templates - -### OAuth Scopes (if OAuth service) -- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS` -- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` -- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode) -- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode) - -### Deployment Availability (if OAuth service) -- [ ] Block declares exactly one distinct `oauth-input.serviceId` -- [ ] `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry -- [ ] Every new OAuth capability field exists in `apps/sim/lib/core/config/env.ts` -- [ ] Runtime OAuth fields live in `OAUTH_CLIENT_CAPABILITIES`; matching CLI input modes live in the exhaustively checked `OAUTH_CLIENT_SETUP_FIELDS` -- [ ] If `serviceAccountProviderId` is configured, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` has the matching projection and deployment requirement - -### Icon -- [ ] Asked user to provide SVG -- [ ] Added icon to `components/icons.tsx` -- [ ] Icon spreads props correctly -- [ ] Monochrome marks use `fill='currentColor'` (not hardcoded white/black) so the icon renders bare in light AND dark mode — verified with `bun run check:bare-icons` - -### Triggers (if service supports webhooks) -- [ ] Created `triggers/{service}/` directory -- [ ] Created `utils.ts` with options, instructions, and extra fields helpers -- [ ] Primary trigger uses `includeDropdown: true` -- [ ] Secondary triggers do NOT have `includeDropdown` -- [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] Created `index.ts` barrel export -- [ ] Registered all triggers in `triggers/registry.ts` - -### Docs -- [ ] Ran `bun run scripts/generate-docs.ts` -- [ ] Verified docs file created -- [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change -- [ ] `bun run integration-catalog:check` passes - -### Final Validation (Required) -- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs -- [ ] Verified block subBlocks cover all required tool params with correct conditions -- [ ] Verified block outputs match what the tools actually return -- [ ] Verified `tools.config.params` correctly maps and coerces all param types -- [ ] Verified every tool output and `transformResponse` path against documented or live-verified JSON responses -- [ ] If any response schema remained unknown, explicitly told the user instead of guessing -- [ ] `{Service}BlockMeta` exported with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` - -## Example Command - -When the user asks to add an integration: - -``` -User: Add a Stripe integration - -You: I'll add the Stripe integration. Let me: - -1. First, research the Stripe API using Context7 -2. Create the tools for key operations (payments, subscriptions, etc.) -3. Create the block with operation dropdown -4. Register everything -5. Generate docs -6. Ask you for the Stripe icon SVG - -[Proceed with implementation...] - -[After completing steps 1-5...] - -I've completed the Stripe integration. Before I can add the icon, please provide the SVG for Stripe. -You can usually find this in the service's brand/press kit page, or copy it from their website. - -Paste the SVG code here and I'll convert it to a React component. -``` - -## File Handling - -When your integration handles file uploads or downloads, follow these patterns to work with `UserFile` objects consistently. - -### What is a UserFile? - -A `UserFile` is the standard file representation in Sim: - -```typescript -interface UserFile { - id: string // Unique identifier - name: string // Original filename - url: string // Presigned URL for download - size: number // File size in bytes - type: string // MIME type (e.g., 'application/pdf') - base64?: string // Optional base64 content (if small file) - key?: string // Internal storage key - context?: object // Storage context metadata -} -``` - -### File Input Pattern (Uploads) - -For tools that accept file uploads, **always route through an internal API endpoint** rather than calling external APIs directly. This ensures proper file content retrieval. - -#### 1. Block SubBlocks for File Input - -Use the basic/advanced mode pattern: - -```typescript -// Basic mode: File upload UI -{ - id: 'uploadFile', - title: 'File', - type: 'file-upload', - canonicalParamId: 'file', // Maps to 'file' param - placeholder: 'Upload file', - mode: 'basic', - multiple: false, - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -// Advanced mode: Reference from previous block -{ - id: 'fileRef', - title: 'File', - type: 'short-input', - canonicalParamId: 'file', // Same canonical param - placeholder: 'Reference file (e.g., {{file_block.output}})', - mode: 'advanced', - required: true, - condition: { field: 'operation', value: 'upload' }, -}, -``` - -**Critical:** `canonicalParamId` must NOT match any subblock `id`. - -#### 2. Normalize File Input in Block Config - -In `tools.config.tool`, use `normalizeFileInput` to handle all input variants: - -```typescript -import { normalizeFileInput } from '@/blocks/utils' - -tools: { - config: { - tool: (params) => { - // Normalize file from basic (uploadFile), advanced (fileRef), or legacy (fileContent) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile - } - return `{service}_${params.operation}` - }, - }, -} -``` - -#### 3. Create Special Internal Tool Execution Route - -Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. This raw route pattern is only for an integration's provider-execution boundary when it needs special file normalization, large-body handling, or protocol behavior. It is not the pattern for CRUD or other operations on protected Sim resources. For those, use the `migrate-application-operation` skill and an authorized application use case with the ordinary internal/v2 route builders. - -Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/tools/{service}.ts` (or an existing aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema. Authenticate and perform cheap admission before parsing or downloading files. - -```typescript -// apps/sim/lib/api/contracts/tools/{service}.ts -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts' -import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' - -export const {service}UploadBodySchema = z.object({ - accessToken: z.string(), - file: FileInputSchema.optional().nullable(), - fileContent: z.string().optional().nullable(), - // ... other params -}) - -export const {service}UploadResponseSchema = z.object({ - success: z.boolean(), - output: z.object({ id: z.string(), url: z.string() }).optional(), - error: z.string().optional(), -}) - -export const {service}UploadContract = defineRouteContract({ - method: 'POST', - path: '/api/tools/{service}/upload', - body: {service}UploadBodySchema, - response: { mode: 'json', schema: {service}UploadResponseSchema }, -}) - -export type {Service}UploadBody = z.input -export type {Service}UploadResponse = z.output -``` - -```typescript -// apps/sim/app/api/tools/{service}/upload/route.ts -import { createLogger } from '@sim/logger' -import { NextResponse, type NextRequest } from 'next/server' -import { {service}UploadContract } from '@/lib/api/contracts/tools/{service}' -import { parseRequest } from '@/lib/api/server' -import { checkInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { type RawFileInput } from '@/lib/uploads/utils/file-schemas' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' - -const logger = createLogger('{Service}UploadAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - // Auth always runs BEFORE parseRequest — never validate untrusted input before authenticating. - const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest({service}UploadContract, request, {}) - if (!parsed.success) return parsed.response - const data = parsed.data.body - - let fileBuffer: Buffer - let fileName: string - - // Prefer UserFile input, fall back to legacy base64 - if (data.file) { - const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger) - if (userFiles.length === 0) { - return NextResponse.json({ success: false, error: 'Invalid file' }, { status: 400 }) - } - const userFile = userFiles[0] - fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) - fileName = userFile.name - } else if (data.fileContent) { - // Legacy: base64 string (backwards compatibility) - fileBuffer = Buffer.from(data.fileContent, 'base64') - fileName = 'file' - } else { - return NextResponse.json({ success: false, error: 'File required' }, { status: 400 }) - } - - // Now call external API with fileBuffer - const response = await fetch('https://api.{service}.com/upload', { - method: 'POST', - headers: { Authorization: `Bearer ${data.accessToken}` }, - body: new Uint8Array(fileBuffer), // Convert Buffer for fetch - }) - - // ... handle response -}) -``` - -#### 4. Update Tool to Use Internal Route - -```typescript -export const {service}UploadTool: ToolConfig = { - id: '{service}_upload', - // ... - params: { - file: { type: 'file', required: false, visibility: 'user-or-llm' }, - fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy - }, - request: { - url: '/api/tools/{service}/upload', // Internal route - method: 'POST', - body: (params) => ({ - accessToken: params.accessToken, - file: params.file, - fileContent: params.fileContent, - }), - }, -} -``` - -### File Output Pattern (Downloads) - -For tools that return files, use `FileToolProcessor` to store files and return `UserFile` objects. - -#### In Tool transformResponse - -```typescript -import { FileToolProcessor } from '@/executor/utils/file-tool-processor' - -transformResponse: async (response, context) => { - const data = await response.json() - - // Process file outputs to UserFile objects - const fileProcessor = new FileToolProcessor(context) - const file = await fileProcessor.processFileData({ - data: data.content, // base64 or buffer - mimeType: data.mimeType, - filename: data.filename, - }) - - return { - success: true, - output: { file }, - } -} -``` - -#### In API Route (for complex file handling) - -```typescript -// Return file data that FileToolProcessor can handle -return NextResponse.json({ - success: true, - output: { - file: { - data: base64Content, - mimeType: 'application/pdf', - filename: 'document.pdf', - }, - }, -}) -``` - -### Key Helpers Reference - -| Helper | Location | Purpose | -|--------|----------|---------| -| `normalizeFileInput` | `@/blocks/utils` | Normalize file params in block config | -| `processFilesToUserFiles` | `@/lib/uploads/utils/file-utils` | Convert raw inputs to UserFile[] | -| `downloadFileFromStorage` | `@/lib/uploads/utils/file-utils.server` | Get file Buffer from UserFile | -| `FileToolProcessor` | `@/executor/utils/file-tool-processor` | Process tool output files | -| `isUserFile` | `@/lib/core/utils/user-file` | Type guard for UserFile objects | -| `FileInputSchema` | `@/lib/uploads/utils/file-schemas` | Zod schema for file validation | - -### Advanced Mode for Optional Fields - -Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. Examples: pagination tokens, time range filters, sort order, max results, reply settings. - -### WandConfig for Complex Inputs - -Use `wandConfig` for fields that are hard to fill out manually: -- **Timestamps**: Use `generationType: 'timestamp'` to inject current date context into the AI prompt -- **JSON arrays**: Use `generationType: 'json-object'` for structured data -- **Complex queries**: Use a descriptive prompt explaining the expected format - -```typescript -{ - id: 'startTime', - title: 'Start Time', - type: 'short-input', - mode: 'advanced', - wandConfig: { - enabled: true, - prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.', - generationType: 'timestamp', - }, -} -``` - -### OAuth Scopes (Centralized System) - -Scopes are maintained in a single source of truth and reused everywhere: - -1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes` -2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI -3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils` -4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils` - -**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source. - -```typescript -// In auth.ts (Better Auth config) -scopes: getCanonicalScopesForProvider('{service}'), - -// In block credential sub-block -requiredScopes: getScopesForService('{service}'), -``` - -### Common Gotchas - -1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration -2. **All tool IDs MUST be snake_case** - `stripe_create_payment`, not `stripeCreatePayment`. This applies to tool `id` fields, registry keys, `tools.access` arrays, and `tools.config.tool` return values -3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'` -4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted -5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true -6. **DependsOn clears options** - When a dependency changes, selector options are refetched -7. **Never pass Buffer directly to fetch** - Convert to `new Uint8Array(buffer)` for TypeScript compatibility -8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility -9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields -10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled -11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts -12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` -13. **OAuth service IDs need deployment capabilities** - Every visible OAuth integration must resolve through `OAUTH_CLIENT_CAPABILITIES`; shared Google/Microsoft aliases map to their provider capability -14. **Keep runtime and presentation separate** - Runtime OAuth fields live in `env-capabilities.ts`; CLI input modes live in the exhaustively checked `scripts/setup/capability-config.ts` mapping diff --git a/.claude/commands/add-managed-cli.md b/.claude/commands/add-managed-cli.md deleted file mode 100644 index 4b5bfcd0840..00000000000 --- a/.claude/commands/add-managed-cli.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -description: Add or upgrade a curated, immutable managed CLI for Sim Function sandboxes, including client-safe catalog metadata, a pinned server-only installation recipe, checksum and executable verification, provider compatibility, PATH propagation, content-addressed image identity, and tests. Use when adding a CLI to the Sandbox managed-CLI selector or changing an existing managed CLI version or recipe. ---- - -# Add a Managed CLI - -Add CLIs through the curated registry. Never turn this surface into arbitrary commands or package names: system packages already cover validated Debian/APT coordinates, while managed CLIs require immutable artifacts and reproducible recipes. - -## Read First - -Read these live sources before editing; do not copy their current entries into this skill: - -1. `apps/sim/lib/execution/remote-sandbox/cli-tools.ts` — persisted IDs and client-safe metadata. -2. `apps/sim/lib/execution/remote-sandbox/cli-tools.server.ts` — server-only recipes and recipe helpers. -3. `apps/sim/lib/execution/remote-sandbox/cli-tools.test.ts` — catalog and supply-chain invariants. -4. `apps/sim/lib/execution/remote-sandbox/cli-tools-boundary.test.ts` — client/server import boundary. -5. `apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts` — content-addressed hash inputs. - -Read `resolve.ts` and `e2b.ts` only when changing provisioning mechanics. A normal catalog addition should not require UI, API, database, resolver, or provider edits; those paths derive from the registries. - -Do not modify the dedicated Function base image or the separate Mothership Shell template for a normal managed CLI addition. Managed recipes layer on the Function base image. Do not change `MAX_SANDBOX_CLI_TOOLS` from ten unless the user separately requests a product-limit change. - -## 1. Verify the Upstream Release - -Use primary upstream release documentation and official artifacts. Establish all of the following before writing code: - -- Exact version and stable Linux x86-64 artifact URL. Never use `latest`, mutable redirects, or an unversioned installer. -- SHA-256 for the exact artifact. Prefer a publisher-signed checksum; otherwise download the official artifact and compute it independently. -- Archive layout and the exact executable paths to install. -- Noninteractive, credential-free verification commands for every advertised executable, normally version commands. -- Required PATH entries under `/opt/sim-cli`. -- E2B and Daytona compatibility. Default to both only when the same Linux recipe works on both. - -When the user does not name a version, select the current stable upstream release from primary sources and state the exact version chosen. Do not silently choose a prerelease or infer a version from an unverified secondary source. - -Reject curl-to-shell installers, `npm install`, `pip install`, distro package repositories, arbitrary user commands, and artifacts from unofficial mirrors. Never place credentials, tokens, login commands, or account configuration in an image recipe or build log; authentication is runtime-only. - -## 2. Choose an Immutable ID - -Use `@-r`. - -- New upstream version: append a new ID ending in `-r1`. -- Recipe-only change for the same upstream version: append `-r2`, `-r3`, and so on. -- Never mutate or delete an existing ID or recipe. Persisted sandboxes must continue resolving to the bytes and behavior they selected. -- On upgrade, retain the old ID and recipe and set its metadata to `selectable: false`. Only the newest version keeps the public label selectable. - -Before shipping the first upgrade for a tool family, verify that editing a sandbox cannot leave both the retired and replacement IDs selected. If the generic selector and API validation do not already replace or reject colliding versions, address that once at the generic registry boundary with focused UI and contract tests; never special-case the individual CLI or silently install two versions that expose the same executable. - -Recipe identity includes the ID, revision, and SHA-256 in the sandbox image hash. Keeping old entries is what makes that identity reproducible rather than merely cache-busting. - -## 3. Add Client-Safe Metadata - -In `cli-tools.ts`: - -1. Append the ID to `SANDBOX_CLI_TOOL_IDS` in the same order used by the metadata and recipe registries. -2. Add a `SANDBOX_CLI_TOOLS` entry whose key and `id` exactly match. -3. Provide a unique selectable `label`, concise `description`, existing `category`, and useful executable/vendor aliases in `searchTerms`. -4. Add a category only when no existing category is accurate, then ensure it has at least one selectable entry. - -Keep this file safe for client bundles. It must not contain artifact URLs, checksums, install commands, verification commands, PATH recipes, provider SDKs, or imports from `cli-tools.server.ts`. - -The API enum and searchable grouped selector derive from this registry. Do not add parallel option arrays or route-local wire types. - -## 4. Add the Server-Only Recipe - -In `cli-tools.server.ts`, use the narrowest existing helper: - -- `defineBinaryRecipe` for one downloaded binary. -- `defineTarGzipRecipe` or `defineZipRecipe` for archives containing binaries. -- `defineVerifiedRecipe` for a vendor archive or installer layout that needs explicit commands. -- A direct typed entry only when the helpers cannot faithfully model the release. - -Provide every field the recipe contract requires: - -- Exact `version`, `artifactUrl`, `artifactName`, and lowercase 64-character `sha256`. -- Every installed `executable` and a corresponding `verificationCommands` entry. -- Deterministic extraction/install commands into `/opt/sim-cli`; quote fixed paths and clean temporary artifacts. -- `pathEntries` when the executable is not installed into the helper's default `bin` directory. -- `supportedProviders` only when it differs from the E2B-and-Daytona default. -- `revision` when it differs from `1`; it must agree with the ID suffix. - -Verification must prove the command is discoverable through `sandboxCliEnvironment`, not authenticate or contact a user account. Recipe commands run as root during both prebuilt image creation and runtime provisioning. - -If the artifact host is new, add only the exact official hostname to the `officialHosts` allowlist in `cli-tools.test.ts`. Treat that as a supply-chain review, not a way to silence the test. - -## 5. Preserve Generic Behavior - -Confirm the existing generic paths remain sufficient: - -- `sandboxCliToolRecipes` canonicalizes and resolves the recipe. -- `sandboxCliEnvironment` propagates PATH to Python subprocesses, JavaScript subprocesses, and Shell. -- E2B bakes the recipe into the custom image; runtime-strategy providers install it within the Function timeout. -- CLI-only sandboxes remain buildable even with no language packages. -- `hashSandboxSpec` includes recipe ID, revision, and checksum while preserving the legacy hash for an empty CLI list. -- The settings selector derives groups and search aliases from client-safe metadata. - -Do not special-case a CLI in those layers unless the registry contract cannot express a genuine provider requirement. Extend the registry contract generically when multiple CLIs need the same new behavior. - -## 6. Test the Addition - -Extend tests when the new entry introduces behavior not already covered: - -- For every upgrade, add a regression proving the old ID and recipe remain resolvable but non-selectable, while the replacement ID is selectable. -- Add important executable aliases to the table-driven search assertion. -- Add a focused assertion for a multi-executable recipe, custom PATH, or restricted provider. -- Add an opt-in credentialed smoke test only when installation plus a real minimal command cannot be validated without authentication. Read credentials from test-only environment variables, skip by default, create them only at runtime, and always tear down the sandbox. - -Never commit downloaded artifacts or credentials. - -## Required Validation - -From `apps/sim`: - -```bash -bunx vitest run \ - lib/execution/remote-sandbox/cli-tools.test.ts \ - lib/execution/remote-sandbox/cli-tools-boundary.test.ts \ - lib/execution/remote-sandbox/sandbox-spec.test.ts \ - lib/execution/remote-sandbox/resolve.test.ts \ - lib/api/contracts/sandboxes.test.ts \ - 'app/workspace/[workspaceId]/settings/components/sandboxes/utils.test.ts' \ - 'app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.test.tsx' -``` - -From the repository root: - -```bash -bun run type-check -bun run check:api-validation -bunx biome check \ - apps/sim/lib/execution/remote-sandbox/cli-tools.ts \ - apps/sim/lib/execution/remote-sandbox/cli-tools.server.ts \ - apps/sim/lib/execution/remote-sandbox/cli-tools.test.ts -git diff --check -``` - -For a new recipe, also exercise its install and every verification command in an actual E2B or Daytona sandbox when credentials and network access are available. Report clearly when only registry/unit validation ran. - -## Completion Checklist - -- [ ] Official immutable Linux x86-64 artifact and SHA-256 verified. -- [ ] Versioned ID appended; old IDs and recipes retained. -- [ ] Client metadata is searchable, categorized, unique, and recipe-free. -- [ ] Server recipe is pinned, integrity-checked, noninteractive, and credential-free. -- [ ] Every advertised executable has an offline verification command and PATH entry. -- [ ] Provider compatibility is explicit and accurate. -- [ ] Catalog, boundary, hash, resolver, type, API-validation, format, and diff checks pass. -- [ ] Real provider installation was tested, or the missing live verification is disclosed. diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md deleted file mode 100644 index 9cef8634689..00000000000 --- a/.claude/commands/add-model.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -description: Add a new LLM model to apps/sim/providers/models.ts with specs verified against the provider's live API docs (no hallucination) -argument-hint: [docs-url] ---- - -# Add Model Skill - -You add a new model entry to `apps/sim/providers/models.ts`. **Every numeric and capability claim MUST be derived from a live web fetch of the provider's official docs in this session.** Marketing emails, training data, and your prior knowledge are not sources of truth — they routinely hallucinate pricing, context windows, and capability lists. - -## Hard rules (do not skip) - -1. **Live-fetch or refuse.** Before writing the entry, you must successfully WebFetch the provider's official models/pricing page in this session. If you cannot reach an authoritative source for any field, **mark the field as UNVERIFIED in your report and ask the user before guessing**. Never fill in pricing or capabilities from memory. -2. **Two-source rule for pricing.** Cross-check input/output/cached pricing against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice, mem0, intuitionlabs). If sources disagree, the provider's own docs win — but flag the disagreement. -3. **Read the code before setting capability flags.** Capability flags are dead unless the provider's implementation under `apps/sim/providers/{provider}/` actually consumes them (see Consumption Matrix below). Setting a flag the provider ignores is a silent bug. -4. **Cite every fact.** Your final report must list the URL each value came from. No URL → not verified. - -## Your Task - -1. Identify provider and model id from user args -2. Live-fetch official docs + pricing page + capability/parameter pages + at least one secondary source -3. Apply the Consumption Matrix to know which capability flags are real -4. Read 2-3 sibling entries in `models.ts` and match their pattern exactly -5. Check the repo-side touchpoints that are NOT data-driven (hosted-key billing, tests, provider code) -6. Insert the entry, run `bun run lint`, print the verification report - -## Step 1: Live source-of-truth lookup - -In priority order — fetch all that exist for the provider: - -| Provider | Models index | Pricing | Reasoning/parameter caveats | -|---|---|---|---| -| OpenAI | platform.openai.com/docs/models | openai.com/api/pricing | platform.openai.com/docs/guides/reasoning | -| Anthropic | docs.anthropic.com/en/docs/about-claude/models | anthropic.com/pricing | docs.anthropic.com/en/docs/build-with-claude/extended-thinking | -| Google (Gemini) | ai.google.dev/gemini-api/docs/models | ai.google.dev/pricing | ai.google.dev/gemini-api/docs/thinking | -| xAI | docs.x.ai/developers/models | docs.x.ai/developers/models (per-model detail page) | docs.x.ai/developers/model-capabilities/text/reasoning | -| Mistral | docs.mistral.ai/getting-started/models/models_overview | mistral.ai/pricing | n/a | -| DeepSeek | api-docs.deepseek.com/quick_start/pricing | same | api-docs.deepseek.com/guides/reasoning_model | -| Groq | console.groq.com/docs/models | groq.com/pricing | n/a | -| Cerebras | inference-docs.cerebras.ai/models | cerebras.ai/pricing | n/a | - -Secondary verification (use at least one): `openrouter.ai//`, `artificialanalysis.ai/models/`, `cloudprice.net/models/-`. - -Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, context window in tokens, input price per 1M, cached input price per 1M, output price per 1M, max output tokens, supported reasoning effort levels, accepted parameters (temperature, top_p), release date. Do not fill in fields you cannot find."* - -## Step 2: Consumption Matrix (which provider honors which capability) - -| Capability | Honored by | Effect if set elsewhere | -|---|---|---| -| `temperature` | All providers (passed through if set) | Safe but inert on always-reasoning models that reject it | -| `toolUsageControl` | All providers (provider-level, not per-model) | n/a — set on `ProviderDefinition`, not models | -| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | -| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | -| `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | -| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | -| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | -| `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | -| `computerUse` | `anthropic/core.ts` | Dead elsewhere | -| `deepResearch` | UI flag for routing to deep-research SKUs | Set only on actual deep-research model IDs | -| `memory: false` | Conversation persistence opt-out | Set only when model genuinely cannot maintain history (e.g., deep-research) | - -**Always re-grep before relying on this table** — the codebase moves: - -```bash -rg "reasoningEffort|reasoning_effort" apps/sim/providers// -rg "verbosity" apps/sim/providers// -rg "request\.thinking|thinking:" apps/sim/providers// -rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers// -``` - -## Step 3: Match the provider's existing entry pattern - -Open `apps/sim/providers/models.ts`, find `PROVIDER_DEFINITIONS[].models`, read 2-3 sibling entries. Match field order exactly: - -```ts -{ - id: '', - pricing: { - input: , - cachedInput: , // omit if provider doesn't offer caching - output: , - updatedAt: '', - }, - capabilities: { - // only flags the provider actually consumes — see matrix - }, - contextWindow: , - releaseDate: '', - recommended: true, // only if new flagship; ask user before swapping - speedOptimized: true, // only on smallest/fastest tier - deprecated: true, // only on retired models -} -``` - -### Reseller providers (azure-openai, azure-anthropic, vertex, bedrock, openrouter) - -Model id MUST be prefixed: `azure/`, `azure-anthropic/`, `vertex/`, `bedrock/`, `openrouter/`. Pricing usually mirrors the upstream provider but verify on the reseller's own pricing page. - -### Insertion order - -Within a family, newest first (matches existing convention: GPT-5.5 above GPT-5.4 above GPT-5.2). Across families, biggest/flagship at top of list. - -### `recommended` / `speedOptimized` - -- At most one or two `recommended: true` per provider — the current flagship(s). -- If you're adding a new flagship, ask the user before removing `recommended` from the previous flagship. Never silently flip it. -- `speedOptimized: true` only on the smallest/fastest tier (nano, flash-lite, haiku class). - -## Step 4: Repo-side touchpoints beyond the entry - -Adding the `models.ts` entry is most of the job because nearly every consumer is **data-driven** and picks the model up automatically: the ~40 query helpers in `models.ts` / `providers/utils.ts`, the public `/models` catalog (`app/(landing)/models/utils.ts` iterates `PROVIDER_DEFINITIONS`), the agent-block model dropdown, and copilot's `isKnownModelId` / `suggestModelIdsForUnknownModel` validation. The touchpoints below are the exceptions — they are **not** data-driven, so check each one. - -### Hosted = auto-billed, by provider - -`getHostedModels()` in `apps/sim/providers/models.ts` returns **every** model under `openai`, `anthropic`, and `google`: - -```ts -export function getHostedModels(): string[] { - return [ - ...getProviderModels('openai'), - ...getProviderModels('anthropic'), - ...getProviderModels('google'), - ] -} -``` - -So a model added to any of those three providers is **automatically served with Sim's rotating hosted key and billed** to the workspace via `shouldBillModelUsage()` (`providers/utils.ts`). Before you insert: - -- **If the model should be BYOK-only / never-billed**, do NOT drop it under `openai`/`anthropic`/`google` as-is — that silently enrolls it in hosted billing. Confirm hosting/billing intent with the user. (Precedent: Ollama Cloud is a deliberately separate `isReseller` provider specifically to stay BYOK-only/never-billed.) -- **If the model should be hosted**, the deployment must actually have a key for it — the provider's `{PREFIX}_COUNT` / `{PREFIX}_1..N` env vars must be set, or hosted runs fail at execution time. -- State the hosted/billing status explicitly in the verification report. - -### Tests with hardcoded model IDs - -`bun run lint` does **not** run tests. A few tests assert specific model IDs and can break or need updating when you touch a hosted or flagship model: - -- `apps/sim/providers/utils.test.ts` — asserts membership of `getHostedModels()` / `shouldBillModelUsage()` -- `apps/sim/providers/index.test.ts` and serializer tests — reference concrete model IDs - -```bash -rg "|getHostedModels|shouldBillModelUsage" apps/sim/providers/*.test.ts -``` - -If anything matches, run the affected provider tests and update assertions as needed. - -### New API behavior is NOT data-driven - -The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). - -### Thinking/reasoning models: `streamed` visibility + generated docs - -If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: - -- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. -- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. -- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. -- Include the `streamed` value (with its source URL) in the verification report when set. - -### Wrong family entirely? - -- **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. -- **Brand-new provider** (not just a new model under an existing one) → much larger surface: add the id to `ProviderId` in `providers/types.ts`, a registry entry in `providers/registry.ts`, a provider implementation under `providers//` (assemble streaming responses with `createStreamingExecution` and wrap tool schemas with the `@/providers/tool-schema-adapter` helpers), an icon in `components/icons.tsx`, and the `PROVIDER_DEFINITIONS` block. That is beyond this skill — tell the user. - -## Step 5: Write, lint - -```bash -bun run lint -bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort -``` - -Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. - -## Step 6: Verification report (mandatory format) - -End with this exact structure: - -```markdown -### Verification — - -| Field | Value | Source URL | Status | -|---|---|---|---| -| `id` | `grok-4.3` | https://docs.x.ai/... | ✓ verified | -| `contextWindow` | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified (2 sources agree) | -| `input` | $1.25/M | https://docs.x.ai/... | ✓ verified | -| `cachedInput` | $0.20/M | https://cloudprice.net/... | ⚠️ single source | -| `output` | $2.50/M | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified | -| `capabilities.temperature` | `{ min: 0, max: 1 }` | matches sibling entries | — pattern-match only | -| `capabilities.reasoningEffort` | NOT SET | provider docs say API rejects it for this model | ✓ correctly omitted | -| `releaseDate` | 2026-04-30 | https://docs.x.ai/... announcement | ✓ verified | -| hosted/billing | BYOK-only (xai not in `getHostedModels`) | `providers/models.ts` | — confirmed intent | - -**Disagreements** -- _none_ OR _OpenRouter says X, provider docs say Y — used Y per provider rule_ - -**Unverified fields** -- _none_ OR _: could not find authoritative source — left as based on sibling pattern; please confirm_ -``` - -If any row is ⚠️ single-source or "unverified," **state it plainly to the user and ask whether to proceed**. Do not silently merge. - -## What to do if you cannot find a source - -Omitting a field is **not the same as verifying it**. Any field you cannot confirm from a live fetch must be **both** omitted from the entry **and** listed as ❓ UNVERIFIED in the report's "Unverified fields" section, with the URLs you attempted. Then ask the user to confirm before merging. - -- Pricing missing → do NOT guess. Omit `cachedInput`. Mark ❓ UNVERIFIED. Ask the user for the price or the docs URL. -- Context window missing → do NOT guess. Ask the user; mark ❓ UNVERIFIED. -- Release date missing → omit the field; mark ❓ UNVERIFIED in the report. -- Capability uncertain → omit the flag (safer than setting a dead/wrong one); mark ❓ UNVERIFIED so the user knows you didn't confirm it either way. - -## Anti-patterns this skill exists to prevent - -- ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) -- ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) -- ❌ Setting `thinking` on non-Anthropic/non-Gemini providers -- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model -- ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x -- ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date -- ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) -- ❌ Stamping `recommended: true` on the new model without removing it from the previous flagship -- ❌ Adding a BYOK-only model under `openai`/`anthropic`/`google` (silently enrolls it in hosted billing via `getHostedModels()`) -- ❌ Reporting "done" after only `bun run lint` when you touched a hosted (openai/anthropic/google) or flagship model with assertions in `providers/utils.test.ts` -- ❌ Reporting "done" with any UNVERIFIED row in the table diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md deleted file mode 100644 index 6b390520b64..00000000000 --- a/.claude/commands/add-tools.md +++ /dev/null @@ -1,501 +0,0 @@ ---- -description: Create tool configurations for a Sim integration by reading API docs -argument-hint: [api-docs-url] ---- - -# Add Tools Skill - -You are an expert at creating tool configurations for Sim integrations. Your job is to read API documentation and create properly structured tool files. - -## Your Task - -When the user asks you to create tools for a service: -1. Use Context7 or WebFetch to read the service's API documentation -2. Create the tools directory structure -3. Generate properly typed tool configurations - -## Hard Rule: No Guessed Response Schemas - -If the docs do not clearly show the response JSON for a tool, you MUST tell the user exactly which outputs are unknown and stop short of guessing. - -- Do NOT invent response field names -- Do NOT infer nested paths from nearby endpoints -- Do NOT guess array item shapes -- Do NOT write `transformResponse` against unverified payloads - -If the response shape is unknown, do one of these instead: -1. Ask the user for sample responses -2. Ask the user for test credentials so you can verify live responses -3. Implement only the endpoints whose outputs are documented -4. Leave the tool unimplemented and explicitly say why - -## Directory Structure - -Create files in `apps/sim/tools/{service}/`: -``` -tools/{service}/ -├── index.ts # Barrel export -├── types.ts # Parameter & response types -└── {action}.ts # Individual tool files (one per operation) -``` - -## Tool Configuration Structure - -Every tool MUST follow this exact structure: - -```typescript -import type { {ServiceName}{Action}Params } from '@/tools/{service}/types' -import type { ToolConfig } from '@/tools/types' - -interface {ServiceName}{Action}Response { - success: boolean - output: { - // Define output structure here - } -} - -export const {serviceName}{Action}Tool: ToolConfig< - {ServiceName}{Action}Params, - {ServiceName}{Action}Response -> = { - id: '{service}_{action}', // snake_case, matches tool name - name: '{Service} {Action}', // Human readable - description: 'Brief description', // One sentence - version: '1.0.0', - - // OAuth config (if service uses OAuth) - oauth: { - required: true, - provider: '{service}', // Must match OAuth provider ID - }, - - params: { - // Hidden params (system-injected, only use hidden for oauth accessToken) - accessToken: { - type: 'string', - required: true, - visibility: 'hidden', - description: 'OAuth access token', - }, - // User-only params (credentials, api key, IDs user must provide) - someId: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'The ID of the resource', - }, - // User-or-LLM params (everything else, can be provided by user OR computed by LLM) - query: { - type: 'string', - required: false, // Use false for optional - visibility: 'user-or-llm', - description: 'Search query', - }, - }, - - request: { - url: (params) => `https://api.service.com/v1/resource/${params.id}`, - method: 'POST', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - }), - body: (params) => ({ - // Request body - only for POST/PUT/PATCH - // Trim ID fields to prevent copy-paste whitespace errors: - // userId: params.userId?.trim(), - }), - }, - - transformResponse: async (response: Response) => { - const data = await response.json() - return { - success: true, - output: { - // Map API response to output - // Use ?? null for nullable fields - // Use ?? [] for optional arrays - }, - } - }, - - outputs: { - // Define each output field - }, -} -``` - -## Critical Rules for Parameters - -### Visibility Options -- `'hidden'` - System-injected (OAuth tokens, internal params). User never sees. -- `'user-only'` - User must provide (credentials, api keys, account-specific IDs) -- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters, most fall into this category) - -### Parameter Types -- `'string'` - Text values -- `'number'` - Numeric values -- `'boolean'` - True/false -- `'json'` - Complex objects (NOT 'object', use 'json') -- `'file'` - Single file -- `'file[]'` - Multiple files - -### Required vs Optional -- Always explicitly set `required: true` or `required: false` -- Optional params should have `required: false` - -## Resolved Secrets and Provenance Boundaries - -- Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only - when an exact field is proven to cross a Sim model, durable-storage, or internal-execution boundary. -- Project AI-consumed text/structured fields with the smallest exact `request.modelInput` selector. -- Treat URLs, domains, resource IDs, and control fields as ordinary request values unless the exact - field is proven model-visible. For serialized external model content, project the serialized - top-level param through `request.modelInput` before the existing formatter parses it; do not add a - separate hard-rejection mechanism. -- For authenticated internal routes, use `privateProvenance` for actual inline/raw model bytes or - `request.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key, - path, signed URL, or remote URL as provenance for fetched bytes; authorize tracked stored bytes at - the owning model-egress boundary. Authenticate first, validate the exact selection and scope, - strip the private envelope, then import or propagate provenance at the receiving boundary. - Preserve documented headerless legacy behavior. -- Never substitute secret plaintext into source, serialize plaintext provenance, hand-roll private - headers, or blanket-sanitize tool results. -- Add focused tests for named projection, identical unproven public text, malformed/incomplete - metadata, metadata stripping, scope isolation, and legacy compatibility where applicable. - -## Critical Rules for Outputs - -### Output Types -- `'string'`, `'number'`, `'boolean'` - Primitives -- `'json'` - Complex objects (use this, NOT 'object') -- `'array'` - Arrays with `items` property -- `'object'` - Objects with `properties` property - -### Optional Outputs -Add `optional: true` for fields that may not exist in the response: -```typescript -closedAt: { - type: 'string', - description: 'When the issue was closed', - optional: true, -}, -``` - -### Typed JSON Outputs - -When using `type: 'json'` and you know the object shape in advance, **always define the inner structure** using `properties` so downstream consumers know what fields are available: - -```typescript -// BAD: Opaque json with no info about what's inside -metadata: { - type: 'json', - description: 'Response metadata', -}, - -// GOOD: Define the known properties -metadata: { - type: 'json', - description: 'Response metadata', - properties: { - id: { type: 'string', description: 'Unique ID' }, - status: { type: 'string', description: 'Current status' }, - count: { type: 'number', description: 'Total count' }, - }, -}, -``` - -For arrays of objects, define the item structure: -```typescript -items: { - type: 'array', - description: 'List of items', - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'Item ID' }, - name: { type: 'string', description: 'Item name' }, - }, - }, -}, -``` - -Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown. - -If the response shape is unknown because the docs do not provide it, you MUST tell the user and stop. Unknown is not the same as dynamic. Never guess outputs. - -## Critical Rules for transformResponse - -### Handle Nullable Fields -ALWAYS use `?? null` for fields that may be undefined: -```typescript -transformResponse: async (response: Response) => { - const data = await response.json() - return { - success: true, - output: { - id: data.id, - title: data.title, - body: data.body ?? null, // May be undefined - assignee: data.assignee ?? null, // May be undefined - labels: data.labels ?? [], // Default to empty array - closedAt: data.closed_at ?? null, // May be undefined - }, - } -} -``` - -### Never Output Raw JSON Dumps -DON'T do this: -```typescript -output: { - data: data, // BAD - raw JSON dump -} -``` - -DO this instead - extract meaningful fields: -```typescript -output: { - id: data.id, - name: data.name, - status: data.status, - metadata: { - createdAt: data.created_at, - updatedAt: data.updated_at, - }, -} -``` - -## Types File Pattern - -Create `types.ts` with interfaces for all params and responses: - -```typescript -import type { ToolResponse } from '@/tools/types' - -// Parameter interfaces -export interface {Service}{Action}Params { - accessToken: string - requiredField: string - optionalField?: string -} - -// Response interfaces (extend ToolResponse) -export interface {Service}{Action}Response extends ToolResponse { - output: { - field1: string - field2: number - optionalField?: string | null - } -} -``` - -## Index.ts Barrel Export Pattern - -```typescript -// Export all tools -export { serviceTool1 } from './{action1}' -export { serviceTool2 } from './{action2}' - -// Export types -export * from './types' -``` - -## Registering Tools - -After creating tools: -1. Import tools in `apps/sim/tools/registry.ts` -2. Add to the `tools` object with snake_case keys (alphabetically): -```typescript -import { serviceActionTool } from '@/tools/{service}' - -export const tools = { - // ... existing tools ... - {service}_{action}: serviceActionTool, -} -``` - -3. Regenerate the tool metadata artifacts: - -```bash -bun run tool-metadata:generate -``` - -Client code reads a tool's `params`/`outputs` from generated metadata rather than -importing the registry, so a tool you add, change or remove is invisible to the UI until -these are regenerated — and CI fails on stale artifacts. Commit the result. See -`.agents/skills/tool-registry-boundary/SKILL.md`. - -## Wiring Tools into the Block (Required) - -After registering in `tools/registry.ts`, you MUST also update the block definition at `apps/sim/blocks/blocks/{service}.ts`. This is not optional — tools are only usable from the UI if they are wired into the block. - -### 1. Add to `tools.access` - -```typescript -tools: { - access: [ - // existing tools... - 'service_new_action', // Add every new tool ID here - ], - config: { ... } -} -``` - -### 2. Add operation dropdown options - -If the block uses an operation dropdown, add an option for each new tool: - -```typescript -{ - id: 'operation', - type: 'dropdown', - options: [ - // existing options... - { label: 'New Action', id: 'new_action' }, // id maps to what tools.config.tool returns - ], -} -``` - -### 3. Add subBlocks for new tool params - -For each new tool, add subBlocks covering all its required params (and optional ones where useful). Apply `condition` to show them only for the right operation, and mark required params with `required`: - -```typescript -// Required param for new_action -{ - id: 'someParam', - title: 'Some Param', - type: 'short-input', - placeholder: 'e.g., value', - condition: { field: 'operation', value: 'new_action' }, - required: { field: 'operation', value: 'new_action' }, -}, -// Optional param — put in advanced mode -{ - id: 'optionalParam', - title: 'Optional Param', - type: 'short-input', - condition: { field: 'operation', value: 'new_action' }, - mode: 'advanced', -}, -``` - -### 4. Update `tools.config.tool` - -Ensure the tool selector returns the correct tool ID for every new operation. The simplest pattern: - -```typescript -tool: (params) => `service_${params.operation}`, -// If operation dropdown IDs already match tool IDs, this requires no change. -``` - -If the dropdown IDs differ from tool IDs, add explicit mappings: - -```typescript -tool: (params) => { - const map: Record = { - new_action: 'service_new_action', - // ... - } - return map[params.operation] ?? `service_${params.operation}` -}, -``` - -### 5. Update `tools.config.params` - -Add any type coercions needed for new params (runs at execution time, after variable resolution): - -```typescript -params: (params) => { - const result: Record = {} - if (params.limit != null && params.limit !== '') result.limit = Number(params.limit) - if (params.newParamName) result.toolParamName = params.newParamName // rename if IDs differ - return result -}, -``` - -### 6. Add new outputs - -Add any new fields returned by the new tools to the block `outputs`: - -```typescript -outputs: { - // existing outputs... - newField: { type: 'string', description: 'Description of new field' }, -} -``` - -### 7. Add new inputs - -Add new subBlock param IDs to the block `inputs` section: - -```typescript -inputs: { - // existing inputs... - someParam: { type: 'string', description: 'Param description' }, - optionalParam: { type: 'string', description: 'Optional param description' }, -} -``` - -### Block wiring checklist - -- [ ] New tool IDs added to `tools.access` -- [ ] Operation dropdown has an option for each new tool -- [ ] SubBlocks cover all required params for each new tool -- [ ] SubBlocks have correct `condition` (only show for the right operation) -- [ ] Optional/rarely-used params set to `mode: 'advanced'` -- [ ] `tools.config.tool` returns correct ID for every new operation -- [ ] `tools.config.params` handles any ID remapping or type coercions -- [ ] New outputs added to block `outputs` -- [ ] New params added to block `inputs` - -## V2 Tool Pattern - -If creating V2 tools (API-aligned outputs), use `_v2` suffix: -- Tool ID: `{service}_{action}_v2` -- Variable name: `{action}V2Tool` -- Version: `'2.0.0'` -- Outputs: Flat, API-aligned (no content/metadata wrapper) - -## Naming Convention - -All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase for tool IDs. - -## Checklist Before Finishing - -- [ ] All tool IDs use snake_case -- [ ] All params have explicit `required: true` or `required: false` -- [ ] All params have appropriate `visibility` -- [ ] All nullable response fields use `?? null` -- [ ] All optional outputs have `optional: true` -- [ ] No raw JSON dumps in outputs -- [ ] Types file has all interfaces -- [ ] Index.ts exports all tools and re-exports types (`export * from './types'`) -- [ ] Tools registered in `tools/registry.ts` -- [ ] `bun run tool-metadata:generate` run and the regenerated artifacts committed -- [ ] Block wired: `tools.access`, dropdown options, subBlocks, `tools.config`, outputs, inputs -- [ ] Model, durable-storage, and internal-execution boundaries use the shared provenance mechanisms - only where a concrete Sim `{{...}}` resolution path requires them -- [ ] Ordinary third-party inputs/results remain unchanged and private metadata never leaves Sim - -## Final Validation (Required) - -After creating all tools, you MUST validate every tool before finishing: - -1. **Read every tool file** you created — do not skip any -2. **Cross-reference with the API docs** to verify: - - All required params are marked `required: true` - - All optional params are marked `required: false` - - Param types match the API (string, number, boolean, json) - - Request URL, method, headers, and body match the API spec - - `transformResponse` extracts the correct fields from the API response - - All output fields match what the API actually returns - - No fields are missing from outputs that the API provides - - No extra fields are defined in outputs that the API doesn't return - - Every output field and JSON path is backed by docs or live-verified sample responses -3. **Verify consistency** across tools: - - Shared types in `types.ts` match all tools that use them - - Tool IDs in the barrel export match the tool file definitions - - Error handling is consistent (error checks, meaningful messages) -4. **If any response schema is still unknown**, explicitly tell the user instead of guessing diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md deleted file mode 100644 index 45065c69f1d..00000000000 --- a/.claude/commands/add-trigger.md +++ /dev/null @@ -1,512 +0,0 @@ ---- -description: Create webhook or polling triggers for a Sim integration -argument-hint: ---- - -# Add Trigger - -You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, polling infrastructure, and how triggers connect to blocks. - -## Your Task - -1. Research what webhook events the service supports — if the service lacks reliable webhooks, use polling -2. Create the trigger files using the generic builder (webhook) or manual config (polling) -3. Create a provider handler (webhook) or polling handler (polling) -4. Register triggers and connect them to the block - -## Hard Rule: No Guessed Webhook Payload Schemas - -If the service docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing trigger outputs or `formatInput` mappings. - -- Do NOT invent payload field names -- Do NOT guess nested event object paths -- Do NOT infer output fields from the UI or marketing docs -- Do NOT write `formatInput` against unverified webhook bodies - -If the payload shape is unknown, do one of these instead: -1. Ask the user for sample webhook payloads -2. Ask the user for a test webhook source so you can inspect a real event -3. Implement only the event registration/setup portions whose payloads are documented -4. Leave the trigger unimplemented and explicitly say which payload fields are unknown - -## Directory Structure - -``` -apps/sim/triggers/{service}/ -├── index.ts # Barrel exports -├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs) -├── {event_a}.ts # Primary trigger (includes dropdown) -├── {event_b}.ts # Secondary trigger (no dropdown) -└── webhook.ts # Generic webhook trigger (optional, for "all events") - -apps/sim/lib/webhooks/ -├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) -├── providers/ -│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) -│ ├── types.ts # WebhookProviderHandler interface -│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) -│ └── registry.ts # Handler map + default handler -``` - -## Step 1: Create `utils.ts` - -This file contains all service-specific helpers used by triggers. - -```typescript -import type { SubBlockConfig } from '@/blocks/types' -import type { TriggerOutput } from '@/triggers/types' - -export const {service}TriggerOptions = [ - { label: 'Event A', id: '{service}_event_a' }, - { label: 'Event B', id: '{service}_event_b' }, -] - -export function {service}SetupInstructions(eventType: string): string { - const instructions = [ - 'Copy the Webhook URL above', - 'Go to {Service} Settings > Webhooks', - `Select the ${eventType} event type`, - 'Paste the webhook URL and save', - 'Click "Save" above to activate your trigger', - ] - return instructions - .map((instruction, index) => - `
${index + 1}. ${instruction}
` - ) - .join('') -} - -export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'projectId', - title: 'Project ID (Optional)', - type: 'short-input', - placeholder: 'Leave empty for all projects', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] -} - -export function build{Service}Outputs(): Record { - return { - eventType: { type: 'string', description: 'The type of event' }, - resourceId: { type: 'string', description: 'ID of the affected resource' }, - resource: { - id: { type: 'string', description: 'Resource ID' }, - name: { type: 'string', description: 'Resource name' }, - }, - } -} -``` - -## Step 2: Create Trigger Files - -**Primary trigger** — MUST include `includeDropdown: true`: - -```typescript -import { {Service}Icon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils' -import type { TriggerConfig } from '@/triggers/types' - -export const {service}EventATrigger: TriggerConfig = { - id: '{service}_event_a', - name: '{Service} Event A', - provider: '{service}', - description: 'Trigger workflow when Event A occurs', - version: '1.0.0', - icon: {Service}Icon, - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_a', - triggerOptions: {service}TriggerOptions, - includeDropdown: true, - setupInstructions: {service}SetupInstructions('Event A'), - extraFields: build{Service}ExtraFields('{service}_event_a'), - }), - outputs: build{Service}Outputs(), - webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, -} -``` - -**Secondary triggers** — NO `includeDropdown` (it's already in the primary): - -```typescript -export const {service}EventBTrigger: TriggerConfig = { - // Same as above but: id: '{service}_event_b', no includeDropdown -} -``` - -## Step 3: Register and Wire - -### `apps/sim/triggers/{service}/index.ts` - -```typescript -export { {service}EventATrigger } from './event_a' -export { {service}EventBTrigger } from './event_b' -``` - -### `apps/sim/triggers/registry.ts` - -```typescript -import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}' - -export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing ... - {service}_event_a: {service}EventATrigger, - {service}_event_b: {service}EventBTrigger, -} -``` - -### Block file (`apps/sim/blocks/blocks/{service}.ts`) - -Wire triggers into the block so the trigger UI appears and `generate-docs.ts` discovers them. Two changes are needed: - -1. **Spread trigger subBlocks** at the end of the block's `subBlocks` array -2. **Add `triggers` property** after `outputs` with `enabled: true` and `available: [...]` - -```typescript -import { getTrigger } from '@/triggers' - -export const {Service}Block: BlockConfig = { - // ... - subBlocks: [ - // Regular tool subBlocks first... - ...getTrigger('{service}_event_a').subBlocks, - ...getTrigger('{service}_event_b').subBlocks, - ], - // ... tools, inputs, outputs ... - triggers: { - enabled: true, - available: ['{service}_event_a', '{service}_event_b'], - }, -} -``` - -**Versioned blocks (V1 + V2):** Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1: - -- **V2 uses `...V1Block` spread** (e.g., Google Calendar): Add trigger to V1 — V2 inherits both `subBlocks` and `triggers` automatically. -- **V2 defines its own `subBlocks`** (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it. -- **Single block, no V2** (e.g., Google Drive): Add trigger directly. - -`generate-docs.ts` deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in `integrations.json`. Always verify by checking the output after running the script. - -## Provider Handler - -All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. - -### When to Create a Handler - -| Behavior | Method | Examples | -|---|---|---| -| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | -| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | -| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot | -| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | -| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby | -| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable | -| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable | -| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams | -| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams | - -If none apply, you don't need a handler. The default handler provides bearer token auth. - -### Example Handler - -```typescript -import crypto from 'crypto' -import { createLogger } from '@sim/logger' -import { safeCompare } from '@/lib/core/security/encryption' -import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' - -const logger = createLogger('WebhookProvider:{Service}') - -function validate{Service}Signature(secret: string, signature: string, body: string): boolean { - if (!secret || !signature || !body) return false - const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - return safeCompare(computed, signature) -} - -export const {service}Handler: WebhookProviderHandler = { - verifyAuth: createHmacVerifier({ - configKey: 'webhookSecret', - headerName: 'X-{Service}-Signature', - validateFn: validate{Service}Signature, - providerLabel: '{Service}', - }), - - async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { - const triggerId = providerConfig.triggerId as string | undefined - if (triggerId && triggerId !== '{service}_webhook') { - const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') - if (!is{Service}EventMatch(triggerId, body as Record)) return false - } - return true - }, - - async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record - return { - input: { - eventType: b.type, - resourceId: (b.data as Record)?.id || '', - resource: b.data, - }, - } - }, - - extractIdempotencyId(body: unknown) { - const obj = body as Record - return obj.id && obj.type ? `${obj.type}:${obj.id}` : null - }, -} -``` - -### Register the Handler - -In `apps/sim/lib/webhooks/providers/registry.ts`: - -```typescript -import { {service}Handler } from '@/lib/webhooks/providers/{service}' - -const PROVIDER_HANDLERS: Record = { - // ... existing (alphabetical) ... - {service}: {service}Handler, -} -``` - -## Output Alignment (Critical) - -There are two sources of truth that **MUST be aligned**: - -1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown) -2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data - -If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover. - -**Rules for `formatInput`:** -- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly -- Return `{ input: ..., skip: { message: '...' } }` to skip execution -- No wrapper objects or duplication -- Use `null` for missing optional data - -## Automatic Webhook Registration - -If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. - -```typescript -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' -import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' - -export const {service}Handler: WebhookProviderHandler = { - async createSubscription(ctx: SubscriptionContext): Promise { - const config = getProviderConfig(ctx.webhook) - const apiKey = config.apiKey as string - if (!apiKey) throw new Error('{Service} API Key is required.') - - const res = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }), - }) - - if (!res.ok) throw new Error(`{Service} error: ${res.status}`) - const { id } = (await res.json()) as { id: string } - return { providerConfigUpdates: { externalId: id } } - }, - - async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { - const config = getProviderConfig(ctx.webhook) - const { apiKey, externalId } = config as { apiKey?: string; externalId?: string } - if (!apiKey || !externalId) return - await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${apiKey}` }, - }).catch(() => {}) - }, -} -``` - -**Key points:** -- Throw from `createSubscription` — orchestration rolls back the DB webhook -- Never throw from `deleteSubscription` — log non-fatally -- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig` -- Add `apiKey` field to `build{Service}ExtraFields` with `password: true` - -## Trigger Outputs Schema - -Trigger outputs use the same schema as block outputs (NOT tool outputs). - -**Supported:** `type` + `description` for leaf fields, nested objects for complex data. -**NOT supported:** `optional: true`, `items` (those are tool-output-only features). - -```typescript -export function buildOutputs(): Record { - return { - eventType: { type: 'string', description: 'Event type' }, - timestamp: { type: 'string', description: 'When it occurred' }, - payload: { type: 'json', description: 'Full event payload' }, - resource: { - id: { type: 'string', description: 'Resource ID' }, - name: { type: 'string', description: 'Resource name' }, - }, - } -} -``` - -## Polling Triggers - -Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use `buildTriggerSubBlocks` — they define subBlocks manually. - -### Directory Structure - -``` -apps/sim/triggers/{service}/ -├── index.ts # Barrel export -└── poller.ts # TriggerConfig with polling: true - -apps/sim/lib/webhooks/polling/ -└── {service}.ts # PollingProviderHandler implementation -``` - -### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`) - -```typescript -import { pollingIdempotency } from '@/lib/core/idempotency/service' -import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' -import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' -import { processPolledWebhookEvent } from '@/lib/webhooks/processor' - -export const {service}PollingHandler: PollingProviderHandler = { - provider: '{service}', - label: '{Service}', - - async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { - const { webhookData, workflowData, requestId, logger } = ctx - const webhookId = webhookData.id - - try { - // For OAuth services: - const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId) - const config = webhookData.providerConfig as unknown as {Service}WebhookConfig - - // First poll: seed state, emit nothing - if (!config.lastCheckedTimestamp) { - await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger) - await markWebhookSuccess(webhookId, logger) - return 'success' - } - - // Fetch changes since last poll, process with idempotency - // ... - - await markWebhookSuccess(webhookId, logger) - return 'success' - } catch (error) { - logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error) - await markWebhookFailed(webhookId, logger) - return 'failure' - } - }, -} -``` - -**Key patterns:** -- First poll seeds state and emits nothing (avoids flooding with existing data) -- Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup -- Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow -- Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state -- Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew - -### Trigger Config (`apps/sim/triggers/{service}/poller.ts`) - -```typescript -import { {Service}Icon } from '@/components/icons' -import type { TriggerConfig } from '@/triggers/types' - -export const {service}PollingTrigger: TriggerConfig = { - id: '{service}_poller', - name: '{Service} Trigger', - provider: '{service}', - description: 'Triggers when ...', - version: '1.0.0', - icon: {Service}Icon, - polling: true, // REQUIRED — routes to polling infrastructure - - subBlocks: [ - { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' }, - // ... service-specific config fields (dropdowns, inputs, switches) ... - { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, - ], - - outputs: { - // Must match the payload shape from processPolledWebhookEvent - }, -} -``` - -### Registration (3 places) - -1. **`apps/sim/triggers/constants.ts`** — add provider to `POLLING_PROVIDERS` Set -2. **`apps/sim/lib/webhooks/polling/registry.ts`** — import handler, add to `POLLING_HANDLERS` -3. **`apps/sim/triggers/registry.ts`** — import trigger config, add to `TRIGGER_REGISTRY` - -### Helm Cron Job - -Add to `helm/sim/values.yaml` under the existing polling cron jobs: - -```yaml -{service}WebhookPoll: - schedule: "*/1 * * * *" - concurrencyPolicy: Forbid - url: "http://sim:3000/api/webhooks/poll/{service}" -``` - -### Reference Implementations - -- Simple: `apps/sim/lib/webhooks/polling/rss.ts` + `apps/sim/triggers/rss/poller.ts` -- Complex (OAuth, attachments): `apps/sim/lib/webhooks/polling/gmail.ts` + `apps/sim/triggers/gmail/poller.ts` -- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts` -- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts` - -## Checklist - -### Trigger Definition -- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders -- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT -- [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] Created `index.ts` barrel export - -### Registration -- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY` -- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available` -- [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` - -### Provider Handler (if needed) -- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts` -- [ ] Registered in `providers/registry.ts` (alphabetical) -- [ ] Signature validator is a private function inside the handler file -- [ ] `formatInput` output keys match trigger `outputs` exactly -- [ ] Event matching uses dynamic `await import()` for trigger utils - -### Auto Registration (if supported) -- [ ] `createSubscription` and `deleteSubscription` on the handler -- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` -- [ ] API key field uses `password: true` - -### Polling Trigger (if applicable) -- [ ] Handler implements `PollingProviderHandler` at `lib/webhooks/polling/{service}.ts` -- [ ] Trigger config has `polling: true` and defines subBlocks manually (no `buildTriggerSubBlocks`) -- [ ] Provider string matches across: trigger config, handler, `POLLING_PROVIDERS`, polling registry -- [ ] First poll seeds state and emits nothing -- [ ] Added provider to `POLLING_PROVIDERS` in `triggers/constants.ts` -- [ ] Added handler to `POLLING_HANDLERS` in `lib/webhooks/polling/registry.ts` -- [ ] Added cron job to `helm/sim/values.yaml` -- [ ] Payload shape matches trigger `outputs` schema - -### Testing -- [ ] `bun run type-check` passes -- [ ] Manually verify output keys match trigger `outputs` keys -- [ ] Trigger UI shows correctly in the block diff --git a/.claude/commands/babysit.md b/.claude/commands/babysit.md deleted file mode 100644 index 0c75a25a962..00000000000 --- a/.claude/commands/babysit.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -description: Drive a PR to a clean review (Greptile 5/5, zero open threads) — ships if needed, keeps it mergeable against staging, triggers Greptile/Cursor Bugbot, fixes real findings, replies to and resolves every thread, and loops until clean ---- - -# Babysit PRs - -Owns a PR end-to-end through review: ship it, wait for the automatic review round, and if it -isn't already clean, drive fix → reply → resolve → re-review cycles until Greptile reports 5/5 -and there are zero open comment threads, keeping the branch mergeable against staging along the -way. Designed to be run under `/loop` (no fixed interval — let it self-pace on review latency) -so it survives across multiple wakeups in the same session. - -## When to use - -- The user says "babysit this PR", "keep working the reviews until it's clean", or similar -- As the natural follow-up to `/ship` when the user wants the review loop automated rather than - manually re-triggering reviews and answering comments themselves - -## Inputs - -Needs a PR number. If none is given and there's no open PR for the current branch, run `/ship` -first (which includes the `origin/staging` sync check — see `.agents/skills/ship/SKILL.md`) to -create one. - -## Definition of "clean" - -Both must hold: -1. The latest Greptile summary comment reports **Confidence Score: 5/5** -2. `reviewThreads` (GraphQL, see below) has **zero threads with `isResolved: false`** - -Do not stop early on "no new comments this round" alone — a thread can be open from an earlier -round. Always check both conditions freshly after every push. - -## Loop - -1. **Check current state** before doing anything, including whether the PR is still mergeable: - ```bash - gh pr view --json mergeable - gh pr view --json comments -q '[.comments[] | select(.author.login=="greptile-apps")] | last | .body' - gh api graphql -f query=' - query { repository(owner: "", name: "") { pullRequest(number: ) { - reviewThreads(first: 50) { pageInfo { hasNextPage endCursor } nodes { id isResolved path line - comments(first: 5) { nodes { id databaseId author { login } body } } } } } } }' - ``` - `[.comments[]] | last | .body`, not `... | .body | tail -1` — the latter pipes every matching - comment's full multi-line body through the pipeline and keeps only the final *line* of that - combined output (usually the "Reviews (n): Last reviewed commit..." footer), not the last - *comment*, so it silently misses the actual "Confidence Score: X/5" line. - `reviewThreads(first: 50)` is a single page — check `pageInfo.hasNextPage`. If `true`, don't - stop yet: re-run the same query with `after: ""` and keep paging until - `hasNextPage` is `false` before evaluating "clean." A PR with more than 50 threads is rare but - stopping on a partial page would silently miss unresolved ones past the cutoff. - If `mergeable` is `CONFLICTING`, fix that first (step 2). Otherwise, if Greptile is 5/5 and - every thread across all pages has `isResolved: true`, stop — report the outcome (see - "Reporting" below) and skip the rest of this list. - -2. **If the PR has a merge conflict**, merge `origin/staging`, resolve the conflicts, run the - usual pre-push checks, push, and go to step 8 to re-trigger review. - -3. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run - automatically on PR open — confirm via `gh pr checks ` (look for `Cursor Bugbot` / - `Greptile Review`) and wait for that first round before doing anything else. - -4. **If a review round has landed and it isn't clean**: for every thread where - `isResolved: false`, triage the finding on its own merits — this is the part that requires - judgment, not a mechanical loop: - - **Real bug**: fix it in the cleanest way available. Match the codebase's existing - conventions for that kind of problem before inventing a new one (e.g. an SSRF-prone - user-supplied-host fetch should use whatever `validateUrlWithDNS`/`secureFetchWithPinnedIP` - pattern the rest of the codebase already uses for that exact situation — grep for a sibling - integration solving the same problem first). Never patch around a finding with a - workaround, a broad try/catch, or a suppression comment — fix the actual cause. - - **False positive**: don't change code. Reply with the specific reason it doesn't apply - (cite the type definition, the established pattern it matches, or the doc it follows) so - the reviewer bot and a human skimming later both understand why it was left as-is. - - **Already fixed by an earlier finding in the same round**: note that and resolve without a - duplicate code change. - -5. **Reply to every thread individually** before resolving it — never resolve silently: - ```bash - gh api repos///pulls//comments//replies -f body="" - ``` - Then resolve via GraphQL (needs the thread `id` from step 1, not the comment id): - ```bash - gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: ""}) { thread { isResolved } } }' - ``` - -6. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, - the whole check-and-recover flow (stash WIP if needed, rebase, verify the rebase didn't just - cleanly replay stray commits, cherry-pick rebuild if it did or if it conflicted). A babysit - loop spanning a long session is exactly the scenario where a branch can drift, and pushing - review fixes on top of undetected drift is how an oversized PR happens even after the branch - was fixed once. Then run the repo's pre-ship checks the same way `/ship` does before - committing — not just lint/typecheck/boundary-validation, but also the conditional `/cleanup` - (if this round's fix touched UI code) and `/db-migrate` (if it touched schema/migrations) - gates from `/ship` steps 4 and 5. A review-fix round is still a code change and can trip - either gate just as easily as the original commit did. - -7. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 6's - sync check rewrote history, which includes a plain `git rebase origin/staging` that completed - with no conflicts, not only the cherry-pick rebuild path; both rewrite commits already - published to the remote, so a plain `git push` can be rejected either way — then run `/ship` - step 9's post-push verify — not just before the first push, every push in the loop: - ```bash - git fetch origin staging && git log --oneline --reverse origin/staging..HEAD - gh pr view --json commits -q '.commits[].messageHeadline' - ``` - `--reverse` makes `git log` oldest-first, matching the PR commit list's order — plain - `git log` is newest-first, so without it a positional comparison can spuriously fail on any - multi-commit branch. - These two lists must describe the same commits. A review loop runs many pushes across many - rounds; checking sync only before the push (step 6) and never after is how a bad push or a - PR whose commit history quietly went stale between rounds goes unnoticed. - -8. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR - comments** — never combine them into one comment, each bot only responds to its own mention: - ```bash - gh pr comment --body "@greptile" - gh pr comment --body "@cursor review" - ``` - -9. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using - a fallback delay of ~250–300s (Greptile/Cursor typically take 1–3 minutes) — never busy-poll - in a sleep loop. Pass the same `/loop babysit PR ` prompt on each wakeup so the loop - resumes correctly. - -10. **Stop conditions**: clean state reached (see above), or the same unresolved finding or - merge conflict survives two consecutive rounds with no new information (surface it to the - user instead of looping forever), or the user interrupts. - -## Reporting - -When the loop ends, summarize: how many rounds it took, what was actually fixed (one line each), -what was pushed back on as a false positive and why, and the final Greptile score / thread count. - -## Hard rules - -- Never post the two re-review mentions as a single combined comment. -- Never resolve a thread without replying to it first. -- Never fix a finding with a hacky workaround — if the clean fix isn't obvious, find the sibling - pattern elsewhere in the codebase solving the same class of problem and match it. -- Never silently drop a finding — every thread gets either a code fix or a reasoned reply. -- Always re-run the `/ship`-style sync check before every push in the loop, not just the first. diff --git a/.claude/commands/cleanup.md b/.claude/commands/cleanup.md deleted file mode 100644 index e4f3a2f6f2d..00000000000 --- a/.claude/commands/cleanup.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -description: Run all code quality skills — effects, memo, callbacks, state, React Query, emcn design review, url-state, and comments — analyzing in parallel, then applying fixes sequentially -argument-hint: "[scope] [fix=true|false]" ---- - -# Cleanup - -Arguments: -- scope: what to review (default: your current changes). Examples: "diff to main", "PR #123", "src/components/", "whole codebase" -- fix: whether to apply fixes (default: true). Set to false to only propose changes. - -User arguments: $ARGUMENTS - -## Step 1 — Parallel analysis (read-only) - -First parse the user's `$ARGUMENTS` into `scope` and `fix`: extract the `fix=true|false` token wherever it appears in the string (start, middle, or end), and treat everything else — with that token removed — as `scope`. Defaults: `scope` = your current changes, `fix` = true. The `fix` value is consumed by Step 3 — it does NOT propagate to these passes, which always run `fix=false`. - -Spawn all eight passes concurrently as subagents in a **single message** (multiple Agent tool calls). Each runs its skill on the parsed `scope` with `fix=false` — analysis and proposals ONLY, no edits. Instruct each agent to return its findings as a structured list: for every proposed change, the file path, line range, a one-line description of the change, and the exact before/after so the orchestrator can apply it without re-deriving. - -Run these eight in parallel, substituting the parsed `scope` for `` in each invocation (pass the real scope text, never the literal ``): - -1. `/you-might-not-need-an-effect fix=false` -2. `/you-might-not-need-a-memo fix=false` -3. `/you-might-not-need-a-callback fix=false` -4. `/you-might-not-need-state fix=false` -5. `/react-query-best-practices fix=false` -6. `/emcn-design-review fix=false` -7. `/you-might-not-need-url-state fix=false` -8. `/you-might-not-need-a-comment fix=false` - -## Step 2 — Converge - -Collect all findings into one list, **keeping each proposal tagged with the pass that produced it** — do NOT collapse a file's proposals into a single unlabeled patch, because Step 3 applies in pass order and needs those labels. Detect overlaps where two passes touch the same region (common: a state pass and an effect pass on the same block, or a memo and callback pass on the same component). Reconcile only genuine same-region conflicts, and drop proposals a sibling pass has made moot; a reconciled change inherits the pass label of whichever of its passes comes first in the Step 3 dependency order (effects → state → memo → callback → React Query → url-state → emcn → comments), so it is applied at the earliest safe point. Non-overlapping proposals stay as-is with their own labels. The output is a per-pass list of surviving changes, not a per-file patch. - -## Step 3 — Sequential apply - -If `fix=false`, skip this step — just report the proposals from Step 2. - -Otherwise apply the surviving changes yourself (in the main context, not delegated), iterating **pass by pass** in this dependency order so earlier structural changes settle before later passes build on them: - -1. effects → 2. state → 3. memo → 4. callback → 5. React Query → 6. url-state → 7. emcn design → 8. comments - -For each pass in turn, apply all of that pass's changes, then move to the next pass. A file touched by several passes is therefore edited once per pass, in this order — not once as a merged patch. This is what makes the ordering real: a single merged-per-file patch would collapse all passes into one edit and lose it. - -Comments apply last, on purpose: that pass operates on whatever the earlier structural passes settled the code into, so it never edits lines a sibling pass is about to delete or rewrite. - -**Treat every Step 1 proposal as snapshot-relative, not authoritative.** All passes analyzed the *original* files in parallel, so a proposal's line ranges and before/after text describe the code as it was *before* any edits — once an earlier pass has run, a later pass's snippet may no longer match. So for each change, before applying: - -1. Re-read the file and locate the target by its **content** (the proposal's `old_string` snippet), not by its line number — line numbers from Step 1 are only a hint for where to look, since earlier edits shift them. -2. If the `old_string` still matches verbatim, apply it — a content-anchored edit is safe even if its line moved. -3. If it no longer matches (an earlier pass altered that region), do **not** force the stale patch. Re-derive the change from the current code by re-applying that pass's rule to the construct, or drop it if a prior pass already made it moot. Never apply a proposal against text it wasn't computed from. - -After all edits, run `bun run lint:check` (it runs `turbo run lint:check` across the repo — there is no per-file target, so run the full check). - -## Step 4 — Summary - -Output a summary across all eight passes: what each found, what was applied vs. skipped-as-redundant, and any proposals that need a human decision. - -## Boundary Audit Guidance - -- When removing route-local Zod schemas, replacing raw `fetch(` calls in hooks, or removing `as unknown as X` casts, do not introduce `// boundary-raw-fetch: ` or `// double-cast-allowed: ` annotations to silence the audit. Fix the underlying call instead — adopt a contract from `@/lib/api/contracts/**` and use `requestJson(contract, ...)` from `@/lib/api/client/request`, or refine the type so the double cast is unnecessary. -- Annotations are reserved for legitimate exceptions only: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, external-origin requests, and double casts where no narrower type is available. Each annotation requires a non-empty reason; empty reasons fail `bun run check:api-validation:strict`. diff --git a/.claude/commands/council.md b/.claude/commands/council.md deleted file mode 100644 index 8ea47fe727f..00000000000 --- a/.claude/commands/council.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: Spawn parallel task agents to explore a given area of the codebase from multiple angles, then use their findings to answer the question or build a plan. Use when a task needs broad fan-out exploration across many files before acting. -argument-hint: ---- - -Based on the given area of interest, please: - -1. Dig around the codebase in terms of that given area of interest, gather general information such as keywords and architecture overview. -2. Spawn off n=10 (unless specified otherwise) task agents to dig deeper into the codebase in terms of that given area of interest, some of them should be out of the box for variance. -3. Once the task agents are done, use the information to do what the user wants. - -If user is in plan mode, use the information to create the plan. diff --git a/.claude/commands/db-migrate.md b/.claude/commands/db-migrate.md deleted file mode 100644 index 6a53b472c68..00000000000 --- a/.claude/commands/db-migrate.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -description: Author or review a Drizzle DB migration for zero-downtime safety — expand/contract phasing, backward-compatibility with the deployed app version, and writing the `-- migration-safe` acknowledgment the check:migrations lint requires. Use when adding/editing files under `packages/db/migrations/` or changing `packages/db/schema.ts`. ---- - -# DB Migrate Skill - -You make schema changes that survive a deploy without downtime. The `check:migrations` lint (`scripts/check-migrations-safety.ts`) is the deterministic gate; you are the judgment that decides whether a flagged change is actually safe and writes the annotation that satisfies it. - -## The window (why this matters) - -A deploy runs the migration, then rolls out the new app image via blue/green. The two are **not atomic and cannot be** — during cutover the old task set keeps serving against the **already-migrated** schema. So: - -> Every migration must be backward-compatible with the app version that is *already deployed*. - -If a migration drops a column the old code still reads, renames one, or adds a `NOT NULL` the old inserts don't populate, the old code throws until traffic fully shifts — the downtime we're guarding against. You can't fix this by reordering the pipeline; the only fix is discipline. - -## Expand / contract - -Split every breaking change across **two deploys**: - -1. **Expand** (this PR): additive, backward-compatible schema + code that tolerates *both* the old and new shape. -2. **Contract** (a later PR, after expand is fully deployed): remove the old thing, now that nothing reads it. - -Never put expand and contract in the same PR. If this PR both removes the code that used a column *and* drops the column, the old code is still live during cutover — split it. - -### Per-operation playbook - -| You want to | Do (deploy 1 = expand) | Do (deploy 2 = contract) | -|---|---|---| -| Add a required column | `ADD COLUMN` nullable or `DEFAULT`; code writes it | backfill, then `SET NOT NULL` | -| Rename a column/table | add the new name; code dual-writes / reads new-then-old | drop the old name | -| Drop a column/table | stop all reads/writes in code; ship it | `DROP` (annotate) | -| Change a column type | add a new column of the new type; dual-write | backfill, swap reads, drop old | -| Add FK / CHECK | `ADD CONSTRAINT ... NOT VALID` | `VALIDATE CONSTRAINT` separately | -| Index an existing table | `COMMIT;` breakpoint → `SET lock_timeout = 0` → `CREATE INDEX CONCURRENTLY IF NOT EXISTS` (see `packages/db/scripts/migrate.ts`) | — | -| Drop an index | `COMMIT;` breakpoint → `DROP INDEX CONCURRENTLY` — plain `DROP INDEX` takes ACCESS EXCLUSIVE on the table | — | -| Backfill data | batched + idempotent `UPDATE` (keyset/`WHERE`, bounded) | — | - -A `CREATE INDEX`, `ADD COLUMN`, or `ADD CONSTRAINT` against a table **created in the same migration** is always safe (no rows, no live traffic) — the lint already suppresses those. - -## Tracking the contract (don't let it rot) - -The contract half is deferred to a later deploy — and that is exactly when it gets forgotten, leaving dead columns, orphaned tables, and `NOT NULL`s that never land. Every deferred contract must become a durable, greppable TODO. - -When an expand defers a drop, leave a **`contract-pending`** marker on the legacy column/table in `packages/db/schema.ts` — that is the file you will be editing when you finally do the drop, so the reminder lives where the work happens: - -```ts -// contract-pending(after #5035 is fully deployed): drop once permission-check.ts stops reading it -workspaceId: text('workspace_id'), -``` - -Format: `contract-pending(): `. The precondition names the PR/release that removes the last reader and **must be fully deployed** before the contract ships. - -- **The TODO list is a grep** — always accurate, never drifts: `grep -rn "contract-pending" packages/db apps/sim`. Run it when starting migration work to see what is owed. -- For anything with a real owner or schedule, also open a tracking issue and put its number in the marker. -- **Close the loop in the contract PR:** the contract migration's `-- migration-safe:` annotation references the expand, and you **delete the `contract-pending` marker** in the same PR: - ```sql - -- migration-safe: contract of #5035 — workspace_id readers removed there, deployed 2026-06-10 - ALTER TABLE "permission_group" DROP COLUMN "workspace_id"; - ``` -- An expand merged **without** a marker for the drop it defers, or a contract merged **without** removing its marker, is a bug — flag it in review. - -## The judgment the lint can't do - -The lint flags risky *shapes*; it cannot know whether a given drop is *safe right now*. For each flagged statement, do the work it can't: - -1. **Is the dependency gone?** Grep the app for the table/column: search `apps/sim` and `packages` for the column name, the Drizzle field (camelCase), and the table object. If any live read/write remains, it is **not** safe — fix the code first. -2. **Did the expand already ship?** The removal of that read/write must be in a deploy that is *already out*, not this same PR. If it's in this PR, split: land the code change now, do the destructive migration in a follow-up after it deploys. -3. **Backfills:** confirm the `UPDATE`/`DELETE` is batched (bounded `WHERE`/keyset, not a single whole-table statement), idempotent (safe to replay — a failed migration re-runs unjournaled files from the top), and safe under concurrent writes from the still-live old app. - -## Workflow - -1. Edit `packages/db/schema.ts`, then `cd packages/db && bunx drizzle-kit generate` to produce the SQL. If this is an expand that defers a drop, leave a `contract-pending` marker on the legacy column (see "Tracking the contract"). If this is the contract, delete the marker it resolves. -2. Hand-edit the generated SQL where the playbook requires it: `CONCURRENTLY` + `COMMIT;` breakpoint for indexes on existing tables, `NOT VALID` for constraints, batching for backfills. -3. Run `bun run check:migrations` (base defaults to `origin/staging`). - - **Hard errors** (`add-not-null-no-default`, `rename`, `index-not-concurrent`, `constraint-not-valid`, …): rewrite into expand/contract. Do **not** try to annotate them away — the lint won't accept it. - - **Annotate tier** (`drop-table`, `drop-column`, `drop-default`, `set-not-null`, `alter-type`, `drop-index`): only after you've confirmed steps 1–3 above, add a comment on the line directly above the statement: - ```sql - -- migration-safe: `secret` read removed in v0.6.1 (#1234), shipped two deploys ago - ALTER TABLE "webhook" DROP COLUMN "secret"; - ``` - The reason must be specific and name the PR/version that removed the dependency. An empty reason fails the lint. - - **Warnings** (`data-backfill`): non-blocking, but confirm the batching/idempotency before merging. -4. Verify locally: `cd packages/db && bun run db:migrate` against a dev DB. - -## Hard rule - -Never annotate a destructive statement just to make the lint pass. The annotation is a claim that you verified the old code no longer depends on it. If you can't make that claim truthfully, the change belongs in a later deploy — tell the user to split it. diff --git a/.claude/commands/design-taste-frontend.md b/.claude/commands/design-taste-frontend.md deleted file mode 100644 index 2f58011ea16..00000000000 --- a/.claude/commands/design-taste-frontend.md +++ /dev/null @@ -1,1205 +0,0 @@ ---- -description: Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check. ---- - -# tasteskill: Anti-Slop Frontend Skill - -> Landing pages, portfolios, and redesigns. Not dashboards, not data tables, not multi-step product UI. -> Every rule below is **contextual**. None of it fires automatically. First read the brief, then pull only what fits. - ---- - -## 0. BRIEF INFERENCE (Read the Room Before Anything Else) - -Before touching code or tweaking dials, **infer what the user actually wants**. Most LLM design output is bad because the model jumps to a default aesthetic instead of reading the room. - -### 0.A Read these signals first -1. **Page kind** - landing (SaaS / consumer / agency / event), portfolio (dev / designer / creative studio), redesign (preserve vs overhaul), editorial / blog. -2. **Vibe words** the user used - "minimalist", "calm", "Linear-style", "Awwwards", "brutalist", "premium consumer", "Apple-y", "playful", "serious B2B", "editorial", "agency-y", "glassy", "dark tech". -3. **Reference signals** - URLs they linked, screenshots they pasted, products they named, brands they're competing with. -4. **Audience** - B2B procurement panel vs. design-conscious consumer vs. recruiter scanning a portfolio. The audience picks the aesthetic, not your taste. -5. **Brand assets that already exist** - logo, color, type, photography. For redesigns, these are starting material, not optional input (see Section 11). -6. **Quiet constraints** - accessibility-first audiences, public-sector, regulated industries, trust-first commerce, kids' products. These constraints OVERRIDE aesthetic preference. - -### 0.B Output a one-line "Design Read" before generating -Before any code, state in one line: **"Reading this as: \ for \, with a \ language, leaning toward \."** - -Example reads: -- *"Reading this as: B2B SaaS landing for technical buyers, with a Linear-style minimalist language, leaning toward Tailwind utilities + Geist + restrained motion."* -- *"Reading this as: solo designer portfolio for hiring managers, with an editorial / kinetic-type language, leaning toward native CSS + scroll-driven animation + custom typography."* -- *"Reading this as: redesign of a public-sector service site, with a trust-first language, leaning toward GOV.UK Frontend or USWDS."* - -### 0.C If the brief is ambiguous, ask one question, do not guess -Ask exactly **one** clarifying question - never a multi-question dump - and only when the design read genuinely diverges. Example: *"Should this feel closer to Linear-clean or Awwwards-experimental?"* - -If you can confidently infer from context, **do not ask**. Just declare the design read and proceed. - -### 0.D Anti-Default Discipline -Do not default to: AI-purple gradients, centered hero over dark mesh, three equal feature cards, generic glassmorphism on everything, infinite-loop micro-animations everywhere, Inter + slate-900. These are the LLM defaults. Reach past them deliberately based on the design read. - ---- - -## 1. THE THREE DIALS (Core Configuration) - -After the design read, set three dials. Every layout, motion, and density decision below is gated by these. - -* **`DESIGN_VARIANCE: 8`** - 1 = Perfect Symmetry, 10 = Artsy Chaos -* **`MOTION_INTENSITY: 6`** - 1 = Static, 10 = Cinematic / Physics -* **`VISUAL_DENSITY: 4`** - 1 = Art Gallery / Airy, 10 = Cockpit / Packed Data - -**Baseline:** `8 / 6 / 4`. Use these unless the design read overrides them. Do not ask the user to edit this file - overrides happen conversationally. - -### 1.A Dial Inference (design read → dial values) -| Signal | VARIANCE | MOTION | DENSITY | -|---|---|---|---| -| "minimalist / clean / calm / editorial / Linear-style" | 5-6 | 3-4 | 2-3 | -| "premium consumer / Apple-y / luxury / brand" | 7-8 | 5-7 | 3-4 | -| "playful / wild / Dribbble / Awwwards / experimental / agency" | 9-10 | 8-10 | 3-4 | -| "landing page / portfolio / marketing site (default)" | 7-9 | 6-8 | 3-5 | -| "trust-first / public-sector / regulated / accessibility-critical" | 3-4 | 2-3 | 4-5 | -| "redesign - preserve" | match existing | +1 | match existing | -| "redesign - overhaul" | +2 | +2 | match existing | - -### 1.B Use-Case Presets -| Use case | VARIANCE | MOTION | DENSITY | -|---|---|---|---| -| Landing (SaaS, mainstream) | 7 | 6 | 4 | -| Landing (Agency / creative) | 9 | 8 | 3 | -| Landing (Premium consumer) | 7 | 6 | 3 | -| Portfolio (Designer / studio) | 8 | 7 | 3 | -| Portfolio (Developer) | 6 | 5 | 4 | -| Editorial / Blog | 6 | 4 | 3 | -| Public-sector service | 3 | 2 | 5 | -| Redesign - preserve | match | match+1 | match | -| Redesign - overhaul | +2 | +2 | match | - -### 1.C How the Dials Drive Output -Use these (or user-overridden values) as global variables. Cross-references throughout this document refer to these exact variable names - never invent aliases like `LAYOUT_VARIANCE` or `ANIM_LEVEL`. - ---- - -## 2. BRIEF → DESIGN SYSTEM MAP - -Once you have the design read (Section 0) and dials (Section 1), pick the right foundation. Do not invent CSS for things that have an official package. Do not pretend an aesthetic trend is an official system. - -### 2.A When to reach for a real design system (use official packages) -| Brief reads as… | Reach for | Why | -|---|---|---| -| Microsoft / enterprise SaaS / dashboards | `@fluentui/react-components` or `@fluentui/web-components` | Official Fluent UI, Microsoft tokens, accessibility done | -| Google-ish UI, Material-flavored product | `@material/web` + Material 3 tokens | Official, theme-able via Material Theming | -| IBM-style B2B / enterprise analytics | `@carbon/react` + `@carbon/styles` | Official Carbon, mature data-density patterns | -| Shopify app surfaces | `polaris.js` web components / Polaris React | Required for Shopify admin UI | -| Atlassian / Jira-style product | `@atlaskit/*` + `@atlaskit/tokens` | Official Atlassian DS | -| GitHub-style devtool / community page | `@primer/css` or `@primer/react-brand` | Official Primer; Brand variant for marketing | -| Public-sector UK service | `govuk-frontend` | Legally / regulatorily expected | -| US public-sector / trust-first | `uswds` | Same | -| Fast local-business / agency MVP | Bootstrap 5.3 | Boring, fast, works | -| Modern accessible React foundation | `@radix-ui/themes` | Primitives + polished theme | -| Modern SaaS where you own the components | shadcn/ui (`npx shadcn@latest add ...`) | You own the code, easy to customise; never ship default state | -| Tailwind-based modern SaaS / AI marketing | Tailwind v4 utilities + `dark:` variant | Default for indie + small team builds | - -**Honesty rule:** if the brief reads as one of the systems above, install and use the **official** package. Do not recreate its CSS by hand. Do not import a system's tokens but then override 90% of them. - -**One system per project.** Do not mix Fluent React with Carbon in the same tree. Do not import shadcn/ui components into a Material 3 app. - -### 2.B When the brief is an aesthetic, not a system -For these directions, there is **no single official package**. Build with native CSS + Tailwind + a maintained component library. Be honest in code comments about what is borrowed inspiration vs. official material. - -| Aesthetic | Honest implementation | -|---|---| -| Glassmorphism / "frosted glass" | `backdrop-filter`, layered borders, highlight overlays. Provide solid-fill fallback for `prefers-reduced-transparency`. | -| Bento (Apple-style tile grids) | CSS Grid with mixed cell sizes. No single library owns this. | -| Brutalism | Native CSS, monospace, raw borders. No library. | -| Editorial / magazine | Serif type, asymmetric grid, generous whitespace. No library. | -| Dark tech / hacker | Mono + accent neon, terminal motifs. No library. | -| Aurora / mesh gradients | SVG or layered radial gradients. No library. | -| Kinetic typography | Native CSS animations, scroll-driven animations, GSAP for hijacks. No library. | -| **Apple Liquid Glass** | Apple documents this for Apple platforms only. **There is no official `liquid-glass.css`.** Web implementations are approximations using `backdrop-filter` + layered borders + highlights. Label clearly as approximation. | - ---- - -## 3. DEFAULT ARCHITECTURE & CONVENTIONS - -Unless the design read picks a real design system (Section 2.A), these are the defaults: - -### 3.A Stack -* **Framework:** React or Next.js. Default to Server Components (RSC). - * **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `"use client"` component. - * **INTERACTIVITY ISOLATION:** Any component using Motion, scroll listeners, or pointer physics MUST be an isolated leaf with `'use client'` at the top. Server Components render static layouts only. -* **Styling:** **Tailwind v4** (default). Tailwind v3 only if the existing project demands it. - * For v4: do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin. -* **Animation:** **Motion** (the library formerly known as Framer Motion). Import from `motion/react` (`import { motion } from "motion/react"`). The `framer-motion` package still works as a legacy alias - prefer `motion/react` in new code. -* **Fonts:** Always use `next/font` (Next.js) or self-host with `@font-face` + `font-display: swap`. Never link Google Fonts via `` in production. - -### 3.B State -* Local `useState` / `useReducer` for isolated UI. -* Global state ONLY for deep prop-drilling avoidance - Zustand, Jotai, or React context. -* **NEVER** use `useState` to track continuous values driven by user input (mouse position, scroll progress, pointer physics, magnetic hover). Use Motion's `useMotionValue` / `useTransform` / `useScroll`. `useState` re-renders the React tree on every change and collapses on mobile. - -### 3.C Icons -* **Allowed libraries (priority order):** `@phosphor-icons/react`, `hugeicons-react`, `@radix-ui/react-icons`, `@tabler/icons-react`. -* **Discouraged:** `lucide-react`. Acceptable only when the user explicitly asks for it or the project already depends on it. -* **NEVER hand-roll SVG icons.** If a glyph is missing, install a second library or compose from primitives - do not draw icon paths from scratch. -* **One family per project.** Do not mix Phosphor with Lucide in the same component tree. -* **Standardize `strokeWidth` globally** (e.g. `1.5` or `2.0`). - -### 3.D Emoji Policy -Discouraged by default in code, markup, and visible text. Replace symbols with icon-library glyphs. **Override:** allow emojis only when the user explicitly asks for a playful / chat-style / social-native vibe - and even then use them sparingly with intent. - -### 3.E Responsiveness & Layout Mechanics -* Standardize breakpoints (`sm 640`, `md 768`, `lg 1024`, `xl 1280`, `2xl 1536`). -* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`. -* **Viewport Stability:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent layout jumping on mobile (iOS Safari address bar). -* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`). - -### 3.F Dependency Verification (mandatory) -Before importing ANY 3rd-party library, check `package.json`. If the package is missing, output the install command first. **Never** assume a library exists. - ---- - -## 4. DESIGN ENGINEERING DIRECTIVES (Bias Correction) - -LLMs default to clichés. Override these defaults proactively. Each rule has a context-aware override path. - -### 4.1 Typography -* **Display / Headlines:** Default `text-4xl md:text-6xl tracking-tighter leading-none`. -* **Body / Paragraphs:** Default `text-base text-gray-600 leading-relaxed max-w-[65ch]`. -* **Sans font choice:** - * **Discouraged as default:** `Inter`. Pick `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`, or a brand-appropriate serif first. - * **Override:** Inter is acceptable when the user explicitly asks for a neutral / standard / Linear-style feel, or when the brief is a public-sector / accessibility-first site. -* **Pairings to know:** `Geist` + `Geist Mono`, `Satoshi` + `JetBrains Mono`, `Cabinet Grotesk` + `Inter Tight`, `GT America` + `IBM Plex Mono`. - -* **SERIF DISCIPLINE (VERY DISCOURAGED AS DEFAULT):** - * Serif is **very discouraged as the default font for any project.** "It feels creative / premium / editorial" is NOT a reason to reach for serif. The agent's default mental model that "creative brief = serif" is the single most-tested AI tell in production rounds. - * **Serif is only acceptable when ONE of these is explicitly true:** - - The brand brief literally names a serif font, OR - - The aesthetic family is genuinely editorial / luxury / publication / manuscript / heritage / vintage AND you can articulate why this specific serif fits this specific brand - * For everything else (creative agency, design studio, modern brand, premium consumer, portfolio, lifestyle), **default sans-serif display** (Geist Display, ABC Diatype, Söhne Breit, Cabinet Grotesk Display, Migra Sans, GT Walsheim, Inter Display, PP Neue Montreal). Sans display fonts are not "boring" — they are the default for the same reason black is the default in fashion. - * **EMPHASIS RULE (related):** When you want to emphasize a word within a headline (the kinetic "and `spatial` design" type move), use **italic or bold of the SAME font**. Do NOT inject a random serif word into a sans headline (or vice versa) just to add visual interest. Mixed-family emphasis is amateur. Italic/bold emphasis in the same family is the right move. - * **Specifically BANNED as defaults:** `Fraunces` and `Instrument_Serif` (the two LLM-favorite display serifs). - * **If a serif is justified** (rare, per the above), rotate from this pool, do NOT reuse the same serif across consecutive projects: PP Editorial New, GT Sectra Display, Cardinal Grotesque, Reckless Neue, Tiempos Headline, Recoleta, Cormorant Garamond, Playfair Display, EB Garamond, IvyPresto, Migra, Editorial Old, Saol Display, Söhne Breit Kursiv, Domaine Display, Canela, Schnyder, Tobias, NB Architekt, ITC Galliard. - -* **ITALIC DESCENDER CLEARANCE (mandatory):** When italic is used in display type and the word contains a descender letter (`y g j p q`), `leading-[1]` or `leading-none` will clip the descender. Use `leading-[1.1]` minimum and add `pb-1` or `mb-1` reserve on the wrapping element. Audit every italic word in display headlines before shipping. - -### 4.2 Color Calibration -* Max 1 accent color. Saturation < 80% by default. -* **THE LILA RULE:** The "AI Purple / Blue glow" aesthetic is discouraged as a default. No automatic purple button glows, no random neon gradients. Use neutral bases (Zinc / Slate / Stone) with high-contrast singular accents (Emerald, Electric Blue, Deep Rose, Burnt Orange, etc.). -* **Override:** if the brand or brief explicitly asks for purple / violet / lila, embrace it. But execute with intent: consistent palette, harmonised neutrals, restrained gradients. Not generic AI gradient slop. -* **One palette per project.** Do not fluctuate between warm and cool grays within the same project. -* **COLOR CONSISTENCY LOCK (mandatory):** Once an accent color is chosen for a page, it is used on the WHOLE page. A warm-grey site does not suddenly get a blue CTA in section 7. A rose-accented site does not get a teal status badge in the footer. Pick one accent, lock it, audit every component before shipping. - -* **PREMIUM-CONSUMER PALETTE BAN (mandatory, second-most-recurring AI-tell):** - * For premium-consumer briefs (cookware, wellness, artisan, luxury, heritage craft, DTC home goods, etc.) the LLM default is **warm beige/cream + brass/clay/oxblood/ochre + espresso/ink dark text**. Concretely banned hex families as default backgrounds and accents: - - Backgrounds: `#f5f1ea`, `#f7f5f1`, `#fbf8f1`, `#efeae0`, `#ece6db`, `#faf7f1`, `#e8dfcb` (all "warm paper / cream / chalk / bone") - - Accents: `#b08947`, `#b6553a`, `#9a2436`, `#9c6e2a`, `#bc7c3a`, `#7d5621` (all "brass / clay / oxblood / ochre") - - Text: `#1a1714`, `#1a1814`, `#1b1814` (all "espresso / warm near-black") - * This palette is BANNED as the default reach for premium-consumer briefs. Every premium-consumer site you have ever shipped uses this exact palette. The brand becomes invisible. - * **Default alternatives (rotate, do not reuse):** - - **Cold Luxury:** silver-grey + chrome + smoke (think Tesla, Apple Watch Hermes-without-the-leather) - - **Forest:** deep green + bone + amber accent (think Filson, Patagonia premium) - - **Black and Tan:** true off-black + warm tan, sharp contrast, no beige - - **Cobalt + Cream:** saturated blue against a single neutral, no brass - - **Terracotta + Slate:** warm rust against cool grey, no brass - - **Olive + Brick + Paper:** muted olive plus brick-red accent - - **Pure monochrome + single saturated pop:** off-white + off-black + one bright accent (electric blue, emerald, hot pink, etc.) - * **Palette-rotation rule:** if the previous premium-consumer project you generated used the beige+brass family, this one MUST use a different family. Do not ship the same warm-craft palette twice in a row. - * **Override:** the beige+brass+espresso palette is acceptable ONLY when the brand brief explicitly names those colors, or when the brand identity is genuinely vintage / artisan / warm-craft AND you can articulate why this specific palette fits this specific brand. Default-reaching for it because "this is a cookware brief" is banned. - -### 4.3 Layout Diversification -* **ANTI-CENTER BIAS:** Centered Hero / H1 sections are avoided when `DESIGN_VARIANCE > 4`. Force "Split Screen" (50/50), "Left-aligned content / right-aligned asset", "Asymmetric white-space", or scroll-pinned structures. -* **Override:** centered hero is OK for editorial / manifesto / launch-announcement briefs where the message itself is the design. - -### 4.4 Materiality, Shadows, Cards -* Use cards ONLY when elevation communicates real hierarchy. Otherwise group with `border-t`, `divide-y`, or negative space. -* When a shadow is used, tint it to the background hue. No pure-black drop shadows on light backgrounds. -* For `VISUAL_DENSITY > 7`: generic card containers are banned. Data metrics breathe in plain layout. -* **SHAPE CONSISTENCY LOCK (mandatory):** Pick ONE corner-radius scale for the page and stick to it. Options: all-sharp (radius 0), all-soft (radius 12-16px), all-pill (full radius for interactive). Mixed systems are allowed only when there is a documented rule (e.g. "buttons are full-pill, cards are 16px, inputs are 8px") and that rule is followed everywhere. Round buttons in a square layout, or square cards on a pill-button page, is broken design. - -### 4.5 Interactive UI States -LLMs default to "static successful state only." Always implement full cycles: -* **Loading:** Skeletal loaders matching the final layout's shape. Avoid generic circular spinners. -* **Empty States:** Beautifully composed; indicate how to populate. -* **Error States:** Clear, inline (forms), or contextual (toasts only for transient). -* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push. -* **BUTTON CONTRAST CHECK (mandatory, a11y):** Before shipping any button, verify the button text is readable against the button background. White button + white text, `bg-white` CTA with `text-white` label, transparent button against the page background with no border → all banned. Audit every CTA: contrast ratio WCAG AA min (4.5:1 for body, 3:1 for large text 18px+). Same rule applies to ghost buttons over photographic backgrounds (use a backdrop, scrim, or stroke). -* **CTA BUTTON WRAP BAN (mandatory):** Button text MUST fit on one line at desktop. If a label like "VIEW SELECTED WORK" wraps to 2 or 3 lines, the button is broken. Fix by EITHER shortening the label (3 words max for primary CTAs, ideally 1-2) OR widening the button (do not artificially constrain `max-width` on CTAs). Wrapped CTAs at desktop are a Pre-Flight Fail. -* **NO DUPLICATE CTA INTENT (mandatory):** Two CTAs with the same intent on one page is a Pre-Flight Fail. Examples of same intent: "Get in touch" + "Contact us" + "Let's talk" + "Start a project" + "Start something" + "Reach out" = all "contact" intent → pick ONE label and use it everywhere on the page (nav, hero, footer). Same for "Try free" + "Get started" + "Sign up free" (all "signup" intent) and "View work" + "See selected work" + "Browse projects" (all "portfolio" intent). One label per intent. -* **FORM CONTRAST CHECK (mandatory, a11y):** Form inputs, placeholder text, focus rings, helper text, and error text all pass WCAG AA contrast against the section background. Light placeholders on a near-white form, white form on white page section, form labels grayer than 4.5:1 contrast → all banned. Audit every form before shipping. - -### 4.6 Data & Form Patterns -* Label ABOVE input. Helper text optional but present in markup. Error text BELOW input. Standard `gap-2` for input blocks. -* No placeholder-as-label. Ever. - -### 4.7 Layout Discipline (Hard Rules. Failing any of these is shipping broken work) - -* **Hero MUST fit in the initial viewport.** Headline max 2 lines on desktop, subtext max **20 words** AND max 3-4 lines, CTAs visible without scroll. If the copy is too long: reduce font scale OR cut copy. If you cannot describe the value-prop in 20 words of subtext, the value-prop is unclear, not the rule too tight. Never let the hero overflow and force scroll to find the CTA. -* **Hero font-scale discipline.** Plan font size and image size *together*. If the hero asset is large and the headline is more than 6 words, do not start at `text-7xl/text-8xl`. Default sensible range: `text-4xl md:text-5xl lg:text-6xl` for most heroes; `text-6xl md:text-7xl` only when the headline is 3-5 words. A 4-line hero headline is always a font-size error, never a copy-length error. -* **HERO TOP PADDING CAP (mandatory):** Hero top padding max `pt-24` (≈6rem) at desktop. More than that means the hero content floats halfway down the viewport and reads as a layout bug, not as intentional space. If your hero needs more breathing room, increase font scale or asset size, not top padding. -* **HERO STACK DISCIPLINE (max 4 text elements).** The hero is a single moment, not a feature list. Allowed text elements, max 4 in total: - 1. Eyebrow (small uppercase label) OR brand strip OR neither - pick zero or one - 2. Headline (max 2 lines, see above) - 3. Subtext (max 20 words, max 4 lines) - 4. CTAs (1 primary + max 1 secondary) - - **BANNED in the hero:** tiny tagline below CTAs ("Works with GitHub, GitLab, and self-hosted Git"), trust micro-strip ("Used by engineering teams at..."), pricing teaser ("Free for solo, $10/user for teams"), feature bullet list, social-proof avatar row. All of those move to dedicated sections directly below the hero. - - If you have an eyebrow AND a tagline below CTAs in the same hero, drop the tagline. If you have a brand strip AND a tagline, drop the tagline. One small text element per hero, max. -* **"Used by" / "Trusted by" logo wall belongs UNDER the hero, never inside it.** The hero is for the value prop and primary CTA. The logo wall is a separate section directly below. Do not stuff trust logos into the same flex row as the hero copy. -* **Navigation MUST render on a single line on desktop.** If items don't fit at `lg` (1024px), condense labels, drop secondary items, or move to a hamburger. A two-line nav at desktop is broken design. -* **Navigation height cap: 80px max desktop, default 64-72px.** No huge "agency" nav bars that eat 15% of the viewport. -* **Bento grids MUST have rhythm, not one-sided repetition.** Do not stack 6 left-image / right-text rows. Vary the composition: alternate full-width feature rows, asymmetric tile sizes, vertical breaks. -* **BENTO CELL COUNT RULE (mandatory):** A bento grid has EXACTLY as many cells as you have content for. 3 items → 3 cells (1+2 split, or 2+1, or asymmetric trio). 5 items → 5 cells (2+3, 3+2, hero+4, etc.). If your grid has an empty cell in the middle or at the end, you planned wrong. Re-shape the grid; do not paste a blank tile. -* **Section-Layout-Repetition Ban.** Once you use a layout family for a section (e.g., 3-column-image-cards, full-width-quote, split-text-image), that family can appear at most ONCE on the page. "Selected commissions" must not look like "What we do." A landing page with 8 sections must use at least 4 different layout families. -* **ZIGZAG ALTERNATION CAP (mandatory).** Alternating "left-image + right-text" then "left-text + right-image" zigzag layout = banal. Max 2 sections in a row with this image+text-split pattern. The 3rd consecutive image+text split is a Pre-Flight Fail. Break the pattern with a full-width section, a vertical-stack section, a bento grid, a marquee, or a different layout family. -* **EYEBROW RESTRAINT (mandatory, the #1 violated rule in production tests).** An "eyebrow" is the small uppercase wide-tracking label sitting above a section headline (e.g. `FOUR COLORWAYS`, `SELECTED WORK`, `THE HARDWARE`, `Git-native task management`). Typical CSS signature: `text-[11px] uppercase tracking-[0.18em]`, `font-mono text-[10.5px] uppercase tracking-[0.22em]`. Every AI-built site puts an eyebrow above EVERY section header, producing the same templated rhythm. Hard rule: - - **Maximum 1 eyebrow per 3 sections.** Hero counts as 1. So a page with 9 sections may use at most 3 eyebrows total. - - If section A has an eyebrow, the next 2 sections cannot have one. - - **Pre-Flight Check is mechanical:** count instances of `uppercase tracking` (or similar small-caps mono labels above headlines) across all section components. If count > ceil(sectionCount / 3), the output fails. - - **What to do instead of an eyebrow:** drop it entirely. The headline alone is enough. If you need to categorize a section, the section's location on the page already categorizes it; no label needed. -* **SPLIT-HEADER BAN (mandatory).** The pattern "left big headline + right small explainer paragraph" as a section header (left col-span-7/8, right col-span-4/5 with a small body paragraph floating in the right column) is **banned as default**. Sections should have ONE focused message. If you genuinely need both a headline and an explainer paragraph, stack them vertically (headline on top, body below, max-width 65ch). Reach for the split-header pattern only when there is a real compositional reason (e.g., the right column carries a visual or interactive element, not just filler text). -* **Bento Background Diversity (mandatory).** Bento and feature-grid sections cannot be 6 white-on-white cards with text inside. At least 2-3 cells in any multi-cell grid need real visual variation: a real image, a brand-appropriate gradient (not AI-purple), a pattern, a tinted background. A cream-on-cream bento with only typography inside reads as boring AI default, even when the rest of the page is good. -* **Mobile collapse must be explicit per section.** For every multi-column layout, declare the `< 768px` fallback in the same component. No "it'll work, Tailwind handles it" assumptions. - -### 4.8 Image & Visual Asset Strategy - -Landing pages and portfolios are **visual products**. Text-only pages with fake-screenshot divs are slop. - -**Priority order for visual assets:** -1. **Image-generation tool first.** If ANY image-gen tool is available in the environment (`generate_image`, MCP image tool, IDE-integrated gen, OpenAI image tools, etc.) you MUST use it to create section-specific assets: hero photography, product shots, texture backgrounds, mood images. Generate at the right aspect ratio for the section. Do not skip this step because hand-rolled CSS feels faster. -2. **Real web images second.** When no gen tool is available, use real photography sources. Acceptable defaults: - * `https://picsum.photos/seed/{descriptive-seed}/{w}/{h}` for placeholder photography (seed should describe the section, e.g. `marrow-cookware-kitchen`) - * Actual stock or brand URLs when the brief provides them - * Open-license sources (Unsplash via direct URL, Pexels) if explicitly allowed -3. **Last resort: tell the user.** If neither is possible, do NOT fill the page with hand-rolled SVG illustrations or div-based "fake screenshots." Instead, leave clearly-labeled placeholder slots (``) and at the end of the response say: *"This page needs real images at: \[list of placements\]. Please generate or provide them."* - -**Even minimalist sites need real images.** A pure-text page is not minimalism. It is incomplete work. Even an editorial Linear-style site needs at least 2-3 real images (hero, one product/lifestyle shot, one supporting image). Generate B&W minimalist photography if the brief is restrained; do not skip images entirely because the dial is low. - -**Real company logos for social proof.** When the brief calls for a "Trusted by / Used by / Customers" logo wall, do NOT default to plain text wordmarks (`Acme Co` styled in a row). Use real SVG logos: -* **Source: Simple Icons** (`https://cdn.simpleicons.org/{slug}/ffffff` for any color, or `simple-icons` npm package). Covers most known brands. -* **Alternative: devicon** for tech-stack logos (`@svgr/cli` or CDN). -* **Make-up the brand name? Then make-up an SVG mark too.** Generate a simple monogram (one letter in a circle, two-letter ligature, abstract glyph) rendered as an inline `` matching the page style. Plain text wordmarks for invented brand names look generic. -* **Always** ensure logos render in both light and dark mode (white-on-dark, black-on-light, or single-color theme variable). -* **LOGO-ONLY rule (mandatory):** logo wall = logos and nothing else. Do NOT print industry / category labels below each logo (no `Vercel` + `hosting` underneath, no `Stripe` + `payments`, no `Cloudflare` + `infra`). The logo is the credibility, the label adds nothing the user does not already know. Optional: brand name as alt-text for screen readers, optional link to the brand's site. That is it. - -**Hand-rolled illustrations:** -* SVG icons from libraries: fine (see Section 3.C). -* Hand-rolled decorative SVGs (custom illustrations, logos, marks): **strongly discouraged**, never as default. Acceptable only when: - - The brief explicitly calls for it ("draw me an SVG logo") - - It's a single, simple geometric mark (a square, a circle, a wordmark in display type) - - You're confident in the output quality - -**Div-based fake screenshots are banned.** A "hand-built product preview" rendered with `
` rectangles, fake task lists, fake dashboards, fake terminal windows is a Tell. If you need to show a product: -* Use a real screenshot URL if one exists -* Generate one via image tool -* Use a real component preview (an actual mini-version of the UI inside the page) -* Or skip the preview entirely and use editorial photography - -**Hero needs a real visual.** Text + gradient blob is not a hero - it's a placeholder. - -### 4.9 Content Density - -Landing pages live on the **first impression**, not the full read. Cut ruthlessly. - -* **Default content shape per section:** short headline (≤ 8 words) + short sub-paragraph (≤ 25 words) + one visual asset OR one CTA. Anything more must be justified by the section's job. -* **No data-dump sections.** A 20-row publication table, a 30-row award list, a giant pricing matrix on a marketing page = wrong layout. Use: - - Top 3-5 highlights + "View full list" link - - Marquee / carousel for breadth - - Different page entirely if the data is the product -* **Long lists need a different UI component, not a longer list.** Default `
    ` with bullets / `divide-y` rows is the lazy choice. If you have > 5 items, reach for one of these instead: - - 2-column split with grouped items - - Card grid with image + label per item - - Tabs / accordion if items are categorisable - - Horizontal scroll-snap pills - - Carousel for breadth-heavy lists (testimonials, logos, capabilities) - - Marquee for "lots-of-things-that-don't-need-individual-attention" - A spec sheet with 10 rows + a hairline under every row is the WORST default. Either group rows into 2-3 chunks with sparse dividers, or move to a card-per-spec layout. -* **Spec sheets specifically (the Marrow-cookware pattern).** A long product specification table with `border-b` on every row is the AI default for cookware / hardware / apparel / artisan-goods briefs. Banned. Concrete alternatives: - - **2-col card grid:** each spec gets its own card with the spec name, the value (large display number), and a one-line "why it matters" body. Cards arranged 2-col on desktop, 1-col mobile. - - **Scroll-snap horizontal pills:** each spec is a pill, user can flick through. - - **Grouped chunks:** group 10 specs into 3 logical clusters (e.g. "Materials", "Cooking", "Warranty"), each cluster gets ONE soft divider and a cluster heading. - - **Featured-vs-rest:** 3-4 hero specs visualised as large display tiles, the rest collapsed under a "View full specifications" disclosure. - -* **COPY SELF-AUDIT (mandatory before ship):** Before declaring any task done, re-read every visible string on the page (headlines, subheads, eyebrows, button labels, body copy, captions, alt text, footer text, error messages). Flag any string that is: - - **Grammatically broken** ("free on its past", "two plans but one is honest", "to put it on the table" out of context) - - **Has unclear referents** ("we plan to stay that way" without prior context) - - **Sounds like AI hallucination** (cute-but-wrong wordplay, forced metaphors that don't track, "elegant nothing" phrases) - - **Reads like an LLM trying to sound thoughtful** (passive-aggressive humility, fake-craftsman labels, mock-poetic micro-meta) - Rewrite every flagged string. If unsure whether a string makes sense, replace it with a plain functional sentence. AI-generated cute copy is worse than boring copy. -* **Fake-precise numbers are flagged.** Numbers like `92%`, `4.1×`, `48k`, `5.8 mm`, `13.4 lb` either: - - Come from real data (brief, brand guidelines, public metrics) - fine - - Are explicitly labeled as mock (``, "example", "sample data") - fine - - Are AI-invented spec aesthetics - banned. Don't fake engineering precision the brand doesn't claim. -* **One copy register per page.** Don't mix technical mono ("47 tasks · 0.6 ctx-switches/day"), editorial prose, and marketing punch in the same composition unless the brand voice explicitly calls for it. - -### 4.10 Quotes & Testimonials - -* **Max 3 lines** of quote body. Never 6. If the original quote is longer → cut it. A landing-page quote is a snippet, not the full review. -* For very small font sizes (e.g. footer-style testimonials), the line cap can stretch slightly. Spirit: "fits in a glance." -* **No em-dashes inside the quote text** as design flourish (long pauses, kinetic em-dashes, em-dash-bullets). See Section 9.G - em-dash is completely banned. -* Attribution: name + role + (optionally) company. Never name only ("- Sarah"). -* Quote marks: use real typographic quotes ( " " ) or none at all. Not straight ASCII ( " ). - -### 4.11 Page Theme Lock (Light / Dark Mode Consistency) - -The page has ONE theme. Sections do not invert. - -* If the page is dark mode, ALL sections are dark mode. No light-mode-warm-paper section sandwiched between dark sections (or vice versa). The user must not feel they walked into a different website mid-scroll. -* The exception: if the brief explicitly calls for a "Color Block Story" or "Theme Switch on Scroll" device AND that is a deliberate composition (one full theme switch with a strong transition, not random alternation), it is allowed once per page. -* Default behaviour: pick light, dark, or auto (`prefers-color-scheme`) at the page level and lock it. Section-level background tints within the same theme family are fine (`bg-zinc-950` next to `bg-zinc-900`); flipping to `bg-amber-50` in the middle of a `bg-zinc-950` page is broken. -* When using a design system with built-in theming (Radix Themes, shadcn/ui with ``), set the theme ONCE in `layout.tsx` or the page root. Do not let individual sections override. - ---- - -## 5. CONTEXT-AWARE PROACTIVITY - -These are tools, not defaults. Use them when the design read calls for them. **None of these fire automatically.** - -* **Liquid Glass / Glassmorphism:** Appropriate for premium consumer, Apple-adjacent, luxury brand, or media-overlay vibes. Inappropriate for dashboards, public-sector, or "boring B2B." When used, go beyond `backdrop-blur`: add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) for physical edge refraction. Provide a solid-fill fallback under `prefers-reduced-transparency`. -* **Magnetic Micro-physics:** Use when `MOTION_INTENSITY > 5` AND the brief reads premium / playful / agency. Implement EXCLUSIVELY with Motion's `useMotionValue` / `useTransform` outside the React render cycle. Never `useState`. See Section 3.B. -* **Perpetual Micro-Interactions** (Pulse, Typewriter, Float, Shimmer, Carousel): Use when `MOTION_INTENSITY > 5` AND the section actively benefits from motion (status indicators, live feeds, AI-feel). **Not every card needs an infinite loop.** If a section is informational, leave it still. Apply Spring Physics (`type: "spring", stiffness: 100, damping: 20`) - no linear easing. -* **"Motion claimed, motion shown."** If `MOTION_INTENSITY > 4`, the page must actually move: entry transitions on hero, scroll-reveal on key sections, hover physics on CTAs, at minimum. A static page that claims `MOTION_INTENSITY: 7` is broken. Conversely, if you cannot ship working motion in the available scope, drop the dial to 3 and ship a clean static page. Never half-build motion that breaks (cut-off ScrollTriggers, jumpy enters, missing cleanups). -* **MOTION MUST BE MOTIVATED (mandatory).** Before adding any animation, ask: "what does this animation communicate?" Valid answers: hierarchy (drawing attention to the right thing), storytelling (revealing content in sequence that matches a narrative), feedback (acknowledging a user action), state transition (showing something changed). Invalid answer: "it looked cool". GSAP everywhere because GSAP is available is amateur. Each ScrollTrigger, each marquee, each pinned section needs a reason. If you cannot articulate the reason in one sentence, drop the animation. -* **MARQUEE MAX-ONE-PER-PAGE (mandatory).** Horizontal scrolling text marquees ("logos endlessly scrolling", "manifesto scrolling sideways", "kinetic word strip") are appropriate at most ONCE per page. Two or more marquees on the same page reads as lazy filler. Pick the one section where the marquee actually serves the content; the others get a different layout. -* **GSAP Sticky-Stack Pattern (when scroll-stack is used).** A "card stack on scroll" must be a REAL sticky-stack, not a sequential reveal list. See Section 5.A below for the canonical code skeleton. Common failure: trigger fires halfway through scroll instead of pinning at viewport top. Fix: `start: "top top"` not `start: "top center"` or `"top 80%"`. -* **GSAP Horizontal-Pan Pattern (when horizontal scroll-hijack is used).** See Section 5.B below for the canonical skeleton. Common failure: animation starts before the section is pinned, so the user sees half a slide. Same fix: `start: "top top"`, pin the wrapper, scrub the inner track. - -### 5.A Sticky-Stack - Canonical Skeleton - -```tsx -"use client"; -import { useRef, useEffect } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { useReducedMotion } from "motion/react"; - -gsap.registerPlugin(ScrollTrigger); - -export function StickyStack({ cards }: { cards: React.ReactNode[] }) { - const ref = useRef(null); - const reduce = useReducedMotion(); - - useEffect(() => { - if (reduce || !ref.current) return; - const ctx = gsap.context(() => { - const cardEls = gsap.utils.toArray(".stack-card"); - cardEls.forEach((card, i) => { - if (i === cardEls.length - 1) return; - ScrollTrigger.create({ - trigger: card, - start: "top top", // pin at viewport top - endTrigger: cardEls[cardEls.length - 1], - end: "top top", - pin: true, - pinSpacing: false, - }); - gsap.to(card, { - scale: 0.92, - opacity: 0.55, - ease: "none", - scrollTrigger: { - trigger: cardEls[i + 1], - start: "top bottom", - end: "top top", - scrub: true, - }, - }); - }); - }, ref); - return () => ctx.revert(); - }, [reduce]); - - return ( -
    - {cards.map((card, i) => ( -
    - {card} -
    - ))} -
    - ); -} -``` - -Critical points: `start: "top top"`, `pin: true`, every card except the last is pinned, the scale/opacity transform is driven by the NEXT card's scroll trigger (so previous card shrinks as next one arrives). - -### 5.B Horizontal-Pan - Canonical Skeleton - -```tsx -"use client"; -import { useRef, useEffect } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { useReducedMotion } from "motion/react"; - -gsap.registerPlugin(ScrollTrigger); - -export function HorizontalPan({ children }: { children: React.ReactNode }) { - const wrap = useRef(null); - const track = useRef(null); - const reduce = useReducedMotion(); - - useEffect(() => { - if (reduce || !wrap.current || !track.current) return; - const ctx = gsap.context(() => { - const distance = track.current!.scrollWidth - window.innerWidth; - gsap.to(track.current, { - x: -distance, - ease: "none", - scrollTrigger: { - trigger: wrap.current, - start: "top top", // pin starts when section top hits viewport top - end: () => `+=${distance}`, // scroll distance = track width minus viewport - pin: true, - scrub: 1, - invalidateOnRefresh: true, - }, - }); - }, wrap); - return () => ctx.revert(); - }, [reduce]); - - return ( -
    -
    - {children} -
    -
    - ); -} -``` - -Critical points: `start: "top top"`, `pin: true`, `end: "+=${distance}"` (scroll length = horizontal travel needed), `scrub: 1`. The wrapper is pinned, the inner track slides horizontally as the user scrolls vertically. - -### 5.C Scroll-Reveal Stagger - Canonical Skeleton (lighter alternative) - -For simple "items appear as they enter viewport" (no pinning), prefer Motion's `whileInView` over GSAP - lighter, no ScrollTrigger needed: - -```tsx -"use client"; -import { motion, useReducedMotion } from "motion/react"; - -export function RevealStagger({ items }: { items: string[] }) { - const reduce = useReducedMotion(); - return ( -
      - {items.map((item, i) => ( - - {item} - - ))} -
    - ); -} -``` - -Use this for: feature lists, testimonial grids, logo walls, anything that just needs "enter on scroll." Save GSAP for actual pin/scrub work. - -### 5.D Forbidden Animation Patterns - -* **`window.addEventListener("scroll", ...)`** is banned. It runs on every scroll frame, jank-prone, no batching. Use Motion's `useScroll()`, GSAP's `ScrollTrigger`, IntersectionObserver, or CSS `scroll-driven animations` (`animation-timeline: view()`). -* **Custom scroll progress calculations using `window.scrollY`** in React state. Same reason. Re-renders on every frame. -* **`requestAnimationFrame` loops that touch React state.** Use motion values (`useMotionValue` + `useTransform`) instead. -* **Layout Transitions:** Use Motion's `layout` and `layoutId` props for visible state changes (re-ordering lists, expanding modals, shared elements between routes). Do not wrap static content in `layout` props "for safety" - it costs measurement work. -* **Staggered Orchestration:** Use `staggerChildren` (Motion) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) for reveal moments where sequence matters. For `staggerChildren`, parent (`variants`) and children MUST share the same Client Component tree. - ---- - -## 6. PERFORMANCE & ACCESSIBILITY GUARDRAILS - -### 6.A Hardware Acceleration -* Animate ONLY `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`. -* Use `will-change: transform` sparingly - only on elements that will actually animate. - -### 6.B Reduced Motion (mandatory) -* **Any motion above `MOTION_INTENSITY > 3` MUST honor `prefers-reduced-motion`.** This is non-negotiable. -* In Motion: wrap with `useReducedMotion()` and degrade to static. -* In CSS: gate animations behind `@media (prefers-reduced-motion: no-preference)` or provide an override block under `@media (prefers-reduced-motion: reduce)` that disables. -* Infinite loops, parallax, scroll-hijack, and magnetic physics MUST collapse to static / instant under reduced motion. - -### 6.C Dark Mode (mandatory for any consumer-facing page) -* Design for **both modes from the start**. Never ship light-only or dark-only without explicit user instruction. -* Use Tailwind `dark:` variant OR CSS variables for tokens. Pick one strategy per project. -* **Do not prescribe specific dark-mode colors here.** The brief decides. Maintain visual hierarchy, brand identity, and WCAG AA contrast (AAA for body) across both modes. -* Respect `prefers-color-scheme: dark`. Default to system preference unless the brand insists on one mode. - -### 6.D Core Web Vitals Targets -* **LCP** < 2.5s. Hero image must be `next/image priority` or preloaded. -* **INP** < 200ms. Heavy work off main thread. -* **CLS** < 0.1. Reserve space for images, fonts, embeds. -* Run Lighthouse before declaring a page done. - -### 6.E DOM Cost -* Apply grain / noise filters EXCLUSIVELY to fixed, `pointer-events-none` pseudo-elements (e.g., `fixed inset-0 z-[60] pointer-events-none`). NEVER on scrolling containers - continuous GPU repaints destroy mobile FPS. -* Be aware of bundle size. Motion is not tiny. Three.js is large. Lazy-load anything that's not above-the-fold. - -### 6.F Z-Index Restraint -NEVER spam arbitrary `z-50` or `z-10`. Use z-index strictly for systemic layer contexts (sticky navbars, modals, overlays, grain). Document the z-index scale in a project constants file. - ---- - -## 7. DIAL DEFINITIONS (Technical Reference) - -### DESIGN_VARIANCE (Level 1-10) -* **1-3 (Predictable):** Symmetrical CSS Grid (12-col, equal fr-units), equal paddings, centered alignment. -* **4-7 (Offset):** `margin-top: -2rem` overlaps, varied image aspect ratios (4:3 next to 16:9), left-aligned headers over center-aligned data. -* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (`grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`). -* **MOBILE OVERRIDE:** For levels 4-10, asymmetric layouts above `md:` MUST collapse to strict single-column (`w-full`, `px-4`, `py-8`) on viewports `< 768px`. - -### MOTION_INTENSITY (Level 1-10) -* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only. `prefers-reduced-motion` is the default mode anyway. -* **4-7 (Fluid CSS):** `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. `animation-delay` cascades for load-ins. Focus on `transform` and `opacity`. -* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals, parallax, scroll-driven animation (CSS `animation-timeline` or GSAP ScrollTrigger). Use Motion hooks. **NEVER use `window.addEventListener('scroll')`** - it is a hard ban, not a "prefer-not." See Section 5.D for the allowed alternatives. - -### VISUAL_DENSITY (Level 1-10) -* **1-3 (Art Gallery):** Lots of white space. Huge section gaps (`py-32` to `py-48`). Expensive, clean. -* **4-7 (Daily App):** Standard web app spacing (`py-16` to `py-24`). -* **8-10 (Cockpit):** Tight paddings. No card boxes; 1px lines separate data. Mandatory: `font-mono` for all numbers. - ---- - -## 8. DARK MODE PROTOCOL - -Dual-mode by default. Never assume light-only unless the brief is print-emulating editorial. - -### 8.A Token Strategy (pick one, stick to it) -* **Tailwind `dark:` variant** (default for utility-first projects): every color utility paired with its dark variant (`bg-white dark:bg-zinc-950`, `text-gray-900 dark:text-gray-100`). -* **CSS variables** (for shadcn/ui, Radix Themes, or component libraries with theming): define semantic tokens (`--surface`, `--surface-elevated`, `--text-primary`, `--accent`) and swap values under `[data-theme="dark"]` or `@media (prefers-color-scheme: dark)`. - -### 8.B Do Not Prescribe Specific Colors Here -The brief and brand decide. This skill enforces only: -* **Contrast** - WCAG AA minimum for body text, AAA target for hero copy. -* **Hierarchy parity** - visual hierarchy that works in light must work in dark. If a CTA pops in light, it pops in dark. -* **Brand fidelity** - primary brand color stays recognisable. Don't desaturate the brand into a dark mode. -* **No pure `#000000` and no pure `#ffffff`** - use off-black (zinc-950, near-black warm gray) and off-white. Pure values kill depth. - -### 8.C Default Mode -Respect `prefers-color-scheme` unless the brand insists. Add a manual toggle if either mode would lose key brand expression. - -### 8.D Test in Both Modes Before Finishing -Open the page in both modes during development. Do not ship a page you've only seen in one mode. - ---- - -## 9. AI TELLS (Forbidden Patterns) - -Avoid these signatures unless the brief explicitly asks for them. - -### 9.A Visual & CSS -* **NO neon / outer glows** by default. Use inner borders or subtle tinted shadows. -* **NO pure black (`#000000`).** Off-black, zinc-950, or charcoal. -* **NO oversaturated accents.** Desaturate to blend with neutrals. -* **NO excessive gradient text** for large headers. -* **NO custom mouse cursors.** Outdated, accessibility-hostile, perf-hostile. - -### 9.B Typography -* **AVOID Inter as default.** See Section 4.1. Override path exists. -* **NO oversized H1s** that just scream. Control hierarchy with weight + color, not raw scale. -* **Serif constraints:** Serif for editorial / luxury / publication. Not for dashboards. - -### 9.C Layout & Spacing -* **Mathematically perfect** padding and margins. No floating elements with awkward gaps. -* **NO 3-column equal feature cards.** The generic "three identical cards horizontally" feature row is banned. Use 2-column zig-zag, asymmetric grid, scroll-pinned, or horizontal-scroll alternative. - -### 9.D Content & Data ("Jane Doe" Effect) -* **NO generic names.** "John Doe", "Sarah Chan", "Jack Su" → use creative, realistic, locale-appropriate names. -* **NO generic avatars.** No SVG "egg" or Lucide user icons → use believable photo placeholders or specific styling. -* **NO fake-perfect numbers.** Avoid `99.99%`, `50%`, `1234567`. Use organic, messy data (`47.2%`, `+1 (312) 847-1928`). -* **NO startup-slop brand names.** "Acme", "Nexus", "SmartFlow", "Cloudly" → invent contextual, premium names that sound real. -* **NO filler verbs.** "Elevate", "Seamless", "Unleash", "Next-Gen", "Revolutionize" → concrete verbs only. - -### 9.E External Resources & Components -* **NO hand-rolled SVG icons.** Use Phosphor / HugeIcons / Radix / Tabler. Lucide on explicit request only. -* **Hand-rolled decorative SVGs strongly discouraged** as default (see Section 4.8). -* **NO div-based fake screenshots.** Never build a fake product UI out of `
    ` rectangles to simulate a screenshot. Use real images, generated images, or skip the preview. -* **NO broken Unsplash links.** Use `https://picsum.photos/seed/{descriptive-string}/{w}/{h}`, or generated photo placeholders, or actual assets. -* **shadcn/ui customization:** Allowed, but NEVER in default state. Customize radii, colors, shadows, typography to the project aesthetic. -* **Production-Ready Cleanliness:** Code visually clean, memorable, meticulously refined. - -### 9.F Production-Test Tells (banned outright) - -These patterns came out of real LLM-generated landing-page tests. They are the signatures the model defaults to when it tries to "look designed." Treat them as hard bans unless the brief explicitly calls for one. - -**Hero & top-of-page** -* **NO version labels in the hero.** `V0.6`, `v2.0`, `BETA`, `INVITE-ONLY PREVIEW`, `EARLY ACCESS`, `ALPHA` - banned as default eyebrows. Only acceptable when the brief is explicitly about a product launch / preview status. -* **NO "Brand · No. 01"-style sub-eyebrows.** "Marrow · No. 01 · The 6-quart" type micro-meta lines. Skip them. - -**Section numbering & micro-labels** -* **NO section-number eyebrows.** `00 / INDEX`, `001 · Capabilities`, `002 · Featured commission`, `06 · how it works`, `05 · The honest table` - banned. Eyebrows should name the topic in plain language, not enumerate. -* **NO `01 / 4`-style pagination on images or bento tiles.** If the user can count, they don't need the label. -* **NO `Scroll · 001 Capabilities`-style scroll cues.** A simple arrow or "Scroll" is enough; no section-number prefix. -* **NO "Index of Work, 2018 - 2026"-style range labels** as eyebrows. Just say what the section is. - -**Separators & dots** -* **The middle-dot (`·`) is rationed.** Maximum 1 per line in metadata strips. Do NOT use it as the default separator for everything ("foo · bar · baz · qux · quux"). If you need a separator family, prefer line breaks, hairlines, or columns. -* **NO decorative colored status dots on every list/nav/badge.** A colored dot before "ONE Q4 SLOT OPEN" or before every nav link, or every task row - banned by default. Acceptable only when the dot conveys actual semantic state (a server status, an availability flag) and is used sparingly. - -**Em-dashes & typography flourishes** -* **NO em-dash (`—`) as a design element OR anywhere else.** See Section 9.G below for the complete, non-negotiable ban. The em-dash character is forbidden in headlines, eyebrows, pills, body copy, quotes, attribution, captions, button text, and alt text. Use the regular hyphen (`-`). -* **NO `
    `-broken-and-italicized headlines** as a default "design move." "for thirty\*years.*" type splits. Headlines should read naturally first, get clever only when the brief demands it. -* **NO vertical rotated text** ("INDEX OF WORK, 2018 - 2026" rotated 90°). Agency-portfolio cliché. Use it only when the brief is explicitly agency / Awwwards / experimental AND it serves a real composition purpose. -* **NO crosshair / hairline grid lines as decoration.** Vertical and horizontal lines drawn just to make the page "feel designed" - banned. Use them only when they organize real content. - -**Fake product previews** -* **NO div-based fake product UI in the hero** (fake task list, fake terminal, fake dashboard built from styled divs). It is the #1 LLM-design Tell. Use a real screenshot, a generated image, a real component preview, or none at all. -* **NO fake version footers** ("v0.6.2-rc.1", "last sync 4s ago · main") inside fake screenshots. Adds nothing, screams AI. - -**Marketing-copy Tells** -* **NO "Quietly in use at" / "Quietly trusted by"** social-proof headers. Use natural language: "Trusted by", "Used at", "Customers include", or skip the heading entirely if the logos speak. -* **NO "From the field" / "Field notes" / "Currently on the bench" / "On our desks" / "Loose plates" style poetic labels** on quote, blog, or sidebar sections. Reads as performative-craftsman. Use plain functional labels ("Testimonials", "Latest writing", "Now working on") or skip the label. -* **NO "We respect the French ones"-style** mock-humble industry-references in body copy. Cute and AI-y. -* **NO weather / locale strips** ("LIS 14:23 · 18°C") in headers/footers unless the brief is explicitly about a place / time-zone-distributed studio. -* **NO micro-meta-sentences under eyebrows.** Sentences like *"Each of these is a feature we ship today, not a roadmap promise. The list will stay short on purpose."* sitting under a section heading are clutter. Eyebrow + Headline + Body is enough. -* **NO generic step labels.** "Stage 1 / Stage 2 / Stage 3", "Step 1 / Step 2 / Step 3", "Phase 01 / Phase 02 / Phase 03", "Pass One / Pass Two / Pass Three". Banned. The actual step content is the label. If you must show progression, use the verb-noun directly ("Install", "Configure", "Ship") not "Stage 1: Install". - -**Pills, labels and version stamps** -* **NO pills/labels/tags overlaid on images.** No `` overlays on photos with tags like `Brand · 02`, `PLATE · BRAND`, `Field notes - journal`. Either let the image speak alone, or add a caption directly below (outside the image). -* **NO photo-credit captions as decoration.** Strings like `Field study no. 12 · Ines Caetano`, `Plate 03 · House archive`, `Frame XII · 35mm` under stock/picsum images are pretentious. Photo credit is allowed ONLY when there is a real photographer being credited for a real photo (with permission). Otherwise: skip the caption or use a one-line functional caption ("The 6-quart, in Sage."). -* **NO version footers on marketing pages.** Footer strings like `v1.4.2`, `Build 0048`, `last sync 4s ago · main` are CLI / devtool fixtures, not landing-page content. Banned on marketing/landing/portfolio pages. -* **NO "Reservation 412 of 800"-style live-stock counters** as decoration. Only if the brief is explicitly a limited-run waitlist with real data. - -**Decoration text strips** -* **NO decoration text strip at hero bottom.** Patterns like `BRAND. MOTION. SPATIAL.`, `TYPE / FORM / MOTION`, `DESIGN · BUILD · SHIP`, `ESTD. 2018 · LISBON · BRAND. MOTION. SPATIAL.` as a small mono-caps strip across the bottom of the hero are an agency-portfolio cliché. Banned by default. Only acceptable when the strip carries real, navigable links (sticky bottom nav) or real status info (cookie banner, build info on a docs site). -* **NO floating top-right sub-text in section headings.** Pattern: section has a giant left-aligned headline; in the top-right corner of the same section header there is a small explainer paragraph floating with no clear alignment to anything else. That floater is the Tell. Either put the sub-text directly under the headline, or build a clean 2-column header (left: headline, right: aligned body), but not a tiny corner paragraph. - -**Lists, dividers and scoring** -* **NO `border-t` + `border-b` on every row of a long list / spec table.** Pick one (bottom-border between rows OR top-border above the group) and use it sparsely. A 10-row spec table with hairlines under each row is the laziest layout - see Section 4.9 for alternative UI components. -* **NO scoring/progress bars with filled background tracks** as comparison visuals. If you need to show "X out of Y" comparisons, prefer a number + small icon, or a tiny inline bar WITHOUT a background track. Big filled `bg-zinc-200` tracks with a partial fill on top are dashboard-UI clutter on a landing page. - -**Locale, time, scroll cues** -* **Locale / city-name / time / weather strips are banned for 99% of briefs.** "Lisbon, working with founders" in the hero, "1200-690 Lisbon, Portugal" in the footer, "Lisbon 14:23 · 18°C" in the nav. These are agency-portfolio decoration tells. Allowed ONLY when: the brief explicitly describes a globally-distributed studio with timezone-relevant work, OR a travel-focused brand, OR a real-world physical venue. A single contact-address mention in the footer is fine; an atmospheric locale strip is not. -* **Scroll cues are banned.** `Scroll`, `↓ scroll`, `Scroll to explore`, `Scroll to walk through it`, animated mouse-wheel icons. If the user has not scrolled yet, they are looking at the hero. They know what scroll is. The bottom of the viewport does not need a label. -* **ZERO decorative status dots by default.** A coloured dot before nav items, before list rows, before badges, before status labels is a Tell. Only acceptable when conveying real semantic state (a live indicator on actual server status, a live availability flag) and limited to one per page section. - -### 9.G EM-DASH BAN (the single most-violated Tell) - -**Em-dash (`—`) is COMPLETELY banned.** It is the LLM's signature stylistic crutch and it is the #1 visual Tell in production tests. There is no "limited use" allowance, no "natural language frequency" allowance, no "in body copy is fine" allowance. None. - -* **Banned in headlines.** Use a period or a comma. -* **Banned in eyebrows / labels / pills / button text / image captions / nav items.** Replace with line breaks, columns, or hairlines. -* **Banned in body copy.** Restructure the sentence: two sentences with a period, OR a comma, OR parentheses, OR a colon. -* **Banned in quote attribution.** Use a normal hyphen with spaces (` - `) or a line break + smaller-weight name. -* **Banned in en-dash form too (`–`) when used as a separator.** Date ranges (`2018-2026`) use a hyphen. Number ranges (`€40-80k`) use a hyphen. - -The ONLY permitted dash characters on the page are: -* Regular hyphen `-` (for compound words, ranges, line dividers in markup) -* Minus sign in math (`-5°C`) - -If your output contains a single `—` or `–` anywhere visible to the user, the output fails the Pre-Flight Check and must be rewritten. - -This rule is non-negotiable. The agent has historically ignored em-dash limits when phrased as "use sparingly." The phrasing here is binary: zero em-dashes. - ---- - -## 10. REFERENCE VOCABULARY (Pattern Names the Agent Should Know) - -This is a vocabulary, not a library. The agent should KNOW these pattern names to communicate about them, design with them in mind, and reach for them when the design read calls for them. **Implementations and code sketches live in the Block Library (Section 12), which is populated iteratively.** - -### Hero Paradigms -* **Asymmetric Split Hero** - Text on one side, asset on the other, generous white space. -* **Editorial Manifesto Hero** - Large type, no asset, almost-poster. -* **Video / Media Mask Hero** - Type cut out as mask over video background. -* **Kinetic-Type Hero** - Animated typography as the primary visual. -* **Curtain-Reveal Hero** - Hero parts on scroll like a curtain. -* **Scroll-Pinned Hero** - Hero stays pinned while content scrolls behind. - -### Navigation & Menus -* **Mac OS Dock Magnification** - Edge nav, icons scale fluidly on hover. -* **Magnetic Button** - Pulls toward cursor. -* **Gooey Menu** - Sub-items detach like viscous liquid. -* **Dynamic Island** - Morphing pill for status / alerts. -* **Contextual Radial Menu** - Circular menu expanding at click point. -* **Floating Speed Dial** - FAB springing into curved secondary actions. -* **Mega Menu Reveal** - Full-screen dropdown, stagger-fade content. - -### Layout & Grids -* **Bento Grid** - Asymmetric tile grouping (Apple Control Center). -* **Masonry Layout** - Staggered grid, no fixed row height. -* **Chroma Grid** - Borders / tiles with subtle animating gradients. -* **Split-Screen Scroll** - Two halves sliding in opposite directions. -* **Sticky-Stack Sections** - Sections that pin and stack on scroll. - -### Cards & Containers -* **Parallax Tilt Card** - 3D tilt tracking mouse coordinates. -* **Spotlight Border Card** - Borders illuminate under cursor. -* **Glassmorphism Panel** - Frosted glass with inner refraction. -* **Holographic Foil Card** - Iridescent rainbow shift on hover. -* **Tinder Swipe Stack** - Physical card stack, swipe-away. -* **Morphing Modal** - Button expands into its own dialog. - -### Scroll Animations -* **Sticky Scroll Stack** - Cards stick and physically stack. -* **Horizontal Scroll Hijack** - Vertical scroll → horizontal pan. -* **Locomotive / Sequence Scroll** - Video / 3D sequence tied to scrollbar. -* **Zoom Parallax** - Central background image zooming on scroll. -* **Scroll Progress Path** - SVG line drawing along scroll. -* **Liquid Swipe Transition** - Page transition like viscous liquid. - -### Galleries & Media -* **Dome Gallery** - 3D panoramic gallery. -* **Coverflow Carousel** - 3D carousel with angled edges. -* **Drag-to-Pan Grid** - Boundless draggable canvas. -* **Accordion Image Slider** - Narrow strips expanding on hover. -* **Hover Image Trail** - Mouse leaves popping image trail. -* **Glitch Effect Image** - RGB-channel shift on hover. - -### Typography & Text -* **Kinetic Marquee** - Endless text bands reversing on scroll. -* **Text Mask Reveal** - Massive type as transparent window to video. -* **Text Scramble Effect** - Matrix-style decoding on load / hover. -* **Circular Text Path** - Text curving along spinning circle. -* **Gradient Stroke Animation** - Outlined text with running gradient. -* **Kinetic Typography Grid** - Letters dodging the cursor. - -### Micro-Interactions & Effects -* **Particle Explosion Button** - CTA shatters into particles on success. -* **Liquid Pull-to-Refresh** - Reload indicator like detaching droplets. -* **Skeleton Shimmer** - Shifting light reflection across placeholders. -* **Directional Hover-Aware Button** - Fill enters from cursor's exact side. -* **Ripple Click Effect** - Wave from click coordinates. -* **Animated SVG Line Drawing** - Vectors drawing themselves in real time. -* **Mesh Gradient Background** - Organic lava-lamp blobs. -* **Lens Blur Depth** - Background UI blurred to focus foreground action. - -### Animation Library Choice -* **Motion (`motion/react`)** - default for UI / Bento / state-change motion. -* **GSAP + ScrollTrigger** - for full-page scrolltelling and scroll hijacks. Isolate in dedicated leaf components with `useEffect` cleanup. -* **Three.js / WebGL** - for canvas backgrounds and 3D scenes. Same isolation rule. -* **NEVER mix GSAP / Three.js with Motion in the same component tree.** They fight over the same frames. - ---- - -## 11. REDESIGN PROTOCOL - -This skill handles **greenfield builds AND redesigns**. Misclassifying the mode is the single biggest source of bad redesign output. - -### 11.A Detect the Mode (first action) -* **Greenfield** - no existing site, or full overhaul approved. Dial baseline from Section 1. -* **Redesign - Preserve** - modernise without breaking the brand. Audit first, extract brand tokens, evolve gradually. -* **Redesign - Overhaul** - new visual language on top of existing content. Treat as greenfield for visuals; preserve content and IA. - -If ambiguous, ask **once**: *"Should this redesign preserve the existing brand, or are we starting visually from scratch?"* - -### 11.B Audit Before Touching -Document the current state before proposing changes: -* **Brand tokens** - primary / accent colors, type stack, logo treatment, radii. -* **Information architecture** - page tree, primary nav, key conversion paths. -* **Content blocks** - what exists, what's doing work, what's filler. -* **Patterns to preserve** - signature interactions, recognisable hero, copy voice. -* **Patterns to retire** - AI-slop tells, broken layouts, dead links, generic stock imagery, perf traps. -* **Dial reading of the existing site** - infer current `DESIGN_VARIANCE` / `MOTION_INTENSITY` / `VISUAL_DENSITY`. That's your starting point, not the baseline. -* **SEO baseline** - current ranking pages, meta titles, structured data, OG cards. **SEO migration is the #1 redesign risk.** - -### 11.C Preservation Rules -* **Do not change information architecture** unless asked. Keep page slugs, anchor IDs, primary nav labels stable for SEO and muscle memory. -* **Extract brand colors before applying Section 4.2.** A brand that is already purple stays purple - apply the LILA RULE's override. -* **Preserve copy voice** unless asked for a rewrite. Visual modernisation ≠ content rewrite. -* **Honor existing accessibility wins.** Do not regress focus states, alt text, keyboard nav, contrast. -* **Respect existing analytics events.** Do not rename buttons, form fields, section IDs that downstream tracking depends on. - -### 11.D Modernisation Levers (priority order) -Apply in order - stop when the brief is satisfied: -1. **Typography refresh** - biggest visual lift per unit of risk. -2. **Spacing & rhythm** - increase section padding, fix vertical rhythm. -3. **Color recalibration** - desaturate, unify neutrals, keep brand accent. -4. **Motion layer** - add `MOTION_INTENSITY`-appropriate micro-interactions to existing components. -5. **Hero & key-section recomposition** - restructure top-of-funnel using Section 10 vocabulary. -6. **Full block replacement** - only when the existing block is unsalvageable. - -### 11.E Decision Tree: Targeted Evolution vs Full Redesign -* IA, content, and SEO sound → **targeted evolution** (Levers 1-4). ~70% of value at ~40% of risk. -* Visual debt is structural (broken IA, no design system, broken mobile) → **full redesign** with strict content preservation. -* Brand itself is changing → **greenfield**. - -### 11.F What Never Changes Silently -Never modify without explicit user approval: -* URL structure / route slugs. -* Primary nav labels. -* Form field names or order (breaks analytics + autofill). -* Brand logo or wordmark. -* Existing legal / consent / cookie copy. - ---- - -## 12. THE BLOCK LIBRARY (Contract - Implementations Land Here Iteratively) - -The Reference Vocabulary (Section 10) names patterns. The Block Library implements them with real props, real motion specs, and real code sketches. - -**Status:** schema defined here. Blocks will be added iteratively. Do not freelance new blocks without following this schema. - -### 12.A File Location -``` -skills/taste-skill/blocks/ - hero/ - asymmetric-split.md - editorial-manifesto.md - kinetic-type.md - ... - feature/ - bento-grid.md - sticky-scroll-stack.md - zig-zag.md - ... - social-proof/ - pricing/ - cta/ - footer/ - navigation/ - portfolio/ - transition/ -``` - -### 12.B Required Frontmatter -```yaml ---- -name: asymmetric-split-hero -category: hero -dial_compatibility: - variance: [6, 10] - motion: [3, 10] - density: [2, 5] -when_to_use: "Landing pages with one strong asset and one strong message. Default hero for SaaS, agency, premium consumer." -not_for: "Editorial / manifesto launches where the message IS the design." -stack: ["react", "next", "tailwind", "motion"] ---- -``` - -### 12.C Required Body Sections -1. **Visual sketch** - short ASCII or description of the layout. -2. **Props API** - the component's interface. -3. **Code sketch** - minimal working implementation (Server Component default, Client island for motion). -4. **Mobile fallback** - explicit collapse rules for `< 768px`. -5. **Motion variants** - one variant per `MOTION_INTENSITY` band (1-3, 4-7, 8-10). Reduced-motion fallback explicit. -6. **Dark-mode notes** - token strategy specific to this block. -7. **Anti-patterns** - common ways this block goes wrong. -8. **References** - links to real examples in production. - -### 12.D Block-Library Discipline -* One block per file. No multi-block files. -* Every block must work standalone (drop it into a page, it renders). -* Every block must pass the Pre-Flight Check (Section 14). -* Blocks that depend on a design system from Section 2.A live under `blocks//--.md` (e.g. `feature/bento-grid--material.md`). - ---- - -## 13. OUT OF SCOPE - -This skill is NOT for: -* Dashboards / dense product UI / admin panels (use Fluent, Carbon, Atlassian, or Polaris from Section 2.A). -* Data tables (use TanStack Table or AG Grid). -* Multi-step forms / wizards (use Form-specific patterns; this skill won't make them better). -* Code editors (use Monaco / CodeMirror with their official skinning). -* Native mobile (use Apple HIG / Material directly). -* Realtime collab UIs (presence, cursors, OT-aware - different problem class). - -If the brief is one of the above, **say so explicitly**, point to the right tool, and only apply this skill's marketing-page / about-page / landing-page parts to the surfaces where they apply. - ---- - -## 14. FINAL PRE-FLIGHT CHECK - -Run this matrix before outputting code. This is the last filter. - -**THIS IS NOT OPTIONAL. Run every box. If any box fails, the output is not done.** - -- [ ] **Brief inference** declared (Section 0.B one-liner)? -- [ ] **Dial values** explicit and reasoned from the brief, not silently using baseline? -- [ ] **Design system** chosen from Section 2 if applicable, or aesthetic labeled honestly? -- [ ] **Redesign mode** detected and audit performed (if applicable, Section 11)? -- [ ] **ZERO em-dashes (`—`) anywhere on the page.** Headlines, eyebrows, pills, body, quotes, attribution, captions, buttons, alt text. Zero. (Section 9.G - non-negotiable.) -- [ ] **Page Theme Lock**: ONE theme (light, dark, or auto) for the whole page. No section flips to inverted mode mid-page (Section 4.11)? -- [ ] **Color Consistency Lock**: one accent color used identically across all sections (Section 4.2)? -- [ ] **Shape Consistency Lock**: one corner-radius system applied consistently (Section 4.4)? -- [ ] **Button Contrast Check**: every CTA text is readable against its background (no white-on-white, WCAG AA 4.5:1)? -- [ ] **CTA Button Wrap**: no CTA label wraps to 2+ lines at desktop? -- [ ] **Form Contrast Check**: form inputs, placeholders, focus rings, labels all pass WCAG AA against the section background? -- [ ] **Serif discipline**: if a serif is used, it is NOT Fraunces or Instrument_Serif (or it is, with explicit brand justification)? Different serif from your previous project? -- [ ] **Premium-consumer palette check**: if the brief is premium-consumer (cookware / wellness / artisan / luxury), the palette is NOT the AI-default beige+brass+oxblood+espresso family? Different family from your previous premium-consumer project? -- [ ] **Italic descender clearance**: every italic word with `y g j p q` has `leading-[1.1]` min + `pb-1` reserve? -- [ ] **Hero fits the viewport**: headline ≤ 2 lines, subtext ≤ 20 words AND ≤ 4 lines, CTA visible without scroll, font scale planned around image? -- [ ] **Hero top padding**: max `pt-24` at desktop, hero content does not float halfway down the viewport? -- [ ] **Hero stack discipline**: max 4 text elements in hero (eyebrow OR brand strip, headline, subtext, CTAs)? No tiny tagline below CTAs, no trust micro-strip in hero? -- [ ] **EYEBROW COUNT (mechanical)**: count instances of `uppercase tracking` micro-labels above section headlines across all components. Count ≤ ceil(sectionCount / 3)? Hero counts as 1. -- [ ] **Split-Header Ban**: no "left big headline + right small explainer paragraph" pattern as a section header (vertical stack instead)? -- [ ] **Zigzag Alternation Cap**: no 3+ consecutive sections with the same image+text-split layout? -- [ ] **No Duplicate CTA Intent**: no two CTAs with the same intent ("Get in touch" + "Let's talk" both on page = Fail)? -- [ ] **Logo wall = logo only**: no industry / category labels printed below logos? -- [ ] **Bento Background Diversity**: at least 2-3 bento cells have real visual variation (image, gradient, pattern), not all white-on-white text cards? -- [ ] **"Used by / Trusted by" logo wall** lives UNDER the hero, not inside it, uses REAL SVG logos (Simple Icons / devicon) or generated SVG marks, NOT plain text wordmarks? -- [ ] **Copy Self-Audit**: every visible string re-read, no grammatically-broken or AI-hallucinated phrases ("free on its past" type) shipped? -- [ ] **Motion motivated**: every animation can be justified in one sentence (hierarchy / storytelling / feedback / state transition), no GSAP-for-show? -- [ ] **Marquee max-one-per-page**: no two horizontal marquees on the same page? -- [ ] **Navigation on ONE line** at desktop, height ≤ 80px? -- [ ] **Section-Layout-Repetition** check: no two sections share the same layout family (at least 4 different families across 8 sections)? -- [ ] **Bento has rhythm AND exact cell count** (N items → N cells, no empty cells in middle or at end)? -- [ ] **Long lists use the right UI component** (not default `
      ` with `divide-y` for > 5 items - see Section 4.9 alternatives)? -- [ ] **Real images used** (gen-tool first, then Picsum-seed, then explicit placeholder slots) - NO div-based fake screenshots, NO hand-rolled decorative SVGs, NO pure-text minimalism? -- [ ] **No pills/labels overlaid on images** (no `Plate · Brand`, no `Field notes - journal`)? -- [ ] **No photo-credit captions as decoration** (`Field study no. 12 · Ines Caetano`)? -- [ ] **No version footers** (`v1.4.2`, `Build 0048`) on marketing pages? -- [ ] **No micro-meta-sentences** under eyebrows ("Each of these is a feature we ship today...")? -- [ ] **No decoration text strip at hero bottom** (`BRAND. MOTION. SPATIAL.`)? -- [ ] **No floating top-right sub-text** in section headings? -- [ ] **No scoring/progress bars with filled background tracks** as comparison visuals? -- [ ] **No locale / city-name / time / weather strips** unless brief is genuinely globally-distributed or place-focused? -- [ ] **No scroll cues** (`Scroll`, `↓ scroll`, `Scroll to explore`)? -- [ ] **No version labels in hero** (V0.6, BETA, INVITE-ONLY) unless the brief is a launch? -- [ ] **No section-numbering eyebrows** (`00 / INDEX`, `001 · Capabilities`, `06 · how it works`)? -- [ ] **No decorative dots** (zero by default, only for real semantic state)? -- [ ] **No `border-t` + `border-b` on every row** of long lists / spec tables? -- [ ] **Content density** sane: no 20-row data tables, no fake-precise specs without justification, ≤ 25-word sub-paragraphs by default? -- [ ] **Quotes ≤ 3 lines** of body, attribution clean (no em-dash)? -- [ ] **Motion claimed = motion shown**: if `MOTION_INTENSITY > 4`, page actually animates, not just claimed? -- [ ] **GSAP sticky-stack / horizontal-pan** implemented per Section 5.A / 5.B canonical skeleton (`start: "top top"`, `pin: true`, correct scrub)? -- [ ] **No `window.addEventListener('scroll')`** - using Motion `useScroll()` / ScrollTrigger / IntersectionObserver / CSS scroll-driven animations only? -- [ ] **Reduced motion** wrapped for everything `MOTION_INTENSITY > 3`? -- [ ] **Dark mode** tokens defined and tested in both modes? -- [ ] **Mobile collapse** explicit (`w-full`, `px-4`, `max-w-7xl mx-auto`) for high-variance layouts? -- [ ] **Viewport stability**: `min-h-[100dvh]`, never `h-screen`? -- [ ] **`useEffect` animations** have strict cleanup functions? -- [ ] **Empty / loading / error** states provided? -- [ ] **Cards omitted** in favor of spacing where possible? -- [ ] **Icons** from an allowed library only (Phosphor / HugeIcons / Radix / Tabler), no hand-rolled SVG paths? -- [ ] **Motion** isolated in client-leaf components with `'use client'` at the top, memoized? -- [ ] **No AI Tells** from Section 9 (Inter as default, AI-purple, three-equal cards, Jane Doe, Acme, "Quietly in use at")? -- [ ] **Core Web Vitals** plausibly hit (LCP < 2.5s, INP < 200ms, CLS < 0.1)? -- [ ] **One design system** per project (no Material + shadcn mixed)? - -If a single checkbox cannot be honestly ticked, the page is not done. Fix it before delivering. - ---- - -# APPENDICES - Real Source-Backed Reference Material - -The sections below are vendored reference content. They give the agent real install commands, real canonical doc links, and real working starter snippets for each design system named in Section 2. Use them to ground decisions in production reality, not training-data fiction. - -## Appendix A - Install Commands per Design System - -```bash -# Material Web (Material 3) -npm install @material/web - -# Fluent UI React (v9) -npm install @fluentui/react-components - -# Fluent UI Web Components (framework-free) -npm install @fluentui/web-components @fluentui/tokens - -# IBM Carbon -npm install @carbon/react @carbon/styles - -# Radix Themes -npm install @radix-ui/themes - -# shadcn/ui (open code, owned components) -npx shadcn@latest init -npx shadcn@latest add button card badge separator input - -# Primer CSS (GitHub product/devtool UI) -npm install --save @primer/css - -# Primer Brand (GitHub marketing UI) -npm install @primer/react-brand - -# GOV.UK Frontend -npm install govuk-frontend - -# USWDS (US Web Design System) -npm install uswds - -# Atlassian Design System (Atlaskit) -yarn add @atlaskit/css-reset @atlaskit/tokens @atlaskit/button @atlaskit/badge @atlaskit/section-message @atlaskit/card - -# Bootstrap 5.3 -npm install bootstrap - -# Shopify Polaris Web Components (Shopify apps only) -# Add this to your app HTML head: -# -# -``` - -## Appendix B - Canonical Sources (read these before reinventing) - -### Material Web -- https://github.com/material-components/material-web -- https://material-web.dev/theming/material-theming/ -- https://m3.material.io/develop/web - -### Fluent UI -- https://fluent2.microsoft.design/get-started/develop -- https://fluent2.microsoft.design/components/web/react/ -- https://github.com/microsoft/fluentui -- https://learn.microsoft.com/en-us/fluent-ui/web-components/ - -### Carbon -- https://carbondesignsystem.com/ -- https://github.com/carbon-design-system/carbon -- https://carbondesignsystem.com/developing/react-tutorial/overview/ -- https://carbondesignsystem.com/developing/web-components-tutorial/overview/ - -### Shopify Polaris -- https://shopify.dev/docs/api/app-home/web-components -- https://github.com/Shopify/polaris-react -- https://polaris-react.shopify.com/components - -### Atlassian -- https://atlassian.design/get-started/develop -- https://atlassian.design/components/button/examples -- https://atlaskit.atlassian.com/packages/design-system/button/example/disabled -- https://atlassian.design/tokens/design-tokens - -### Primer -- https://primer.style/ -- https://github.com/primer/css -- https://github.com/primer/brand - -### GOV.UK -- https://design-system.service.gov.uk/components/button/ -- https://design-system.service.gov.uk/styles/layout/ -- https://github.com/alphagov/govuk-frontend - -### USWDS -- https://designsystem.digital.gov/documentation/developers/ -- https://designsystem.digital.gov/components/button/ -- https://designsystem.digital.gov/components/card/ -- https://github.com/uswds/uswds - -### Bootstrap -- https://getbootstrap.com/docs/5.3/layout/grid/ -- https://getbootstrap.com/docs/5.3/components/card/ - -### Tailwind -- https://tailwindcss.com/docs/dark-mode -- https://tailwindcss.com/blog/tailwindcss-v4 - -### Radix -- https://www.radix-ui.com/themes/docs/components/theme -- https://www.radix-ui.com/themes/docs/components/card -- https://github.com/radix-ui/themes - -### shadcn/ui -- https://ui.shadcn.com/docs -- https://ui.shadcn.com/docs/components/card -- https://github.com/shadcn-ui/ui - -### Native CSS / W3C standards -- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/backdrop-filter -- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme -- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion -- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout -- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations -- https://drafts.csswg.org/scroll-animations-1/ - -### Apple Liquid Glass (Apple platforms only) -- https://developer.apple.com/design/human-interface-guidelines/materials -- https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass -- https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass -- https://developer.apple.com/documentation/SwiftUI/Material - ---- - -## Appendix C - Apple Liquid Glass: Honest Web Approximation - -Do **not** treat random CSS snippets as official Apple Liquid Glass. - -### What is official -Apple documents Liquid Glass inside Apple's Human Interface Guidelines and Developer Documentation for **Apple platforms**. It is a dynamic material used across Apple platform UI. Apple's native implementation belongs to Apple platform APIs and system components, **not a public web CSS package**. - -Relevant official docs: -- Apple Human Interface Guidelines → Materials -- Apple Developer Documentation → Liquid Glass -- Apple Developer Documentation → Adopting Liquid Glass -- SwiftUI → Material - -### What is NOT official -There is no `liquid-glass.css` from Apple for normal websites. - -A web approximation can use: -- `backdrop-filter` -- transparent backgrounds -- layered borders -- highlight overlays -- gradients -- motion -- strong contrast fallbacks - -But that is **web glassmorphism / frosted-glass approximation**, not official Apple Liquid Glass. Label it as such in comments. - -### Safer web approximation skeleton - -```css -.liquid-glass-web-approx { - position: relative; - isolation: isolate; - overflow: hidden; - border-radius: 999px; - border: 1px solid rgb(255 255 255 / .32); - background: - linear-gradient(135deg, rgb(255 255 255 / .30), rgb(255 255 255 / .08)), - rgb(255 255 255 / .12); - backdrop-filter: blur(24px) saturate(180%) contrast(1.05); - -webkit-backdrop-filter: blur(24px) saturate(180%) contrast(1.05); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / .48), - inset 0 -1px 0 rgb(255 255 255 / .12), - 0 18px 60px rgb(0 0 0 / .18); -} - -.liquid-glass-web-approx::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - background: - radial-gradient(circle at 20% 0%, rgb(255 255 255 / .55), transparent 34%), - linear-gradient(90deg, rgb(255 255 255 / .18), transparent 42%, rgb(255 255 255 / .14)); - pointer-events: none; -} - -.liquid-glass-web-approx::after { - content: ""; - position: absolute; - inset: 1px; - border-radius: inherit; - border: 1px solid rgb(255 255 255 / .14); - pointer-events: none; -} - -@media (prefers-color-scheme: dark) { - .liquid-glass-web-approx { - border-color: rgb(255 255 255 / .18); - background: - linear-gradient(135deg, rgb(255 255 255 / .16), rgb(255 255 255 / .04)), - rgb(15 23 42 / .42); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / .22), - 0 18px 60px rgb(0 0 0 / .42); - } -} - -@media (prefers-reduced-transparency: reduce) { - .liquid-glass-web-approx { - background: rgb(255 255 255 / .96); - backdrop-filter: none; - -webkit-backdrop-filter: none; - } -} -``` - -**Important:** `prefers-reduced-transparency` has uneven browser support; test it. Always provide enough contrast even without blur. - ---- - -**End of appendices.** Install commands above are reality anchors. The Apple Liquid Glass skeleton is a labeled approximation, not an Apple-issued package. For canonical docs per design system, consult the system's official docs (links in Section 2 plus Appendix B). diff --git a/.claude/commands/emcn-design-review.md b/.claude/commands/emcn-design-review.md deleted file mode 100644 index 1a5c562facd..00000000000 --- a/.claude/commands/emcn-design-review.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -description: Review UI code for alignment with the emcn design system — components, tokens, patterns, and conventions -argument-hint: "[scope] [fix=true|false]" ---- - -# EMCN Design Review - -Arguments: -- scope: what to review (default: your current changes). Examples: "diff to main", "PR #123", "src/components/", "whole codebase" -- fix: whether to apply fixes (default: true). Set to false to only propose changes. - -User arguments: $ARGUMENTS - -## Context - -This codebase uses **emcn**, a custom component library built on Radix UI primitives with CVA variants and CSS variable design tokens. All UI must use emcn components and tokens. - -## Steps - -1. Read the emcn public barrel at `packages/emcn/src/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `packages/emcn/src/icons/index.ts` -2. Read `apps/sim/app/_styles/globals.css` for CSS variable tokens -3. Analyze the specified scope against every rule below -4. If fix=true, apply the fixes. If fix=false, propose the fixes without applying. - ---- - -## Imports - -- Import from `@/components/emcn` barrel, never subpaths -- Icons from `@sim/emcn/icons` -- Use `cn` from `@/lib/core/utils/cn` for conditional classes - -## Design Tokens - -Use CSS variable pattern (`text-[var(--text-primary)]`), never Tailwind semantics (`text-muted-foreground`) or hardcoded colors (`text-gray-500`, `#333`). - -**Text**: `--text-primary`, `--text-secondary`, `--text-tertiary`, `--text-muted`, `--text-body` (canonical value text), `--text-icon`, `--text-placeholder`, `--text-subtle`, `--text-inverse`, `--text-error` -**Surfaces**: `--bg`, `--surface-1` through `--surface-7`, `--surface-hover`, `--surface-active` -**Borders**: `--border`, `--border-1`, `--border-muted` -**Brand/accent**: `--brand-secondary`, `--brand-accent` -**Z-Index**: `--z-dropdown` (100), `--z-modal` (200), `--z-popover` (300), `--z-tooltip` (400), `--z-toast` (500) -**Shadows**: `shadow-subtle`, `shadow-medium`, `shadow-overlay`, `shadow-card` -**Badges**: `--badge-*` semantic families (success/error/gray/blue/purple/orange/amber/teal/cyan/pink, each with `-bg`/`-text`) - -## Buttons - -Intent-to-variant mapping (read the actual `buttonVariants` in `packages/emcn/src/components/button/button.tsx` for the full variant set — it exposes more than listed here): - -| Action | Variant | -|--------|---------| -| Toolbar, icon-only | `ghost` | -| Create, save, submit | `primary` | -| Cancel, close | `default` | -| Delete, remove | `destructive` | -| Selected state | `active` | -| Toggle | `outline` | - -## Delete/Remove Confirmations - -`ChipModal` `size='sm'`, title "Delete/Remove {ItemType}", destructive confirm button, plain Cancel (follow the chip footer layout in `.claude/rules/emcn-components.md`). Use `text-[var(--text-error)]` for irreversible warnings. - -## Toast - -`toast.success()`, `toast.error()`, `toast()` from `@/components/emcn`. Never custom notification UI. - -## Badges - -`red`=error/failed, `gray-secondary`=metadata/roles, `type`=type annotations, `green`=success/active, `gray`=neutral, `amber`=processing, `orange`=paused, `blue`=info. Use `dot` prop for status indicators. - -## Icons - -Default: `size-[14px]`. Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. Use the `size-*` shorthand — flag `h-[Npx] w-[Npx]` and `h-N w-N` pairs as refactor targets. - -## Anti-patterns to flag - -- Raw `
    - {(allConnected || enrollment.status === 'completed') && ( -
    - - {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} - -
    - )} +
    + + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
) diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx index 8207e2c3acf..4e473134b6b 100644 --- a/apps/sim/app/desktop/connect/page.tsx +++ b/apps/sim/app/desktop/connect/page.tsx @@ -50,8 +50,16 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec const port = parseLoopbackPort(typeof params.port === 'string' ? params.port : '') const workspaceId = isValidOpaqueId(params.workspaceId) ? params.workspaceId : undefined const credentialId = isValidOpaqueId(params.credentialId) ? params.credentialId : undefined + const draftId = isValidOpaqueId(params.draftId) ? params.draftId : undefined const expectedUserId = isValidOpaqueId(params.user) ? params.user : undefined - if (!isValidOAuthProviderId(providerId) || !isValidHandoffState(state) || port === null) { + const hasInvalidDraftId = params.draftId !== undefined && draftId === undefined + if ( + !isValidOAuthProviderId(providerId) || + !isValidHandoffState(state) || + port === null || + hasInvalidDraftId || + (workspaceId !== undefined && draftId !== undefined) + ) { return } @@ -65,6 +73,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec buildDesktopConnectPath(providerId, state, port, { workspaceId, credentialId, + draftId, user: expectedUserId, }) )}` @@ -86,6 +95,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec returnTo={buildDesktopConnectPath(providerId, state, port, { workspaceId, credentialId, + draftId, user: expectedUserId, })} /> @@ -113,6 +123,9 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec } return ( - + ) } diff --git a/apps/sim/app/desktop/connect/validation.test.ts b/apps/sim/app/desktop/connect/validation.test.ts index 161742ce325..13bdbda1bc9 100644 --- a/apps/sim/app/desktop/connect/validation.test.ts +++ b/apps/sim/app/desktop/connect/validation.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { buildConnectCompletePath, buildConnectLoopbackUrl, @@ -36,19 +37,23 @@ describe('sanitizeOAuthErrorSlug', () => { describe('URL builders', () => { it('buildDesktopConnectPath round-trips provider, state, and port', () => { - const path = buildDesktopConnectPath('google-email', STATE, 49152) + const path = buildDesktopConnectPath('google-email', STATE, 49152, { + draftId: 'draft-1', + }) const url = new URL(path, 'https://sim.ai') expect(url.pathname).toBe('/desktop/connect') expect(url.searchParams.get('provider')).toBe('google-email') expect(url.searchParams.get('state')).toBe(STATE) expect(url.searchParams.get('port')).toBe('49152') + expect(url.searchParams.get('draftId')).toBe('draft-1') }) - it('buildConnectCompletePath carries state and port', () => { - const url = new URL(buildConnectCompletePath(STATE, 49152), 'https://sim.ai') + it('buildConnectCompletePath carries state, port, and the exact credential draft', () => { + const url = new URL(buildConnectCompletePath(STATE, 49152, 'draft-1'), 'https://sim.ai') expect(url.pathname).toBe('/desktop/connect/complete') expect(url.searchParams.get('state')).toBe(STATE) expect(url.searchParams.get('port')).toBe('49152') + expect(url.searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM)).toBe('draft-1') }) it('buildConnectLoopbackUrl targets the 127.0.0.1 connect callback, error optional', () => { diff --git a/apps/sim/app/desktop/connect/validation.ts b/apps/sim/app/desktop/connect/validation.ts index e45d8a6d2f3..4a70467707e 100644 --- a/apps/sim/app/desktop/connect/validation.ts +++ b/apps/sim/app/desktop/connect/validation.ts @@ -1,3 +1,5 @@ +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' + /** * OAuth providerIds are kebab-case service slugs (e.g. "google-email"). The * value is only used to start a better-auth oauth2.link flow, which validates @@ -36,6 +38,7 @@ export function isValidOpaqueId(value: unknown): value is string { export interface ConnectScope { workspaceId?: string credentialId?: string + draftId?: string /** The account the desktop app is signed in as; the flow is pinned to it. */ user?: string } @@ -53,6 +56,7 @@ export function buildDesktopConnectPath( const params = new URLSearchParams({ provider: providerId, state, port: String(port) }) if (scope.workspaceId) params.set('workspaceId', scope.workspaceId) if (scope.credentialId) params.set('credentialId', scope.credentialId) + if (scope.draftId) params.set('draftId', scope.draftId) if (scope.user) params.set('user', scope.user) return `/desktop/connect?${params.toString()}` } @@ -61,8 +65,9 @@ export function buildDesktopConnectPath( * The same-origin path better-auth redirects the browser to after the OAuth * callback — the complete page then bounces to the desktop app's loopback. */ -export function buildConnectCompletePath(state: string, port: number): string { +export function buildConnectCompletePath(state: string, port: number, draftId?: string): string { const params = new URLSearchParams({ state, port: String(port) }) + if (draftId) params.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId) return `/desktop/connect/complete?${params.toString()}` } diff --git a/apps/sim/app/oauth/credential-connected/page.test.tsx b/apps/sim/app/oauth/credential-connected/page.test.tsx new file mode 100644 index 00000000000..2a137ea39b7 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CredentialConnectedPage from '@/app/oauth/credential-connected/page' + +describe('CredentialConnectedPage', () => { + it('confirms a successful connection', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Credential connected') + expect(markup).toContain('The credential is ready to use.') + }) + + it('does not claim success when the provider returns an error', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected', error: 'access_denied' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) + + it('does not claim success without an explicit success result', async () => { + const page = await CredentialConnectedPage({ searchParams: Promise.resolve({}) }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) +}) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx new file mode 100644 index 00000000000..72606f82511 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -0,0 +1,39 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { LogoShell } from '@/app/(landing)/components' + +export const metadata: Metadata = { + title: 'Credential connected', + robots: { index: false, follow: false }, +} + +interface CredentialConnectedPageProps { + searchParams: Promise> +} + +export default async function CredentialConnectedPage({ + searchParams, +}: CredentialConnectedPageProps) { + const params = await searchParams + const result = typeof params.result === 'string' ? params.result : undefined + const error = Array.isArray(params.error) ? params.error[0] : params.error + const connected = result === 'connected' && !error + + return ( + +
+

+ {connected ? 'Credential connected' : 'Connection failed'} +

+

+ {connected + ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' + : 'The credential could not be connected. Return to the app that started the connection and try again.'} +

+ + Open Sim + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index a5bd8cdf51a..c3624387ae1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -256,6 +256,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { setSubmitError(null) try { let connectorType: string | undefined + let draftId: string | undefined if (isConnect) { const trimmed = displayName.trim() @@ -264,12 +265,13 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { return } - await createDraft.mutateAsync({ + const draft = await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmed, description: description.trim() || undefined, }) + draftId = draft.draftId const preCount = credentials.filter( (c) => c.type === 'oauth' && c.providerId === providerId @@ -279,6 +281,15 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { displayName: trimmed, providerId, preCount, + baselineCredentials: credentials + .filter( + (credential) => credential.type === 'oauth' && credential.providerId === providerId + ) + .map((credential) => ({ + id: credential.id, + accountId: credential.accountId, + updatedAt: credential.updatedAt, + })), workspaceId, requestedAt: Date.now(), } @@ -319,6 +330,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { await connectOAuthService.mutateAsync({ providerId, callbackURL: callbackURL.toString(), + draftId, }) handleClose() } catch (err: unknown) { diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts new file mode 100644 index 00000000000..227c17308fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts @@ -0,0 +1,41 @@ +/** + * The row ids a drag carries, written to and read from `dataTransfer` as JSON under a + * private MIME type. + * + * The payload has to live on the event rather than only in component state: a drag survives + * the source row unmounting — spring-loading navigates away mid-drag — and it can be released + * over a different mount of the same page. + * + * Each surface passes its own MIME so a drag from one list is never mistaken for a drag from + * another, and so an unrelated OS drag is ignored outright. + */ + +/** Writes `rowIds` under `mime`, plus a plain-text fallback for drops outside the app. */ +export function writeRowDragPayload( + dataTransfer: DataTransfer, + mime: string, + rowIds: string[] +): void { + dataTransfer.setData(mime, JSON.stringify(rowIds)) + dataTransfer.setData('text/plain', rowIds.join(',')) +} + +/** + * Reads the row ids back, returning `null` when the payload is absent (a foreign drag) or + * malformed (another writer on the same MIME) rather than throwing mid-drop. Callers fall back + * to their in-memory source for drags that never round-tripped through `dataTransfer`. + */ +export function readRowDragPayload(dataTransfer: DataTransfer, mime: string): string[] | null { + const raw = dataTransfer.getData(mime) + if (!raw) return null + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return null + const rowIds = parsed.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + return rowIds.length > 0 ? rowIds : null + } catch { + return null + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 7a2b3d88f50..1ff57a3becb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -91,7 +91,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const trailing = options.trailing ?? NO_TRAILING_CRUMBS const items: BreadcrumbItem[] = [ - { label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) }, + { label: rootLabel, icon: rootIcon, folderId: null, onClick: () => onNavigate(null) }, ] breadcrumbs.forEach((folder, index) => { @@ -99,6 +99,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 items.push({ label: folder.name, + folderId: folder.id, onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), dropdownItems: isOpenFolder && options.currentFolderActions?.length diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 614786a1ed3..41b8682aef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -35,9 +35,8 @@ interface FolderContextMenuProps { * Row context menu for a folder, shared by the resource lists built on the generic folder * engine — Knowledge and Tables — so a folder offers the same actions on both. * - * Files is deliberately not a consumer: its rows carry multi-select and bulk actions, so a - * folder there routes through `FileRowContextMenu` alongside the file rows it is selected - * with. Converging the two is follow-up work. + * Files is deliberately not a consumer: a folder there routes through `FileRowContextMenu` + * alongside the file rows it is selected with. Converging the two is follow-up work. * * Mirrors the resource-row menus (`KnowledgeBaseContextMenu`, `FileRowContextMenu`): a * `DropdownMenu` anchored to a one-pixel fixed trigger at the cursor, non-modal so the list diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts index f4561a3adef..82042473d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts @@ -26,3 +26,23 @@ export function parseFolderedRowId(rowId: string): ParsedFolderedRowId { } return { kind: 'resource', id: rowId } } + +/** + * Splits a selection of foldered row ids into the two id lists every bulk operation takes. + * + * A foldered list holds folder rows and resource rows in one selection (see + * {@link folderRowId}), so every consumer needs this same split before it can call an API. + */ +export function splitFolderedRowIds(rowIds: Iterable): { + folderIds: string[] + resourceIds: string[] +} { + const folderIds: string[] = [] + const resourceIds: string[] = [] + for (const rowId of rowIds) { + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') folderIds.push(parsed.id) + else resourceIds.push(parsed.id) + } + return { folderIds, resourceIds } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts index 994dfaf5e49..68e6b3b218f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts @@ -11,10 +11,12 @@ import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components import { folderRowId, parseFolderedRowId, + splitFolderedRowIds, } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, } from '@/app/workspace/[workspaceId]/components/folders/move-options' @@ -331,3 +333,114 @@ describe('folderAncestorChain', () => { expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a']) }) }) + +describe('splitFolderedRowIds', () => { + it('separates folder rows from resource rows', () => { + const { folderIds, resourceIds } = splitFolderedRowIds([ + folderRowId('f-1'), + 'res-1', + folderRowId('f-2'), + 'res-2', + ]) + + expect(folderIds).toEqual(['f-1', 'f-2']) + expect(resourceIds).toEqual(['res-1', 'res-2']) + }) + + it('returns empty lists for an empty selection', () => { + expect(splitFolderedRowIds([])).toEqual({ folderIds: [], resourceIds: [] }) + }) + + it('accepts a Set, which is how a selection is actually held', () => { + const { folderIds, resourceIds } = splitFolderedRowIds(new Set([folderRowId('f-1'), 'res-1'])) + expect(folderIds).toEqual(['f-1']) + expect(resourceIds).toEqual(['res-1']) + }) +}) + +describe('buildMoveOptionsExcludingSubtrees', () => { + /** `a` holds `a1`, which holds `a1x`; `b` is an unrelated sibling. */ + const folders = [makeFolder('a'), makeFolder('a1', 'a'), makeFolder('a1x', 'a1'), makeFolder('b')] + const descendantsByFolderId = buildDescendantIndex(folders) + const valuesOf = (nodes: ReturnType): string[] => + nodes.flatMap((node) => [node.value, ...valuesOf(node.children)]) + + it('offers every folder when nothing is excluded', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: [], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a', 'a1', 'a1x', 'b']) + }) + + it('excludes a moving folder and its whole subtree, never offering a cycle', () => { + // The invariant this helper exists to hold: a folder can never be filed into itself or + // anything beneath it, at any depth. + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'b']) + }) + + it('excludes the union of several selected subtrees', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a1', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a']) + }) + + it('always keeps the workspace root as a destination', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE]) + }) +}) + +describe('folderBreadcrumbItems drag destinations', () => { + const chain = [makeFolder('a'), makeFolder('a1', 'a')] + + it('names the folder each crumb points at, so the header can accept a drop on it', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + }) + + expect(items.map((item) => item.folderId)).toEqual([null, 'a', 'a1']) + }) + + it('leaves a trailing crumb without a folder id, so it stays inert', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + trailing: [{ label: 'report.md', terminal: true }], + }) + + expect(items.at(-1)).toMatchObject({ label: 'report.md' }) + expect(items.at(-1)?.folderId).toBeUndefined() + }) + + it('gives the root crumb null rather than omitting it — the root is a real destination', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: [], + onNavigate: vi.fn(), + }) + + expect(items).toHaveLength(1) + expect(items[0]).toHaveProperty('folderId', null) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index aee06919742..20c6a275243 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -1,3 +1,4 @@ +export { readRowDragPayload, writeRowDragPayload } from './drag-payload' export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs' export { FolderContextMenu } from './folder-context-menu' @@ -5,16 +6,17 @@ export { nextUntitledFolderName } from './folder-naming' export type { FolderRowOptions } from './folder-row' export { folderRow } from './folder-row' export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' -export { folderRowId, parseFolderedRowId } from './folder-row-id' +export { folderRowId, parseFolderedRowId, splitFolderedRowIds } from './folder-row-id' export type { FolderedHeaderResourceType, FolderedResourceHeaderMeta, } from './foldered-resources' export { FOLDERED_RESOURCE_HEADERS, folderedResourceListHref } from './foldered-resources' -export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options' +export type { BuildMoveOptionsParams, MoveOptionFolder, MoveOptionNode } from './move-options' export { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, renderMoveOption, @@ -23,9 +25,20 @@ export { export type { SortableResource } from './resource-sort' export { sortResources } from './resource-sort' export { folderNavParsers, folderNavUrlKeys } from './search-params' +export { useDragTeardown } from './use-drag-teardown' export type { FolderAncestors, UseFolderAncestorsOptions } from './use-folder-ancestors' export { useFolderAncestors } from './use-folder-ancestors' export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation' export { useFolderNavigation } from './use-folder-navigation' -export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' +export type { FolderedRowMove, UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' export { useFolderRowDragDrop } from './use-folder-row-drag-drop' +export type { RowDragGhost } from './use-row-drag-ghost' +export { useRowDragGhost } from './use-row-drag-ghost' +export type { + SpringLoadedFolder, + SpringOpenOptions, + UseSpringLoadedFolderOptions, +} from './use-spring-loaded-folder' +export { SPRING_LOAD_DELAY_MS, useSpringLoadedFolder } from './use-spring-loaded-folder' +export type { SpringNavigation, UseSpringNavigationOptions } from './use-spring-navigation' +export { useSpringNavigation } from './use-spring-navigation' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx index 66f0b2e455a..865c50bca74 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -7,7 +7,6 @@ import { DropdownMenuSubTrigger, } from '@sim/emcn' import { Folder } from '@sim/emcn/icons' -import type { WorkflowFolder } from '@/stores/folders/types' export interface MoveOptionNode { value: string @@ -26,8 +25,16 @@ export function parseMoveOptionValue(optionValue: string): string | null { return optionValue === ROOT_MOVE_OPTION_VALUE ? null : optionValue } +/** The folder fields the move-option builders actually read, so any folder tree can use them. */ +export interface MoveOptionFolder { + id: string + name: string + parentId: string | null + sortOrder: number +} + export interface BuildMoveOptionsParams { - folders: WorkflowFolder[] + folders: readonly MoveOptionFolder[] rootLabel: string /** * Folder ids that must not appear as destinations — the folder being moved and every @@ -53,7 +60,7 @@ export function buildMoveOptions({ rootLabel, excludedFolderIds, }: BuildMoveOptionsParams): MoveOptionNode[] { - const childrenByParent = new Map() + const childrenByParent = new Map() for (const folder of folders) { if (excludedFolderIds?.has(folder.id)) continue const parentId = folder.parentId ?? null @@ -80,7 +87,9 @@ export function buildMoveOptions({ * candidate instead of re-walking the tree. `seen` terminates a cycle, which the DB permits * between constraint checks. */ -export function buildDescendantIndex(folders: WorkflowFolder[]): Map> { +export function buildDescendantIndex( + folders: readonly { id: string; parentId: string | null }[] +): Map> { const childrenByParent = new Map() for (const folder of folders) { if (!folder.parentId) continue @@ -167,3 +176,38 @@ export function renderMoveOptions( ) } + +/** + * Move destinations for a selection, with every selected folder and its subtree excluded — a + * folder cannot be filed into itself or anything beneath it. + * + * Shared because that exclusion is a correctness invariant, not a preference: hand-copying it + * per surface is how one list eventually offers a cyclic destination. Covers the single-folder + * case too — pass a one-element array. + * + * Expanding each selection to its descendants is deliberately belt-and-braces: {@link + * buildMoveOptions} descends from the root, so an excluded folder already takes its subtree out + * of the walk. The explicit expansion keeps the invariant true of the exclusion set itself, so + * it survives that walk ever being replaced by a flat render. + */ +export function buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel, + excludeFolderIds, + descendantsByFolderId, +}: { + folders: readonly MoveOptionFolder[] + rootLabel: string + excludeFolderIds: readonly string[] + descendantsByFolderId: Map> +}): MoveOptionNode[] { + if (excludeFolderIds.length === 0) return buildMoveOptions({ folders, rootLabel }) + + const excludedFolderIds = new Set(excludeFolderIds) + for (const folderId of excludeFolderIds) { + for (const descendantId of descendantsByFolderId.get(folderId) ?? []) { + excludedFolderIds.add(descendantId) + } + } + return buildMoveOptions({ folders, rootLabel, excludedFolderIds }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx new file mode 100644 index 00000000000..d107519d7d9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' + +const mountedRoots: Root[] = [] + +function renderDragTeardown(teardown: () => void) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + function Probe() { + useDragTeardown(teardown) + return null + } + + act(() => { + root.render() + }) +} + +function fire(type: string) { + act(() => { + window.dispatchEvent(new Event(type, { bubbles: true })) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useDragTeardown', () => { + it('tears down on dragend', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on drop', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('drop') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on the first pointermove after a drag, which dragend can miss', () => { + // Spring-loading unmounts the source row, so `dragend` — dispatched at that node — never + // reaches window. Browsers suppress pointer events during a drag, so the first one after + // is an exact signal that the drag ended. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('never tears down from the passage of time alone', () => { + // Regression: an idle-timeout version of this tore the drag down whenever the user rested + // on a folder waiting for it to spring open — the drag model reports only about every + // 350ms while the pointer is still, so any timeout in that range trips on a held drag. + // Nothing here may depend on a timer, so advancing the clock must change nothing. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + act(() => { + vi.advanceTimersByTime(30_000) + }) + + expect(teardown).not.toHaveBeenCalled() + + // And the drag is still live, so a real end signal still lands. + fire('dragend') + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('ignores pointer movement when no drag is in flight', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('pointermove') + fire('pointermove') + + expect(teardown).not.toHaveBeenCalled() + }) + + it('tears down once per drag, not on every event after it ends', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + fire('pointermove') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts new file mode 100644 index 00000000000..a28ca2057a5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -0,0 +1,61 @@ +'use client' + +import { useEffect, useRef } from 'react' + +/** + * Runs a drag's teardown wherever the drag actually ends. + * + * Three signals, because no single one is reliable here: + * + * 1. `drop` on `window` — a release over any valid target, wherever it bubbles from. + * 2. `dragend` on `window` — the normal end of a drag whose source row still exists. + * 3. `pointermove` on `window` — the case the first two miss. `dragend` is dispatched *at the + * source node*, so once spring-loading navigates the list and unmounts that row, the event + * has no path to `window` and neither listener above ever runs. Cancelling with Escape or + * releasing over nothing then leaves the ghost on the page, every row frozen at drag + * opacity, and the spring-open set uncleared so those folders refuse to open again. + * + * The third signal works because browsers suppress mouse and pointer events for the duration of + * a native drag: the first `pointermove` after one starts can only mean it is over. That makes + * it exact, where a timer is not — the drag model fires `dragover` on roughly a 350ms cadence + * while the pointer is stationary, so an idle-timeout version of this tore down mid-drag + * whenever the user rested on a folder waiting for it to spring open. + * + * `teardown` is read through a ref and the listeners bind once, deliberately. Depending on the + * callback would re-run this effect on every render, and the teardown wired into it would then + * abort drags that are still in progress — a bug this exact hook already shipped once. + */ +export function useDragTeardown(teardown: () => void): void { + const teardownRef = useRef<() => void>(teardown) + teardownRef.current = teardown + + useEffect(() => { + /** + * Set from `dragover` rather than `dragstart` so the flag only turns on once a drag is + * genuinely under way, and so a stray `pointermove` before the drag engages cannot tear + * down a drag that never started. + */ + let isDragging = false + + const markDragging = () => { + isDragging = true + } + + const endDrag = () => { + if (!isDragging) return + isDragging = false + teardownRef.current() + } + + window.addEventListener('dragover', markDragging) + window.addEventListener('dragend', endDrag) + window.addEventListener('drop', endDrag) + window.addEventListener('pointermove', endDrag) + return () => { + window.removeEventListener('dragover', markDragging) + window.removeEventListener('dragend', endDrag) + window.removeEventListener('drop', endDrag) + window.removeEventListener('pointermove', endDrag) + } + }, []) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index 19eb8f3e7d2..a739111c83a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -20,7 +20,12 @@ export interface UseFolderNavigationOptions { export interface FolderNavigation extends FolderAncestors { /** The open folder, or `null` at the workspace root. */ currentFolderId: string | null - setCurrentFolderId: (folderId: string | null) => void + /** + * Opens a folder. Defaults to the param group's `history: 'push'` — a folder the user chose + * to open is a destination. Pass `{ history: 'replace' }` for a write that is not a chosen + * navigation, such as the second and later spring-opens within a single drag. + */ + setCurrentFolderId: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void } /** @@ -49,8 +54,8 @@ export function useFolderNavigation({ const { folderById, foldersResolved } = ancestry const setCurrentFolderId = useCallback( - (folderId: string | null) => { - void setFolderParams({ folderId }) + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + void setFolderParams({ folderId }, options) }, [setFolderParams] ) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index b6a746b2606..4ac69bb78b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -1,22 +1,83 @@ 'use client' -import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import { + readRowDragPayload, + writeRowDragPayload, +} from '@/app/workspace/[workspaceId]/components/folders/drag-payload' import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' +import { useRowDragGhost } from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' +import type { SpringOpenOptions } from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' +import { useSpringNavigation } from '@/app/workspace/[workspaceId]/components/folders/use-spring-navigation' import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' /** - * Private drag payload, namespaced so a drag started on another Sim surface (or an external - * drag) is never mistaken for a foldered list row. + * What the hook hands back: the render contract `Resource` consumes, plus the one signal that is + * not a rendering concern. */ -const DRAG_ROW_MIME = 'application/x-sim-foldered-row' - -const DRAG_GHOST_STYLE = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' +export interface FolderRowDragDrop extends RowDragDropConfig { + /** + * Reports that a page-level overlay consumed an external drop, so spring navigation keeps the + * folder it opened instead of returning to where the drag started. Only a surface that owns a + * whole-page drop target needs this; row, body, and breadcrumb drops report themselves. + */ + externalDropHandled: () => void +} /** Shared empty set so an idle drag state keeps a stable identity across renders. */ const EMPTY_ROW_IDS = new Set() +/** + * The one surface currently reading as "release here". + * + * A union rather than three booleans because the three targets are mutually exclusive: a row, + * the list body, and a breadcrumb crumb can never be armed together. As separate flags every + * handler had to hand-clear the other two, and where a `dragleave` does not fire — a row lives + * inside the scroll container, so moving onto it leaves that container with a contained + * `relatedTarget` its handler ignores — two affordances could paint at once. Here exactly one + * is armed by construction. + */ +type ActiveDropTarget = + | { kind: 'row'; rowId: string } + | { kind: 'body' } + | { kind: 'crumb'; index: number } + +/** + * Arms `next`, reusing the current value when it already names the same target. + * + * `dragover` fires continuously — several times a second even with the pointer still — so a + * fresh object per event would re-render the whole list and rebuild the memoized config every + * time. Returning `current` unchanged lets React bail on `Object.is`, which is what the plain + * string this union replaced used to get for free. + */ +function armDropTarget( + current: ActiveDropTarget | null, + next: ActiveDropTarget +): ActiveDropTarget | null { + if (current?.kind !== next.kind) return next + switch (next.kind) { + case 'row': + return current.kind === 'row' && current.rowId === next.rowId ? current : next + case 'crumb': + return current.kind === 'crumb' && current.index === next.index ? current : next + default: + return current + } +} + +/** Rows carried by one drag, already split by kind and stripped of no-op moves. */ +export interface FolderedRowMove { + folderIds: string[] + resourceIds: string[] +} + export interface UseFolderRowDragDropOptions { + /** + * This list's private drag MIME. Each surface owns one so a drag started in another list is + * never mistaken for one of these rows — see {@link writeRowDragPayload}. + */ + dragMime: string /** Drag and drop are edits; a reader gets neither draggable rows nor drop targets. */ canEdit: boolean /** Row currently being renamed inline, which must stay editable rather than draggable. */ @@ -29,10 +90,50 @@ export interface UseFolderRowDragDropOptions { getResourceFolderId: (resourceId: string) => string | null | undefined /** Label shown in the drag ghost. */ getRowLabel: (rowId: string) => string - /** Reparents a folder into `targetFolderId`. */ - onMoveFolder: (folderId: string, targetFolderId: string) => void - /** Files a resource into `targetFolderId`. */ - onMoveResource: (resourceId: string, targetFolderId: string) => void + /** + * Moves every row of the drag into `targetFolderId` in one call (`null` is the workspace + * root). Rows already sitting directly in the target are filtered out before this fires, and + * it is never called with both lists empty — so the consumer maps it straight onto its + * bulk-move operations. + */ + onMoveRows: (rows: FolderedRowMove, targetFolderId: string | null) => void + /** + * Checkbox selection, when the list has one. Dragging a selected row carries the whole + * selection; dragging an unselected row collapses the selection onto it first, matching + * every file manager. Omit on a list without selection to keep drags single-row. + */ + selection?: { + selectedRowIds: Set + /** Row ids in display order, so the drag carries them in the order they are read. */ + visibleRowIds: string[] + /** Collapses the selection onto a single row dragged from outside it. */ + replaceSelection: (rowIds: string[]) => void + } + /** + * Opens a folder the drag has rested on, so the user can file into a nested folder without + * dropping first. Forward `options` to the folder-navigation setter so one drag leaves one + * back-stack entry. Omit to disable spring-loading. See {@link useSpringNavigation}. + */ + onSpringOpenFolder?: (folderId: string | null, options: SpringOpenOptions) => void + /** + * The folder the list is currently showing (`null` at the workspace root). Enables dropping + * onto the list body to file into it — the only way to land a drag that spring-opened into an + * empty folder, which has no row to drop on. + */ + currentFolderId?: string | null + /** + * OS file drops, which Files accepts and the other lists do not. + * + * When `matches` recognises the drag, folder rows still highlight and still spring open — the + * gesture is the same, only the payload differs — but the internal move-validity gate is + * skipped, and the body and breadcrumb decline so a page-level upload overlay owns those + * regions rather than competing with it. + */ + externalDrop?: { + matches: (dataTransfer: DataTransfer) => boolean + /** Files released on a folder row, to be uploaded into it. */ + onDropIntoFolder: (dataTransfer: DataTransfer, targetFolderId: string) => void + } } /** @@ -40,77 +141,119 @@ export interface UseFolderRowDragDropOptions { * Tables behave exactly like Files: only folder rows accept a drop, a folder cannot land in * itself or its own subtree, and a row already sitting directly in the target is a no-op. * - * Single-row only, which is what the resource lists that use it support. The Files page - * keeps its own configuration because it additionally drags multi-selections and accepts - * external OS file drops. + * Carries a whole checkbox selection when `selection` is supplied, and a single row otherwise. + * Files layers OS file drops on top through `externalDrop`; the gesture is identical, only the + * payload differs. */ export function useFolderRowDragDrop({ + dragMime, canEdit, editingRowId, descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, -}: UseFolderRowDragDropOptions): RowDragDropConfig { - const [activeDropTargetId, setActiveDropTargetId] = useState(null) + onMoveRows, + selection, + onSpringOpenFolder, + currentFolderId = null, + externalDrop, +}: UseFolderRowDragDropOptions): FolderRowDragDrop { + const [activeDropTarget, setActiveDropTarget] = useState(null) const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_ROW_IDS) /** * The in-flight drag source, mirrored outside React state because `onDragOver` fires far * faster than a re-render and must decide drop validity against the current source * synchronously. */ - const draggedRowIdRef = useRef(null) - const dragGhostRef = useRef(null) + const draggedRowIdsRef = useRef([]) const optionsRef = useRef({ descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, + externalDrop, }) optionsRef.current = { descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, + externalDrop, } + const springNav = useSpringNavigation({ currentFolderId, onNavigate: onSpringOpenFolder }) + + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + const dragGhost = useRowDragGhost() + + /** Returns the list to its resting state once a drag is over, however it ended. */ + const endDrag = useCallback(() => { + springNav.end() + dragGhost.remove() + draggedRowIdsRef.current = [] + setDraggedRowIds(EMPTY_ROW_IDS) + setActiveDropTarget(null) + }, [dragGhost, springNav]) + + useDragTeardown(endDrag) + /** - * The ghost lives on `document.body`, but the only thing that removes it is `dragend`, which - * fires on the SOURCE ROW. `Resource.Table` is virtualized, so scrolling the source out of - * view mid-drag unmounts that row and the event never arrives — leaving the ghost stuck on - * the page and every row frozen at drag opacity. Clean up on unmount as the backstop. + * Splits the drag into the rows that would actually move into `targetFolderId`, dropping any + * row already sitting directly there. `null` when the drop is illegal outright — the target is + * one of the dragged folders or inside one, which would orphan a subtree into itself — or when + * nothing would actually change. + * + * Takes a folder id rather than a row id because the destination is not always a row: the + * list body files into the folder currently open, which has no row of its own, and `null` + * addresses the workspace root. */ - useEffect( - () => () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null + const resolveMoveToFolder = useCallback( + (targetFolderId: string | null, sourceRowIds: string[]): FolderedRowMove | null => { + const { descendantsByFolderId, getFolderParentId, getResourceFolderId } = optionsRef.current + const folderIds: string[] = [] + const resourceIds: string[] = [] + + for (const sourceRowId of sourceRowIds) { + const source = parseFolderedRowId(sourceRowId) + if (source.kind === 'folder') { + if (source.id === targetFolderId) return null + if (targetFolderId !== null && descendantsByFolderId.get(source.id)?.has(targetFolderId)) + return null + if ((getFolderParentId(source.id) ?? null) === targetFolderId) continue + folderIds.push(source.id) + continue + } + if ((getResourceFolderId(source.id) ?? null) === targetFolderId) continue + resourceIds.push(source.id) + } + + if (folderIds.length === 0 && resourceIds.length === 0) return null + return { folderIds, resourceIds } }, [] ) - const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => { - const target = parseFolderedRowId(targetRowId) - if (target.kind !== 'folder') return true - - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') { - if (source.id === target.id) return true - if (optionsRef.current.descendantsByFolderId.get(source.id)?.has(target.id)) return true - return (optionsRef.current.getFolderParentId(source.id) ?? null) === target.id - } - return (optionsRef.current.getResourceFolderId(source.id) ?? null) === target.id - }, []) + /** Row-targeted drop: only a folder row can receive one. */ + const resolveMove = useCallback( + (targetRowId: string, sourceRowIds: string[]): FolderedRowMove | null => { + const target = parseFolderedRowId(targetRowId) + if (target.kind !== 'folder') return null + return resolveMoveToFolder(target.id, sourceRowIds) + }, + [resolveMoveToFolder] + ) - return useMemo( + return useMemo( () => ({ - activeDropTargetId, + activeDropTargetId: activeDropTarget?.kind === 'row' ? activeDropTarget.rowId : null, draggedRowIds, isAnyDragActive: draggedRowIds.size > 0, isRowDraggable: (rowId) => canEdit && editingRowId !== rowId, @@ -121,30 +264,46 @@ export function useFolderRowDragDrop({ return } - draggedRowIdRef.current = rowId - setDraggedRowIds(new Set([rowId])) + springNav.rememberOrigin() + const { selection } = optionsRef.current + /** + * Read the selection in display order rather than insertion order, so a shift-range + * drag carries its rows the way the user sees them. + */ + const sourceRowIds = selection?.selectedRowIds.has(rowId) + ? selection.visibleRowIds.filter((visibleRowId) => + selection.selectedRowIds.has(visibleRowId) + ) + : [rowId] + if (selection && !selection.selectedRowIds.has(rowId)) selection.replaceSelection([rowId]) + + draggedRowIdsRef.current = sourceRowIds + setDraggedRowIds(new Set(sourceRowIds)) e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData(DRAG_ROW_MIME, rowId) - e.dataTransfer.setData('text/plain', rowId) - - const ghost = document.createElement('div') - ghost.style.cssText = DRAG_GHOST_STYLE - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = optionsRef.current.getRowLabel(rowId) - ghost.appendChild(text) - document.body.appendChild(ghost) - // Force a layout pass so the drag image is measurable before it is captured. - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost + writeRowDragPayload(e.dataTransfer, dragMime, sourceRowIds) + + dragGhost.attach(e, optionsRef.current.getRowLabel(sourceRowIds[0]), sourceRowIds.length) }, onDragOver: (e: DragEvent, rowId) => { - const sourceRowId = draggedRowIdRef.current - if (sourceRowId) { - if (isInvalidDropTarget(rowId, sourceRowId)) return - } else if (!e.dataTransfer.types.includes(DRAG_ROW_MIME)) { + const sourceRowIds = draggedRowIdsRef.current + const isExternal = optionsRef.current.externalDrop?.matches(e.dataTransfer) ?? false + if (isExternal) { + /** + * An upload into a nested folder is the same gesture as a move into one, so the row + * highlights and springs open exactly the same way. Only the move-validity gate is + * skipped — there are no source rows to validate. + */ + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + setActiveDropTarget((current) => armDropTarget(current, { kind: 'row', rowId })) + springNav.arm(parseFolderedRowId(rowId).id) + return + } + if (sourceRowIds.length > 0) { + if (!resolveMove(rowId, sourceRowIds)) return + } else if (!e.dataTransfer.types.includes(dragMime)) { /** * No local source and no payload of ours — an external or foreign drag. Returning * without `preventDefault` leaves the browser's default handling in place, which is @@ -165,38 +324,166 @@ export function useFolderRowDragDrop({ * would light up as a valid target — including the dragged folder itself and its own * descendants — and the drop would then silently do nothing. */ - if (sourceRowId) setActiveDropTargetId(rowId) + if (sourceRowIds.length > 0) { + setActiveDropTarget((current) => armDropTarget(current, { kind: 'row', rowId })) + /** + * Armed on the same condition as the highlight, so a folder only springs open where a + * drop was already possible. A folder the drag cannot legally enter never opens. + */ + springNav.arm(parseFolderedRowId(rowId).id) + } }, onDragLeave: (e: DragEvent, rowId) => { const relatedTarget = e.relatedTarget if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return - setActiveDropTargetId((current) => (current === rowId ? null : current)) + springNav.disarm() + setActiveDropTarget((current) => + current?.kind === 'row' && current.rowId === rowId ? null : current + ) }, onDrop: (e: DragEvent, rowId) => { e.preventDefault() e.stopPropagation() - setActiveDropTargetId(null) const target = parseFolderedRowId(rowId) - if (target.kind !== 'folder') return - + const { externalDrop } = optionsRef.current + if (externalDrop?.matches(e.dataTransfer)) { + const { dataTransfer } = e + /** + * Marked before `endDrag`, which consumes the flag: an upload lands in the folder the + * drag opened, so the view has to stay there rather than springing back to the origin. + */ + if (target.kind === 'folder') springNav.markDropHandled() + endDrag() + if (target.kind === 'folder') externalDrop.onDropIntoFolder(dataTransfer, target.id) + return + } // Prefer the dataTransfer payload over the ref so a drag that started in another - // mount of this page still resolves to a real row id. - const sourceRowId = e.dataTransfer.getData(DRAG_ROW_MIME) || draggedRowIdRef.current - if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return + // mount of this page still resolves to real row ids. + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + const move = + target.kind === 'folder' && sourceRowIds.length > 0 + ? resolveMove(rowId, sourceRowIds) + : null + if (move) springNav.markDropHandled() - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') optionsRef.current.onMoveFolder(source.id, target.id) - else optionsRef.current.onMoveResource(source.id, target.id) + /** + * Ends the drag here rather than leaving it to `dragend`. This handler stops + * propagation, so the window-level backstop never sees this drop, and the source row + * may already have unmounted — after a spring-open it always has. + */ + endDrag() + + if (move) optionsRef.current.onMoveRows(move, target.id) + }, + onDragEnd: endDrag, + externalDropHandled: springNav.markDropHandled, + /** + * The breadcrumb is how a drag walks back UP. Spring-loading only ever goes deeper, so + * without this a drag that entered a folder can only leave it by being abandoned. + * Hovering a crumb navigates to it on the same timer a folder row uses, and releasing on + * one files the drag there directly. + */ + breadcrumb: { + activeIndex: activeDropTarget?.kind === 'crumb' ? activeDropTarget.index : null, + onDragOver: (e: DragEvent, folderId: string | null, index: number) => { + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + const sourceRowIds = draggedRowIdsRef.current + const canDrop = + sourceRowIds.length > 0 && resolveMoveToFolder(folderId, sourceRowIds) !== null + /** + * Armed even when the drop itself would be a no-op — walking back through a crumb the + * rows already live in is exactly how a user returns to where they started, and + * refusing to navigate there would strand them. The crumb for the folder already on + * screen is declined by {@link useSpringNavigation}, not here. + */ + if (sourceRowIds.length > 0) springNav.arm(folderId) + setActiveDropTarget((current) => + canDrop ? armDropTarget(current, { kind: 'crumb', index }) : null + ) + if (!canDrop) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (_e: DragEvent, index: number) => { + springNav.disarm() + setActiveDropTarget((current) => + current?.kind === 'crumb' && current.index === index ? null : current + ) + }, + onDrop: (e: DragEvent, folderId: string | null) => { + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + const move = sourceRowIds.length > 0 ? resolveMoveToFolder(folderId, sourceRowIds) : null + if (move) springNav.markDropHandled() + endDrag() + if (move) optionsRef.current.onMoveRows(move, folderId) + }, }, - onDragEnd: () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null - draggedRowIdRef.current = null - setDraggedRowIds(EMPTY_ROW_IDS) - setActiveDropTargetId(null) + body: { + isActive: activeDropTarget?.kind === 'body', + onDragOver: (e: DragEvent) => { + /** Declined: a page-level upload overlay owns the whole region for an OS file drag. */ + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + const sourceRowIds = draggedRowIdsRef.current + /** + * Recomputed on every event rather than latched, because a spring-open changes the + * destination mid-drag: the folder just entered may not accept this drag, and an + * early return would leave the body overlay showing from the previous folder. Setting + * the same value repeatedly is free — React bails on an unchanged state write. + */ + const canDrop = + sourceRowIds.length > 0 && + resolveMoveToFolder(currentFolderIdRef.current, sourceRowIds) !== null + setActiveDropTarget((current) => + canDrop ? armDropTarget(current, { kind: 'body' }) : null + ) + if (!canDrop) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (e: DragEvent) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setActiveDropTarget((current) => (current?.kind === 'body' ? null : current)) + }, + onDrop: (e: DragEvent) => { + if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + /** + * Read from the ref, not the closure. This config is memoized, and during a drag the + * only dep that routinely changes is the hovered row — so after a spring-open into an + * empty folder, which has no rows to hover, a captured `currentFolderId` would still + * name the folder the drag came FROM and file the rows back into it. + */ + const targetFolderId = currentFolderIdRef.current + const move = + sourceRowIds.length > 0 ? resolveMoveToFolder(targetFolderId, sourceRowIds) : null + if (move) springNav.markDropHandled() + endDrag() + if (move) optionsRef.current.onMoveRows(move, targetFolderId) + }, }, }), - [activeDropTargetId, draggedRowIds, canEdit, editingRowId, isInvalidDropTarget] + [ + activeDropTarget, + draggedRowIds, + canEdit, + dragMime, + editingRowId, + resolveMove, + resolveMoveToFolder, + springNav, + endDrag, + dragGhost, + ] ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx new file mode 100644 index 00000000000..6aa96a1d3e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.test.tsx @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { + type RowDragGhost, + useRowDragGhost, +} from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' + +const mountedRoots: Root[] = [] + +function renderGhost() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + let result: RowDragGhost | undefined + + function Probe() { + result = useRowDragGhost() + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + document.body.innerHTML = '' +}) + +describe('useRowDragGhost', () => { + it('keeps a stable handle identity across renders', () => { + // Consumers list this handle in the deps of the `endDrag` callback that `useDragTeardown` + // binds and the drag config memo depends on. A fresh object per render makes `endDrag` + // unstable and rebuilds the whole drag config every render. + const ghost = renderGhost() + + const before = ghost.get() + ghost.rerender() + ghost.rerender() + + expect(ghost.get()).toBe(before) + }) + + it('removes the ghost node on unmount', () => { + const ghost = renderGhost() + const dataTransfer = { setDragImage: () => {} } as unknown as DataTransfer + + act(() => { + ghost.get().attach({ dataTransfer } as never, 'Report.md', 1) + }) + expect(document.body.children.length).toBe(1) + + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + expect(document.body.children.length).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts new file mode 100644 index 00000000000..ee37ca45a8c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts @@ -0,0 +1,66 @@ +'use client' + +import type { DragEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' + +/** + * Inline chrome for the drag image. It is set on a detached DOM node handed to `setDragImage`, + * so it cannot be a Tailwind class list. + * + * `font-family` is deliberately absent: the node is appended to ``, so omitting it lets + * the label inherit the app font and match the row it was lifted from. + */ +const DRAG_GHOST_STYLE = + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + +const DRAG_GHOST_LABEL_STYLE = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' + +export interface RowDragGhost { + /** Builds the drag image for this drag and attaches it to the event. */ + attach: (e: DragEvent, label: string, count: number) => void + /** Removes the ghost node. Safe to call when none is attached. */ + remove: () => void +} + +/** + * The drag image shown while dragging resource rows — the first row's label, plus a count when + * the drag carries a multi-row selection. + * + * Shared so every foldered list lifts rows the same way. The node lives on `document.body` + * rather than in the React tree because `setDragImage` snapshots a real, laid-out element; the + * unmount cleanup is the backstop for a drag whose source row disappears before it ends. + */ +export function useRowDragGhost(): RowDragGhost { + const ghostRef = useRef(null) + + const remove = useCallback(() => { + ghostRef.current?.remove() + ghostRef.current = null + }, []) + + useEffect(() => remove, [remove]) + + const attach = useCallback((e: DragEvent, label: string, count: number) => { + const ghost = document.createElement('div') + ghost.style.cssText = DRAG_GHOST_STYLE + const text = document.createElement('span') + text.style.cssText = DRAG_GHOST_LABEL_STYLE + text.textContent = count > 1 ? `${label} +${count - 1} more` : label + ghost.appendChild(text) + document.body.appendChild(ghost) + // Force a layout pass so the drag image is measurable before it is captured. + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + ghostRef.current = ghost + }, []) + + /** + * Stable identity, not a fresh object per render — the same requirement + * {@link useSpringLoadedFolder} documents. Consumers list this handle in the deps of the + * `endDrag` callback that `useDragTeardown` binds and that the drag-config memo depends on, + * so a new object each render makes `endDrag` unstable and rebuilds the whole drag config + * every render, re-rendering every memoized row. The inner callbacks are already stable, so + * this memo never invalidates. + */ + return useMemo(() => ({ attach, remove }), [attach, remove]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx new file mode 100644 index 00000000000..e2b3b9d5278 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx @@ -0,0 +1,235 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SPRING_LOAD_DELAY_MS, + type SpringLoadedFolder, + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' + +const mountedRoots: Root[] = [] + +interface SpringLoadHarness { + get: () => SpringLoadedFolder + /** Re-renders the probe with a new inline callback, as a parent re-render would. */ + rerender: () => void +} + +function renderSpringLoad( + onSpringOpen: (folderId: string, options: SpringOpenOptions) => void +): SpringLoadHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + let result: SpringLoadedFolder | undefined + + function Probe() { + // A fresh arrow each render, mirroring how every real consumer passes this. + result = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => onSpringOpen(folderId, options), + }) + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +/** Advances past the spring delay, flushing the timer callback inside React's act scope. */ +function rest(ms = SPRING_LOAD_DELAY_MS) { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useSpringLoadedFolder', () => { + it('opens the folder after the drag rests on it', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 1) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest(1) + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('does not restart the countdown while the drag stays on one folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + // `dragover` fires continuously; re-arming the same folder must not push the deadline back. + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-a')) + rest(100) + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('restarts the countdown when the drag moves to another folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-b')) + rest(100) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest() + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-b', { history: 'push' }) + }) + + it('cancels the pending open when the drag leaves the row', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => harness.get().disarm()) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) + + it('opens a folder again when the drag comes back to it', () => { + // Descend, walk back out through the breadcrumb, change your mind and descend again — one + // gesture, and the second entry has to work. Re-entry costs another full delay, and + // `useSpringNavigation` refuses the folder already on screen, so nothing oscillates. + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().arm(null)) + rest() + act(() => harness.get().arm('folder-a')) + rest() + + expect(onSpringOpen.mock.calls).toEqual([ + ['folder-a', { history: 'push' }], + [null, { history: 'replace' }], + ['folder-a', { history: 'replace' }], + ]) + }) + + it('pushes the first spring-open of a drag and replaces the rest', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().arm('folder-b')) + rest() + + // One gesture, one back-stack entry: Back returns to where the drag started rather than + // replaying every level it passed through, and without eating the entry it started on. + expect(onSpringOpen.mock.calls).toEqual([ + ['folder-a', { history: 'push' }], + ['folder-b', { history: 'replace' }], + ]) + + // A new drag is a new gesture, so its first open pushes again. + act(() => harness.get().reset()) + act(() => harness.get().arm('folder-c')) + rest() + expect(onSpringOpen).toHaveBeenLastCalledWith('folder-c', { history: 'push' }) + }) + + it('keeps a stable handle identity across renders', () => { + // Regression guard: consumers feed this handle into a `useCallback` that a drag-lifecycle + // effect depends on. A fresh object per render re-runs that effect continuously, which tore + // down in-flight drags and silently disabled spring-loading entirely. + const harness = renderSpringLoad(vi.fn()) + + const before = harness.get() + harness.rerender() + harness.rerender() + + expect(harness.get()).toBe(before) + }) + + it('allows the same folder again after the drag ends', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().reset()) + + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(2) + }) + + it('springs to the workspace root, which a breadcrumb targets as null', () => { + // Walking a drag back UP goes through the breadcrumb, whose first crumb is the root — so + // null has to be a real destination here, distinct from "nothing armed". + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith(null, { history: 'push' }) + }) + + it('re-opens the root like any other folder, and only after a full rest', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + + // Passing over another row cancels the countdown, so returning to the root has to wait out + // the delay again rather than firing on whatever was left of the previous one. + act(() => harness.get().arm('folder-a')) + act(() => harness.get().arm(null)) + expect(onSpringOpen).toHaveBeenCalledTimes(1) + rest() + + expect(onSpringOpen).toHaveBeenCalledTimes(2) + }) + + it('never opens a folder after unmount', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts new file mode 100644 index 00000000000..3f39c951fc0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts @@ -0,0 +1,121 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef } from 'react' + +/** + * How long a drag must rest on a folder before it opens. + * + * Deliberately slower than the workflow sidebar's 400ms hover-to-expand: that one opens a tree + * node in place and is trivially reversible, while this one navigates the whole list view out + * from under the drag. At 700ms a drag merely crossing a folder on its way elsewhere kept + * triggering it; the cost of waiting is far lower than the cost of an unwanted navigation. + */ +export const SPRING_LOAD_DELAY_MS = 1000 + +/** How a spring-open writes the newly opened folder to the browser history. */ +export interface SpringOpenOptions { + history: 'push' | 'replace' +} + +export interface UseSpringLoadedFolderOptions { + /** + * Opens the folder mid-drag. The drag continues in the newly opened folder. + * + * `options.history` is `'push'` for the first folder a drag opens and `'replace'` for every + * one after, so one gesture leaves exactly one back-stack entry and Back returns to the + * folder the drag started in. Pushing every level would record folders the user only rested + * over while deciding where to drop; replacing every level would overwrite the entry they + * were actually standing on, so Back would leave the page instead of returning to it. + */ + onSpringOpen: (folderId: string | null, options: SpringOpenOptions) => void + delayMs?: number +} + +export interface SpringLoadedFolder { + /** + * Starts (or continues) the timer for `folderId`. Safe to call on every `dragover`, which + * fires continuously: re-arming the folder already being timed does not restart it, so the + * countdown reflects how long the drag has actually rested there. + */ + arm: (folderId: string | null) => void + /** Cancels the pending open — the drag left the row, or the row stopped being a valid target. */ + disarm: () => void + /** Cancels the pending open and forgets that this drag opened anything. Call when the drag ends. */ + reset: () => void +} + +/** + * Spring-loaded folders: resting a drag on a folder row opens it, so a resource can be filed + * into a nested folder in one gesture instead of being dropped, navigated, and dragged again. + * + * The dragged rows unmount when the list re-renders into the newly opened folder, which is why + * the drag payload has to live in `dataTransfer` rather than only in the source row's state. + * + * A folder may open more than once in a single drag: walking back out through the breadcrumb and + * descending again is a normal way to change your mind mid-gesture, and refusing the second entry + * strands the drag one level up. Nothing oscillates, because every open costs another full + * {@link SPRING_LOAD_DELAY_MS} of the drag holding still, and {@link useSpringNavigation} refuses + * to arm the folder already on screen. + */ +export function useSpringLoadedFolder({ + onSpringOpen, + delayMs = SPRING_LOAD_DELAY_MS, +}: UseSpringLoadedFolderOptions): SpringLoadedFolder { + const timerRef = useRef | null>(null) + /** + * Folder the timer is counting down for, so re-arming it is a no-op. `undefined` means + * nothing is armed — `null` is a real destination here, the workspace root. + */ + const armedFolderIdRef = useRef(undefined) + /** Whether this drag has already sprung a folder open, which decides push vs. replace. */ + const hasOpenedRef = useRef(false) + + const onSpringOpenRef = useRef(onSpringOpen) + onSpringOpenRef.current = onSpringOpen + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + timerRef.current = null + armedFolderIdRef.current = undefined + }, []) + + /** A drag can outlive the list that started it; never leave a timer pointing at a dead tree. */ + useEffect(() => clearTimer, [clearTimer]) + + const arm = useCallback( + (folderId: string | null) => { + if (armedFolderIdRef.current === folderId) return + + /** + * The drag has moved to a different row, so any countdown started on the previous one is + * stale — cancel it before deciding whether this row can spring. Returning early without + * this would let the folder the drag just left open behind the cursor. + */ + clearTimer() + armedFolderIdRef.current = folderId + timerRef.current = setTimeout(() => { + timerRef.current = null + armedFolderIdRef.current = undefined + const isFirstOpenOfDrag = !hasOpenedRef.current + hasOpenedRef.current = true + onSpringOpenRef.current(folderId, { + history: isFirstOpenOfDrag ? 'push' : 'replace', + }) + }, delayMs) + }, + [clearTimer, delayMs] + ) + + const reset = useCallback(() => { + clearTimer() + hasOpenedRef.current = false + }, [clearTimer]) + + /** + * Stable identity, not a fresh object per render. Consumers feed this handle into a + * `useCallback` that a drag-lifecycle effect depends on; a new object each render re-runs + * that effect continuously, and its cleanup then tears down the drag that is still in + * progress. The inner callbacks are already stable, so this memo never invalidates. + */ + return useMemo(() => ({ arm, disarm: clearTimer, reset }), [arm, clearTimer, reset]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx new file mode 100644 index 00000000000..6e3590e2d48 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.test.tsx @@ -0,0 +1,309 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SPRING_LOAD_DELAY_MS } from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' +import { + type SpringNavigation, + useSpringNavigation, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-navigation' + +const mountedRoots: Root[] = [] + +/** + * Drives the hook the way a drag does, re-rendering with the folder the navigation callback + * just moved to — the hook compares the origin against the *current* folder, so a probe that + * never advances would never exercise the return path. + */ +function renderSpringNavigation(startFolderId: string | null) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + const navigate = vi.fn<(folderId: string | null, history: 'push' | 'replace') => void>() + let currentFolderId = startFolderId + let result: SpringNavigation | undefined + + function Probe({ folderId }: { folderId: string | null }) { + result = useSpringNavigation({ + currentFolderId: folderId, + onNavigate: (nextFolderId, options) => { + navigate(nextFolderId, options.history) + currentFolderId = nextFolderId + }, + }) + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + navigate, + currentFolderId: () => currentFolderId, + } +} + +/** + * Advances past the spring delay, then re-renders with the folder the navigation moved to — + * the real list does the same, and the hook compares the origin against the *current* folder, + * so a probe stuck on the old one would never exercise the return path. + */ +function rest(harness: { rerender: () => void }) { + act(() => { + vi.advanceTimersByTime(SPRING_LOAD_DELAY_MS) + }) + harness.rerender() +} + +/** One spring-open: rest the drag on `folderId` until the timer fires and the list follows. */ +function descend(nav: ReturnType, folderId: string | null) { + act(() => nav.get().arm(folderId)) + rest(nav) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useSpringNavigation', () => { + it('opens a folder the drag rests on', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + }) + + it('returns to the origin when the drag ends without a drop', () => { + // The whole point: spring-loading only goes deeper, so a cancelled drag would otherwise + // strand the user in a folder they never chose to open. + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenLastCalledWith(null, 'replace') + expect(nav.currentFolderId()).toBeNull() + }) + + it('stays put when a drop actually landed', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().markDropHandled()) + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + expect(nav.currentFolderId()).toBe('folder-a') + }) + + it('returns in one hop from several levels deep', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().arm('folder-b')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-a', 'push'], + ['folder-b', 'replace'], + [null, 'replace'], + ]) + expect(nav.currentFolderId()).toBeNull() + }) + + it('returns to a subfolder origin, not the root', () => { + const nav = renderSpringNavigation('origin-folder') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.currentFolderId()).toBe('origin-folder') + }) + + it('does not navigate when the drag never opened anything', () => { + const nav = renderSpringNavigation('origin-folder') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().end()) + + expect(nav.navigate).not.toHaveBeenCalled() + }) + + describe('walking a drag back out and in again', () => { + it('re-enters a folder it already left through the breadcrumb', () => { + // The whole point of the breadcrumb accepting a drag: descend, think better of it, walk + // back up, then descend again — all inside one gesture without releasing the mouse. + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + expect(nav.currentFolderId()).toBe('folder-a') + + descend(nav, null) + expect(nav.currentFolderId()).toBeNull() + + descend(nav, 'folder-a') + expect(nav.currentFolderId()).toBe('folder-a') + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-a', 'push'], + [null, 'replace'], + ['folder-a', 'replace'], + ]) + }) + + it('never re-opens the folder already on screen', () => { + // The crumb for the current folder is a legal drop target but not a navigation. Arming it + // would re-enter the folder the drag is already standing in, on a loop. + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + + descend(nav, 'folder-a') + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('folder-a', 'push') + }) + + it('cancels a pending open when the drag moves onto the current folder', () => { + // Hovering a sibling folder starts its countdown; sliding onto the crumb of the folder + // you are already in has to call that off, not let it fire from under the cursor. + const nav = renderSpringNavigation('folder-a') + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-b')) + act(() => { + vi.advanceTimersByTime(SPRING_LOAD_DELAY_MS - 1) + }) + descend(nav, 'folder-a') + + expect(nav.navigate).not.toHaveBeenCalled() + }) + + it('returns to the origin in one hop after a round trip that dropped nothing', () => { + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + descend(nav, 'folder-b') + descend(nav, 'folder-a') + + nav.navigate.mockClear() + act(() => nav.get().end()) + + expect(nav.navigate).toHaveBeenCalledExactlyOnceWith('origin', 'replace') + expect(nav.currentFolderId()).toBe('origin') + }) + + it('stays put when the round trip ends in a real drop', () => { + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + descend(nav, null) + descend(nav, 'folder-a') + + nav.navigate.mockClear() + act(() => { + nav.get().markDropHandled() + nav.get().end() + }) + + expect(nav.navigate).not.toHaveBeenCalled() + expect(nav.currentFolderId()).toBe('folder-a') + }) + + it('walks back to the origin folder itself without then bouncing away from it', () => { + // Ending a drag whose spring-opens happen to land back on the origin must not navigate + // again — the guard is origin-vs-current, not "did anything open". + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + descend(nav, 'origin') + expect(nav.currentFolderId()).toBe('origin') + + nav.navigate.mockClear() + act(() => nav.get().end()) + + expect(nav.navigate).not.toHaveBeenCalled() + }) + + it('starts the next drag from where the previous one left the user', () => { + // A drag that ended on a new folder is the new origin. Reusing the old one would yank the + // list back several folders on the next unrelated drag. + const nav = renderSpringNavigation('origin') + + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-a') + act(() => { + nav.get().markDropHandled() + nav.get().end() + }) + + nav.navigate.mockClear() + act(() => nav.get().rememberOrigin()) + descend(nav, 'folder-b') + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-b', 'push'], + ['folder-a', 'replace'], + ]) + }) + }) + + it('does not carry drop state into the next drag', () => { + const nav = renderSpringNavigation(null) + + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-a')) + rest(nav) + act(() => nav.get().markDropHandled()) + act(() => nav.get().end()) + nav.navigate.mockClear() + + // A second drag that lands nowhere must still return, despite the first one having dropped. + act(() => nav.get().rememberOrigin()) + act(() => nav.get().arm('folder-b')) + rest(nav) + act(() => nav.get().end()) + + expect(nav.navigate.mock.calls).toEqual([ + ['folder-b', 'push'], + ['folder-a', 'replace'], + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts new file mode 100644 index 00000000000..1e0e208f4ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-navigation.ts @@ -0,0 +1,128 @@ +'use client' + +import { useCallback, useMemo, useRef } from 'react' +import { + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' + +export interface UseSpringNavigationOptions { + /** The folder the list is currently showing (`null` at the workspace root). */ + currentFolderId: string | null + /** + * Navigates the list. Called both to spring a folder open mid-drag and to return to where the + * drag began. Omit to disable spring navigation entirely. + */ + onNavigate?: (folderId: string | null, options: SpringOpenOptions) => void +} + +export interface SpringNavigation { + /** Records where this drag began. Call from `dragstart`. */ + rememberOrigin: () => void + /** Starts (or continues) the timer that opens `folderId`. Safe to call on every `dragover`. */ + arm: (folderId: string | null) => void + /** Cancels a pending open — the drag left the target. */ + disarm: () => void + /** Marks that a drop actually moved something, so the destination stays on screen. */ + markDropHandled: () => void + /** Ends the drag, returning to the origin when the spring-opens went unused. */ + end: () => void +} + +/** + * Spring-loaded folder navigation for a drag, including the way back out. + * + * Opening a folder mid-drag is only half a gesture. A drag that springs its way several levels + * deep and is then cancelled — or dropped somewhere else — would otherwise leave the user in a + * folder they never chose to open, looking at a list they did not ask for. So the navigation is + * treated as part of the drag: unless a drop actually landed, ending the drag returns to where + * it started. The workflow sidebar collapses its own spring-opened folders for the same reason. + * + * Shared by every foldered list, including a drag of OS files onto the Files page. + */ +export function useSpringNavigation({ + currentFolderId, + onNavigate, +}: UseSpringNavigationOptions): SpringNavigation { + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + const onNavigateRef = useRef(onNavigate) + onNavigateRef.current = onNavigate + + const originFolderIdRef = useRef(null) + /** Whether this drag has an origin yet. A drag of OS files fires no `dragstart` of ours. */ + const hasOriginRef = useRef(false) + const didSpringOpenRef = useRef(false) + const dropHandledRef = useRef(false) + + const springLoad = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => { + didSpringOpenRef.current = true + onNavigateRef.current?.(folderId, options) + }, + }) + + const rememberOrigin = useCallback(() => { + originFolderIdRef.current = currentFolderIdRef.current + hasOriginRef.current = true + }, []) + + /** + * Seeds the origin for a drag that never reached {@link SpringNavigation.rememberOrigin} — a + * drag of OS files starts outside the page, so there is no `dragstart` of ours to record it. + * Without this the return lands on whatever folder the PREVIOUS drag began in. + * + * Refuses the folder already on screen. That target is not a navigation, and arming it is how + * a drag resting on one spot would re-open the same folder over and over: the underlying timer + * lets a folder spring more than once per drag so the user can descend, back out through the + * breadcrumb, and descend again. + */ + const arm = useCallback( + (folderId: string | null) => { + if (folderId === currentFolderIdRef.current) { + springLoad.disarm() + return + } + if (!hasOriginRef.current) { + originFolderIdRef.current = currentFolderIdRef.current + hasOriginRef.current = true + } + springLoad.arm(folderId) + }, + [springLoad.arm, springLoad.disarm] + ) + + const markDropHandled = useCallback(() => { + dropHandledRef.current = true + }, []) + + const end = useCallback(() => { + /** + * `replace`, not `push`: the spring-opens are being undone, so they should leave no trace in + * the back stack rather than a trail the user has to walk back out of. + */ + if ( + didSpringOpenRef.current && + !dropHandledRef.current && + originFolderIdRef.current !== currentFolderIdRef.current + ) { + onNavigateRef.current?.(originFolderIdRef.current, { history: 'replace' }) + } + didSpringOpenRef.current = false + dropHandledRef.current = false + hasOriginRef.current = false + springLoad.reset() + }, [springLoad]) + + return useMemo( + () => ({ + rememberOrigin, + arm, + disarm: springLoad.disarm, + markDropHandled, + end, + }), + [rememberOrigin, arm, springLoad.disarm, markDropHandled, end] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 16570ad0070..3aeaaeebbeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -4,8 +4,11 @@ export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' +export type { BulkOutcome } from './resource/bulk-outcome' +export { reportBulkOutcome } from './resource/bulk-outcome' export { FloatingOverflowText } from './resource/components/floating-overflow-text' -export { ownerCell } from './resource/components/owner-cell' +export type { OwnerAvatarProps } from './resource/components/owner-cell' +export { OwnerAvatar, ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, ResourceChromeFallback, @@ -24,7 +27,10 @@ export type { SearchTag, SortConfig, } from './resource/components/resource-options' -export { SortDropdown } from './resource/components/resource-options' +export { + FILTER_SECTION_LABEL_CLASS, + SortDropdown, +} from './resource/components/resource-options' export { timeCell } from './resource/components/time-cell' export type { PaginationConfig, @@ -37,5 +43,8 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { selectionLabel } from './resource/selection-label' +export type { ResourceRowSelection } from './resource/use-resource-row-selection' +export { useResourceRowSelection } from './resource/use-resource-row-selection' export { ResourceTile } from './resource-tile' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts new file mode 100644 index 00000000000..2d2817a8bd0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts @@ -0,0 +1,53 @@ +import { toast } from '@sim/emcn' + +/** An item the batch reached but could not act on, with a reason worth showing. */ +interface BulkFailure { + name: string + reason: string +} + +/** An id that resolved to nothing active — deleted, or not visible to this user. */ +interface BulkMissing { + id: string +} + +export interface BulkOutcome { + failed: BulkFailure[] + notFound: BulkMissing[] +} + +/** + * Reports the parts of a bulk operation that did not happen. + * + * A bulk request succeeds as a whole while individual items are refused (a delete lock, a + * folder cycle) or have vanished since the list was rendered. Those items are the difference + * between what the user selected and what actually changed, so they have to be said out loud — + * the list simply refetching leaves the user to notice a row survived. + * + * Success stays silent, matching the single-item move and delete paths. + * + * @param verb Past-tense verb for the failure sentence, e.g. `'moved'` or `'deleted'`. + */ +export function reportBulkOutcome(outcome: BulkOutcome, verb: string): void { + const { failed, notFound } = outcome + + if (failed.length > 0) { + const [first] = failed + toast.error( + failed.length === 1 + ? `${first.name} could not be ${verb}: ${first.reason}` + : `${failed.length} items could not be ${verb}. ${first.name}: ${first.reason}`, + { duration: 5000 } + ) + return + } + + if (notFound.length > 0) { + toast.error( + notFound.length === 1 + ? `One item was no longer available and was not ${verb}.` + : `${notFound.length} items were no longer available and were not ${verb}.`, + { duration: 5000 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..afbe7593240 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -0,0 +1,163 @@ +'use client' + +import type { ComponentType } from 'react' +import { + Button, + chipFilledFillTokens, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Folder, + Tooltip, + Trash, +} from '@sim/emcn' +import { Download } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' + +/** Shared chrome for every action button, so the bar reads as one control strip. */ +const ACTION_BUTTON_CLASS = cn( + chipFilledFillTokens, + 'hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]' +) + +interface ActionButtonProps { + icon: ComponentType<{ className?: string }> + label: string + onClick: () => void + disabled?: boolean +} + +function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProps) { + return ( + + + + + {label} + + ) +} + +export interface ResourceActionBarProps { + /** The bar is mounted only while this is above zero; it animates in and out on the edges. */ + selectedCount: number + /** Omit on lists with nothing to download (tables, knowledge bases). */ + onDownload?: () => void + /** Both `onMove` and `moveOptions` are required for the move menu to appear. */ + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + onDelete?: () => void + /** Disables every action while a bulk mutation is in flight. */ + isLoading?: boolean + /** + * Largest selection the bulk endpoints accept. Past it the server rejects the whole request, + * so the bar says so and disables the actions rather than letting the user confirm something + * that cannot succeed. + */ + maxSelectable?: number + className?: string +} + +/** + * Floating bulk-action bar for a `Resource.Table` with checkbox selection, shared so Files, + * Tables, and Knowledge present the same strip in the same place. + * + * Actions are ordered to mirror the row context menu — move before delete, destructive last. + * Each action is opt-in: a list that cannot perform one simply omits its handler, and a reader + * omits the ones they lack permission for. + * + * The entrance is a CSS animation rather than framer-motion: this bar is reachable from three + * list pages, and an animation library on that path costs every one of them ~40 modules of page + * weight for one fade. The trade is that dismissal is instant — an exit animation needs presence + * tracking, which is the part that pulls the library back in. + */ +export function ResourceActionBar({ + selectedCount, + onDownload, + onMove, + moveOptions, + onDelete, + isLoading = false, + maxSelectable, + className, +}: ResourceActionBarProps) { + if (selectedCount === 0) return null + + const exceedsLimit = maxSelectable !== undefined && selectedCount > maxSelectable + const actionsDisabled = isLoading || exceedsLimit + + return ( +
+
+ + {exceedsLimit + ? `${selectedCount} selected · select ${maxSelectable} or fewer` + : `${selectedCount} selected`} + +
+ {onDownload && ( + + )} + {onMove && moveOptions && ( + + + + + + + + Move + + + {renderMoveOptions(moveOptions, onMove)} + + + )} + {onDelete && ( + + )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts new file mode 100644 index 00000000000..c2c5faaeb72 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts @@ -0,0 +1,2 @@ +export type { ResourceActionBarProps } from './action-bar' +export { ResourceActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts index fa102e05d3a..22f3365aa43 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts @@ -1 +1,2 @@ -export { ownerCell } from './owner-cell' +export type { OwnerAvatarProps } from './owner-cell' +export { OwnerAvatar, ownerCell } from './owner-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx index 23a845af1b6..ebbf3bb9e9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx @@ -2,12 +2,17 @@ import { memo } from 'react' import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' -interface OwnerAvatarProps { +export interface OwnerAvatarProps { name: string image: string | null } -const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { +/** + * The canonical 14px workspace-member avatar — a photo, or the member's initial on a neutral + * disc. Shared so a member reads identically in a resource row's owner cell and in the + * owner/uploaded-by filter options on every list. + */ +export const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { if (image) { return ( void dropdownItems?: DropdownOption[] @@ -95,6 +103,19 @@ export interface ResourceAction { disabled?: boolean } +/** + * Makes breadcrumb crumbs drag destinations, so a drag can walk back up the tree it walked + * into. Hovering a crumb navigates to it after the same delay a folder row uses, and releasing + * on one files the drag there — the counterpart to spring-loading, which only ever goes deeper. + */ +export interface BreadcrumbDropConfig { + /** Index of the crumb currently under the drag, or `null`. Indexed because `null` is a folder. */ + activeIndex: number | null + onDragOver: (e: DragEvent, folderId: string | null, index: number) => void + onDragLeave: (e: DragEvent, index: number) => void + onDrop: (e: DragEvent, folderId: string | null) => void +} + interface ResourceHeaderProps { icon?: React.ElementType title?: string @@ -109,6 +130,7 @@ interface ResourceHeaderProps { * in `actions`; never stuff primary actions in here. */ aside?: ReactNode + breadcrumbDrop?: BreadcrumbDropConfig } export const ResourceHeader = memo(function ResourceHeader({ @@ -117,6 +139,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, actions, aside, + breadcrumbDrop, }: ResourceHeaderProps) { const headerRef = useRef(null) /** @@ -164,6 +187,22 @@ export const ResourceHeader = memo(function ResourceHeader({ */ const showLocationPopover = LocationIcon != null + /** + * Only a crumb that names a folder is a destination; a trailing detail segment + * has no `folderId` and stays inert. + */ + const crumbDrag = + breadcrumbDrop && crumb.folderId !== undefined + ? { + isActive: breadcrumbDrop.activeIndex === i, + onDragOver: (e: DragEvent) => + breadcrumbDrop.onDragOver(e, crumb.folderId as string | null, i), + onDragLeave: (e: DragEvent) => breadcrumbDrop.onDragLeave(e, i), + onDrop: (e: DragEvent) => + breadcrumbDrop.onDrop(e, crumb.folderId as string | null), + } + : undefined + return ( {i > 0 && ( @@ -177,6 +216,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs={breadcrumbs} className={segmentClassName} veilBoundaryRef={headerRef} + drag={crumbDrag} /> ) : ( )} @@ -269,6 +310,13 @@ interface BreadcrumbSegmentProps { dropdownItems?: DropdownOption[] editing?: BreadcrumbEditing className?: string + /** Drag handlers plus the active flag, when this crumb is a drag destination. */ + drag?: { + isActive: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void + } } const BreadcrumbSegment = memo(function BreadcrumbSegment({ @@ -278,6 +326,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ dropdownItems, editing, className, + drag, }: BreadcrumbSegmentProps) { const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing() const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) => @@ -318,7 +367,18 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ - @@ -345,8 +405,11 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ ) : null} {search.dropdown && (
{search.dropdown}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 87c463c3a63..31ca47ae1bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -16,8 +16,11 @@ import { Button, Checkbox, cellIconNodeClass, + chipActiveSurfaceClass, chipContentGap, chipContentLabelClass, + chipDropTargetSurfaceClass, + chipHoverSurfaceClass, cn, Loader, } from '@sim/emcn' @@ -25,6 +28,7 @@ import { ChevronLeft, ChevronRight, Pin } from '@sim/emcn/icons' import { useVirtualizer } from '@tanstack/react-virtual' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import type { BreadcrumbDropConfig } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' @@ -83,6 +87,20 @@ export interface SelectableConfig { disabled?: boolean } +/** + * Drop onto the list body, which files into the folder currently open. + * + * Rows alone are not enough: a drag that spring-opens into an empty folder has nothing to land + * on, so without this the gesture dead-ends and the item cannot be moved there at all. + */ +export interface BodyDropConfig { + /** The drag is over the body and releasing would move something. */ + isActive: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void +} + export interface RowDragDropConfig { activeDropTargetId?: string | null draggedRowIds?: Set @@ -94,6 +112,9 @@ export interface RowDragDropConfig { onDragLeave?: (e: DragEvent, rowId: string) => void onDrop?: (e: DragEvent, rowId: string) => void onDragEnd?: (e: DragEvent, rowId: string) => void + body?: BodyDropConfig + /** Passed to `Resource.Header` so the breadcrumb can receive the same drag. */ + breadcrumb?: BreadcrumbDropConfig } export interface PaginationConfig { @@ -291,6 +312,7 @@ const ResourceTable = memo(function ResourceTable({ }, [onLoadMore, hasMore]) const hasCheckbox = selectable != null + const bodyDrop = rowDragDrop?.body const handleSelectAll = useCallback( (checked: boolean | 'indeterminate') => { @@ -334,7 +356,13 @@ const ResourceTable = memo(function ResourceTable({ return (
-
+
)}
+ {bodyDrop?.isActive && ( + /** + * A soft tint over the whole list region, not a line around it. This is the workflow + * sidebar's own drop-inside affordance (`bg-[var(--text-subtle)] opacity-10`), and it + * is the right weight here: a hairline stretched around the entire pane reads as a + * window border rather than a drop target, and being painted at the scrollport edge it + * also got its corners shaved by the parent's `overflow-hidden`. A fill has no corners + * to clip and no edge to fight the surrounding chrome. + */ +
+ )} {overlay} {pagination && pagination.totalPages > 1 && ( 0 + /** Hover and active are mutually exclusive, so a selected row holds its surface through hover. */ + const isRowActive = selectedRowId === row.id || isSelected || isContextMenuTarget const handleClick = useCallback( (e: React.MouseEvent) => { @@ -664,16 +708,15 @@ const DataRow = memo(function DataRow({ className={cn( 'grid w-full transition-colors', isWindowed && 'absolute top-0 left-0', - !isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]', + !isAnyDragActive && !isRowActive && chipHoverSurfaceClass, onRowClick && 'cursor-pointer', isDraggable && 'cursor-grab active:cursor-grabbing', - isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]', - (selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]', - isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]', + isRowActive && chipActiveSurfaceClass, + /** See {@link chipDropTargetSurfaceClass} for why this is neutral and drawn inset. */ + isActiveDropTarget && chipDropTargetSurfaceClass, (isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50' )} style={rowStyle} - data-drop-target={isDropTarget || undefined} draggable={isDraggable} onClick={onRowClick || selectable ? handleClick : undefined} onMouseEnter={handleMouseEnter} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts new file mode 100644 index 00000000000..fc812dbee55 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts @@ -0,0 +1,9 @@ +/** + * Names a multi-row selection for a confirmation prompt: one row reads as itself, several read + * as a count. Shared so the wording stays identical across every resource list — the phrasing + * appears in destructive confirms, where an inconsistency reads as a different action. + */ +export function selectionLabel(count: number, firstName: string | undefined): string { + if (count === 1) return firstName ?? 'selected item' + return `${count} selected items` +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx new file mode 100644 index 00000000000..827b8edc235 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx @@ -0,0 +1,173 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type ResourceRowSelection, + type UseResourceRowSelectionOptions, + useResourceRowSelection, +} from '@/app/workspace/[workspaceId]/components/resource/use-resource-row-selection' + +/** Trees rendered by a test, torn down in afterEach so listeners do not leak across tests. */ +const mountedRoots: Root[] = [] + +interface Harness { + getResult: () => ResourceRowSelection + /** Re-renders with new options, as a parent would when its rows change. */ + rerender: (options: UseResourceRowSelectionOptions) => void +} + +function renderSelection(initialOptions: UseResourceRowSelectionOptions): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + mountedRoots.push(root) + let result: ResourceRowSelection | undefined + + function Probe({ options }: { options: UseResourceRowSelectionOptions }) { + result = useResourceRowSelection(options) + return null + } + + const render = (options: UseResourceRowSelectionOptions) => { + act(() => { + root.render() + }) + } + + render(initialOptions) + + return { + getResult: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +function pressKey(key: string, init: KeyboardEventInit = {}) { + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + document.body.innerHTML = '' +}) + +const ROWS = ['a', 'b', 'c', 'd'] + +describe('useResourceRowSelection', () => { + it('adds and removes a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('b', true)) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('extends a shift-click range from the last anchor', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('a', true)) + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c']) + }) + + it('treats a shift-click with no anchor as a plain click', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds]).toEqual(['c']) + }) + + it('reports isAllSelected only once every visible row is selected', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c', 'd']) + expect(getResult().selectable.isAllSelected).toBe(true) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectable.isAllSelected).toBe(false) + }) + + it('drops rows that are no longer visible', () => { + const { getResult, rerender } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + rerender({ visibleRowIds: ['a', 'c'] }) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'c']) + }) + + it('replaceSelection collapses onto the given rows and re-anchors a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + act(() => getResult().replaceSelection(['b'])) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + // 'b' became the anchor, so a shift-click on 'd' fills the range from there. + act(() => getResult().selectable.onSelectRow('d', true, true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['b', 'c', 'd']) + }) + + it('selects every visible row on Cmd+A and clears on Escape', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + + pressKey('Escape') + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('calls onDeleteSelected for Delete only while rows are selected', () => { + const onDeleteSelected = vi.fn() + const { getResult } = renderSelection({ visibleRowIds: ROWS, onDeleteSelected }) + + pressKey('Delete') + expect(onDeleteSelected).not.toHaveBeenCalled() + + act(() => getResult().selectable.onSelectRow('a', true)) + pressKey('Delete') + expect(onDeleteSelected).toHaveBeenCalledTimes(1) + }) + + it('ignores shortcuts while blocked or while a text field has focus', () => { + const onDeleteSelected = vi.fn() + const blocked = { current: true } + const { getResult } = renderSelection({ + visibleRowIds: ROWS, + isKeyboardBlocked: () => blocked.current, + onDeleteSelected, + }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + blocked.current = false + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + input.blur() + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts new file mode 100644 index 00000000000..a4852fcb709 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts @@ -0,0 +1,210 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { SelectableConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** Shared empty set so an empty selection keeps a stable identity across renders. */ +const EMPTY_ROW_IDS = new Set() + +/** Sentinel for "no shift-range anchor", so index 0 stays a usable anchor. */ +const NO_ANCHOR = -1 + +/** + * True while a text-entry surface owns the keystroke, so the list shortcuts never eat a + * character the user is typing into a rename field, a search box, or an editor. + */ +function isTypingTarget(): boolean { + const active = document.activeElement + if (!active) return false + return ( + active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA' || + (active as HTMLElement).isContentEditable + ) +} + +export interface UseResourceRowSelectionOptions { + /** + * Row ids currently rendered, in display order. Selection is pruned to this list whenever it + * changes (navigating into a folder, applying a filter) and shift-ranges walk it, so it must + * be the same array identity across renders that do not change the rows. + */ + visibleRowIds: string[] + /** + * Blocks the keyboard shortcuts while another surface owns the keystroke — a detail view open + * over the list, an inline rename in progress, a modal. Text inputs are already excluded. + */ + isKeyboardBlocked?: () => boolean + /** Bound to Delete/Backspace on a non-empty selection. Omit to leave those keys unbound. */ + onDeleteSelected?: () => void +} + +export interface ResourceRowSelection { + selectedRowIds: Set + /** Passed straight to `Resource.Table`'s `selectable` prop. */ + selectable: SelectableConfig + /** Collapses the selection to exactly these rows, e.g. a plain row click or a drag start. */ + replaceSelection: (rowIds: Iterable) => void + clearSelection: () => void +} + +/** + * Checkbox selection for a `Resource.Table` list: click, shift-click ranges, select-all, and the + * Cmd/Ctrl+A · Escape · Delete shortcuts, shared so Files, Tables, and Knowledge select + * identically rather than each re-deriving the same state machine. + * + * Selection is keyed by *row* id, not resource id, so a foldered list can hold folder rows and + * resource rows in one selection; consumers split it back out with `parseFolderedRowId`. + */ +export function useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked, + onDeleteSelected, +}: UseResourceRowSelectionOptions): ResourceRowSelection { + const [selectedRowIds, setSelectedRowIds] = useState>(() => EMPTY_ROW_IDS) + + /** Anchor for shift-click ranges — an index into `visibleRowIds`, not a row id. */ + const anchorIndexRef = useRef(NO_ANCHOR) + + const visibleRowIdsRef = useRef(visibleRowIds) + visibleRowIdsRef.current = visibleRowIds + const isKeyboardBlockedRef = useRef(isKeyboardBlocked) + isKeyboardBlockedRef.current = isKeyboardBlocked + const onDeleteSelectedRef = useRef(onDeleteSelected) + onDeleteSelectedRef.current = onDeleteSelected + + const clearSelection = useCallback(() => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => (prev.size === 0 ? prev : EMPTY_ROW_IDS)) + }, []) + + const replaceSelection = useCallback((rowIds: Iterable) => { + const next = new Set(rowIds) + /** + * A single row becomes the next shift anchor; a multi-row replacement has no meaningful + * anchor, so the following shift-click starts a fresh range instead of extending from a + * row the user never clicked. + */ + let anchor = NO_ANCHOR + if (next.size === 1) { + for (const rowId of next) anchor = visibleRowIdsRef.current.indexOf(rowId) + } + anchorIndexRef.current = anchor + setSelectedRowIds(next) + }, []) + + /** + * Rows that left the list — navigating into a folder, applying a filter — are gone as far as + * selection is concerned, otherwise a bulk action would silently operate on rows the user can + * no longer see. Compared by identity because `visibleRowIds` is memoized upstream and only + * changes when the rows really change. + */ + const prevVisibleRowIdsRef = useRef(visibleRowIds) + useEffect(() => { + if (prevVisibleRowIdsRef.current === visibleRowIds) return + /** + * Identity is only a cheap first test — it changes for reasons that are not list changes. + * Both foldered pages rebuild every row on each inline-rename keystroke (the edit value + * lives in the row memo), so a rename would otherwise clear the shift anchor mid-edit and + * the next shift-click would start a fresh range instead of extending the user's. + */ + const unchanged = + prevVisibleRowIdsRef.current.length === visibleRowIds.length && + prevVisibleRowIdsRef.current.every((rowId, index) => rowId === visibleRowIds[index]) + prevVisibleRowIdsRef.current = visibleRowIds + if (unchanged) return + anchorIndexRef.current = NO_ANCHOR + const visible = new Set(visibleRowIds) + setSelectedRowIds((prev) => { + if (prev.size === 0) return prev + const next = new Set() + for (const rowId of prev) if (visible.has(rowId)) next.add(rowId) + return next.size === prev.size ? prev : next + }) + }, [visibleRowIds]) + + /** + * The size check short-circuits the common case (a selection smaller than the list) in O(1); + * this runs on every render of the page, including each one a drag triggers. + */ + const isAllSelected = + visibleRowIds.length > 0 && + selectedRowIds.size >= visibleRowIds.length && + visibleRowIds.every((rowId) => selectedRowIds.has(rowId)) + + const selectable = useMemo( + () => ({ + selectedIds: selectedRowIds, + isAllSelected, + onSelectRow: (rowId, checked, shiftKey) => { + const currentIndex = visibleRowIds.indexOf(rowId) + if (shiftKey && anchorIndexRef.current !== NO_ANCHOR && currentIndex !== NO_ANCHOR) { + const start = Math.min(anchorIndexRef.current, currentIndex) + const end = Math.max(anchorIndexRef.current, currentIndex) + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) + return next + }) + anchorIndexRef.current = currentIndex + return + } + setSelectedRowIds((prev) => { + const next = new Set(prev) + if (checked) next.add(rowId) + else next.delete(rowId) + return next + }) + anchorIndexRef.current = checked ? currentIndex : NO_ANCHOR + }, + onSelectAll: (checked) => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (const rowId of visibleRowIds) { + if (checked) next.add(rowId) + else next.delete(rowId) + } + return next + }) + }, + disabled: false, + }), + [selectedRowIds, isAllSelected, visibleRowIds] + ) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isKeyboardBlockedRef.current?.()) return + if (isTypingTarget()) return + + const hasSelection = selectedRowIdsRef.current.size > 0 + + if ((e.key === 'Delete' || e.key === 'Backspace') && hasSelection) { + if (!onDeleteSelectedRef.current) return + e.preventDefault() + onDeleteSelectedRef.current() + return + } + + if (e.key === 'Escape' && hasSelection) { + e.preventDefault() + clearSelection() + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { + e.preventDefault() + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds(new Set(visibleRowIdsRef.current)) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [clearSelection]) + + return { selectedRowIds, selectable, replaceSelection, clearSelection } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx deleted file mode 100644 index 53ebe27ba84..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' -import { - Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Folder, - Tooltip, - Trash, -} from '@sim/emcn' -import { Download } from '@sim/emcn/icons' -import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' -import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' - -interface FilesActionBarProps { - selectedCount: number - onDownload?: () => void - onMove?: (optionValue: string) => void - moveOptions?: MoveOptionNode[] - onDelete?: () => void - isLoading?: boolean - className?: string -} - -export function FilesActionBar({ - selectedCount, - onDownload, - onMove, - moveOptions, - onDelete, - isLoading = false, - className, -}: FilesActionBarProps) { - return ( - - - {selectedCount > 0 && ( - -
- - {selectedCount} selected - -
- {onDownload && ( - - - - - Download - - )} - {onMove && moveOptions && ( - - - - - - - - Move - - - {moveOptions.length > 0 && ( - onMove(moveOptions[0].value)}> - - {moveOptions[0].label} - - )} - {moveOptions.length > 1 && } - {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} - - - )} - {onDelete && ( - - - - - Delete - - )} -
-
-
- )} -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts deleted file mode 100644 index aa19162a077..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilesActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..b29e169914b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1,6 +1,6 @@ 'use client' -import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, ChipCombobox, @@ -51,15 +51,18 @@ import type { ResourceAction, ResourceColumn, ResourceRow, - RowDragDropConfig, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -67,14 +70,19 @@ import type { } from '@/app/workspace/[workspaceId]/components/folders' import { breadcrumbFolderChain, + buildDescendantIndex, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, folderedResourceListHref, + folderRowId, + parseFolderedRowId, parseMoveOptionValue, - ROOT_MOVE_OPTION_VALUE, sortResources, + splitFolderedRowIds, + useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' -import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -141,6 +149,12 @@ type FileListEntry = const logger = createLogger('Files') +/** + * This list's private drag MIME, so a drag started on another list is never mistaken for one of + * these rows. + */ +const FILE_ROW_DRAG_MIME = 'application/x-sim-workspace-file-rows' + const FILES_HEADER = FOLDERED_RESOURCE_HEADERS.file const FOLDER_ICON = @@ -192,14 +206,6 @@ const MIME_TYPE_LABELS: Record = { const EMPTY_WORKSPACE_FILES: WorkspaceFileRecord[] = [] const EMPTY_WORKSPACE_FILE_FOLDERS: WorkspaceFileFolderApi[] = [] -const fileRowId = (id: string) => `file:${id}` -const folderRowId = (id: string) => `folder:${id}` -const parseRowId = (rowId: string): { kind: 'file' | 'folder'; id: string } => { - if (rowId.startsWith('folder:')) return { kind: 'folder', id: rowId.slice('folder:'.length) } - if (rowId.startsWith('file:')) return { kind: 'file', id: rowId.slice('file:'.length) } - return { kind: 'file', id: rowId } -} - const hasExternalFiles = (dataTransfer: DataTransfer): boolean => dataTransfer.types.includes('Files') @@ -296,17 +302,39 @@ export function Files() { const justCreatedFileIdRef = useRef(null) const filesRef = useRef(files) filesRef.current = files - const foldersRef = useRef(folders) - foldersRef.current = folders + /** + * Indexed once. The drag hook resolves each dragged row's placement inside `dragover`, which + * fires continuously — a linear scan there is O(selection x resources) per event. + */ + const fileById = useMemo(() => { + const byId = new Map() + for (const file of files) byId.set(file.id, file) + return byId + }, [files]) + const fileByIdRef = useRef(fileById) + fileByIdRef.current = fileById - const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0, currentPercent: 0, }) + /** An upload batch is in flight exactly while a total is set — matches the Tables page. */ + const uploading = uploadProgress.total > 0 const [isDraggingOver, setIsDraggingOver] = useState(false) const dragCounterRef = useRef(0) + /** + * Takes down the "Drop to upload" overlay. + * + * Every path that consumes an OS file drag has to call this, including the one that never + * reaches the page-level handler: a drop on a folder row is handled by the drag hook, which + * stops propagation, so `handleDrop` below never runs and the counter it would have zeroed + * keeps the overlay on screen over the finished upload. + */ + const dismissUploadOverlay = useCallback(() => { + dragCounterRef.current = 0 + setIsDraggingOver(false) + }, []) const [ { search: urlSearchTerm, type: typeFilter, size: sizeFilter, uploadedBy: uploadedByFilter }, setFileFilters, @@ -347,9 +375,6 @@ export function Files() { const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') - const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) - const [activeDropTargetId, setActiveDropTargetId] = useState(null) - const [draggedRowIds, setDraggedRowIds] = useState>(() => new Set()) const [previewMode, setPreviewMode] = useState(() => { if (isNewFile) return 'editor' if (fileIdFromRoute) { @@ -362,9 +387,6 @@ export function Files() { const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const contextMenuItemRef = useRef(null) - const lastSelectedIndexRef = useRef(-1) - const draggedRowIdsRef = useRef([]) - const dragGhostRef = useRef(null) const [deleteTarget, setDeleteTarget] = useState<{ fileIds: string[] folderIds: string[] @@ -373,7 +395,7 @@ export function Files() { const listRename = useInlineRename({ onSave: (rowId, name) => { - const parsed = parseRowId(rowId) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { return updateFolder.mutateAsync({ workspaceId, folderId: parsed.id, updates: { name } }) } @@ -440,6 +462,8 @@ export function Files() { ) : null const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) + const folderByIdRef = useRef(folderById) + folderByIdRef.current = folderById const folderSizeMap = useMemo(() => { const directSize = new Map() @@ -628,7 +652,7 @@ export function Files() { const { file } = item const Icon = getDocumentIcon(file.type || '', file.name) return { - id: fileRowId(file.id), + id: file.id, cells: { name: { icon: , @@ -676,135 +700,23 @@ export function Files() { const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) - const prevVisibleRowIdsRef = useRef(visibleRowIds) - useEffect(() => { - if (prevVisibleRowIdsRef.current === visibleRowIds) return - prevVisibleRowIdsRef.current = visibleRowIds - lastSelectedIndexRef.current = -1 - const visible = new Set(visibleRowIds) - setSelectedRowIds((prev) => { - if (prev.size === 0) return prev - const next = new Set(Array.from(prev).filter((id) => visible.has(id))) - return next.size === prev.size ? prev : next - }) - }, [visibleRowIds]) - - const isAllSelected = - visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) - const { selectedFileIds, selectedFolderIds } = useMemo(() => { - const fileIds: string[] = [] - const folderIds: string[] = [] - for (const rowId of selectedRowIds) { - const item = parseRowId(rowId) - if (item.kind === 'file') fileIds.push(item.id) - else folderIds.push(item.id) - } - return { selectedFileIds: fileIds, selectedFolderIds: folderIds } - }, [selectedRowIds]) + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => Boolean(fileIdFromRoute) || listRename.editingId !== null, + onDeleteSelected: () => handleBulkDelete(), + }) - const selectableConfig = useMemo( - () => ({ - selectedIds: selectedRowIds, - isAllSelected, - onSelectRow: (rowId: string, checked: boolean, shiftKey?: boolean) => { - const currentIndex = visibleRowIds.indexOf(rowId) - if (shiftKey && lastSelectedIndexRef.current !== -1 && currentIndex !== -1) { - const start = Math.min(lastSelectedIndexRef.current, currentIndex) - const end = Math.max(lastSelectedIndexRef.current, currentIndex) - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) - return next - }) - lastSelectedIndexRef.current = currentIndex - } else { - setSelectedRowIds((prev) => { - const next = new Set(prev) - if (checked) next.add(rowId) - else next.delete(rowId) - return next - }) - if (checked) lastSelectedIndexRef.current = currentIndex - else lastSelectedIndexRef.current = -1 - } - }, - onSelectAll: (checked: boolean) => { - lastSelectedIndexRef.current = -1 - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (const rowId of visibleRowIds) { - if (checked) next.add(rowId) - else next.delete(rowId) - } - return next - }) - }, - disabled: false, - }), - [selectedRowIds, isAllSelected, visibleRowIds] + const { folderIds: selectedFolderIds, resourceIds: selectedFileIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] ) - const descendantFolderIdsByFolderId = useMemo(() => { - const childrenByParent = new Map() - for (const folder of folders) { - if (!folder.parentId) continue - const children = childrenByParent.get(folder.parentId) ?? [] - children.push(folder.id) - childrenByParent.set(folder.parentId, children) - } - - const result = new Map>() - const collect = (folderId: string, seen = new Set()): Set => { - const cached = result.get(folderId) - if (cached) return cached - if (seen.has(folderId)) return new Set() - - const nextSeen = new Set(seen) - nextSeen.add(folderId) - const descendants = new Set() - for (const childId of childrenByParent.get(folderId) ?? []) { - if (nextSeen.has(childId)) continue - descendants.add(childId) - for (const nestedId of collect(childId, nextSeen)) { - descendants.add(nestedId) - } - } - result.set(folderId, descendants) - return descendants - } - - for (const folder of folders) { - collect(folder.id) - } - return result - }, [folders]) - - const isInvalidDropTarget = useCallback( - (targetRowId: string, sourceRowIds: string[]) => { - const target = parseRowId(targetRowId) - if (target.kind !== 'folder') return true - - for (const sourceRowId of sourceRowIds) { - const source = parseRowId(sourceRowId) - if (source.kind !== 'folder') continue - if (source.id === target.id) return true - if (descendantFolderIdsByFolderId.get(source.id)?.has(target.id)) return true - } - - // Reject drop if every dragged item is already a direct child of the target - const allAlreadyInTarget = sourceRowIds.every((sourceRowId) => { - const source = parseRowId(sourceRowId) - if (source.kind === 'file') { - return filesRef.current.find((f) => f.id === source.id)?.folderId === target.id - } - return (foldersRef.current.find((f) => f.id === source.id)?.parentId ?? null) === target.id - }) - if (allAlreadyInTarget) return true - - return false - }, - [descendantFolderIdsByFolderId] - ) + const descendantFolderIdsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) const uploadFiles = useCallback( async (filesToUpload: File[], targetFolderId = currentFolderId) => { @@ -841,7 +753,6 @@ export function Files() { if (allowedFiles.length === 0) return try { - setUploading(true) setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) for (let i = 0; i < allowedFiles.length; i++) { @@ -872,159 +783,51 @@ export function Files() { } catch (err) { logger.error('Error uploading file:', err) } finally { - setUploading(false) setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) } }, [workspaceId, canEdit, currentFolderId, notifyLimit] ) - const rowDragDropConfig = useMemo( - () => ({ - activeDropTargetId, - draggedRowIds, - isAnyDragActive: draggedRowIds.size > 0, - isRowDraggable: (rowId) => canEdit && listRename.editingId !== rowId, - isRowDropTarget: (rowId) => canEdit && parseRowId(rowId).kind === 'folder', - onDragStart: (e: DragEvent, rowId) => { - if (!canEdit || listRename.editingId === rowId) { - e.preventDefault() - return - } - - const sourceRowIds = selectedRowIds.has(rowId) - ? visibleRowIds.filter((visibleRowId) => selectedRowIds.has(visibleRowId)) - : [rowId] - - draggedRowIdsRef.current = sourceRowIds - setDraggedRowIds(new Set(sourceRowIds)) - if (!selectedRowIds.has(rowId)) { - setSelectedRowIds(new Set([rowId])) - } - - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData( - 'application/x-sim-workspace-file-rows', - JSON.stringify(sourceRowIds) - ) - e.dataTransfer.setData('text/plain', sourceRowIds.join(',')) - - const count = sourceRowIds.length - const firstParsed = parseRowId(sourceRowIds[0]) - const firstName = - firstParsed.kind === 'file' - ? filesRef.current.find((f) => f.id === firstParsed.id)?.name - : foldersRef.current.find((f) => f.id === firstParsed.id)?.name - const ghostLabel = - count > 1 ? `${firstName ?? 'Items'} +${count - 1} more` : (firstName ?? 'Item') - const ghost = document.createElement('div') - ghost.style.cssText = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = ghostLabel - ghost.appendChild(text) - document.body.appendChild(ghost) - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost - }, - onDragOver: (e: DragEvent, rowId) => { - const sourceRowIds = draggedRowIdsRef.current - const isExternalFileDrag = hasExternalFiles(e.dataTransfer) - if (!isExternalFileDrag && isInvalidDropTarget(rowId, sourceRowIds)) return - - e.preventDefault() - e.stopPropagation() - e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' - setActiveDropTargetId(rowId) - }, - onDragLeave: (e: DragEvent, rowId) => { - const relatedTarget = e.relatedTarget - if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return - setActiveDropTargetId((current) => (current === rowId ? null : current)) - }, - onDrop: (e: DragEvent, rowId) => { - e.preventDefault() - e.stopPropagation() - dragCounterRef.current = 0 - setIsDraggingOver(false) - setActiveDropTargetId(null) - const target = parseRowId(rowId) - if (target.kind !== 'folder') return - - const droppedFiles = Array.from(e.dataTransfer.files ?? []) - if (droppedFiles.length > 0) { - void uploadFiles(droppedFiles, target.id) - return - } - - let sourceRowIds = draggedRowIdsRef.current - const rawSource = e.dataTransfer.getData('application/x-sim-workspace-file-rows') - if (rawSource) { - try { - const parsedSource = JSON.parse(rawSource) - if (Array.isArray(parsedSource)) { - sourceRowIds = parsedSource.filter( - (source): source is string => typeof source === 'string' && source.length > 0 - ) - } - } catch { - sourceRowIds = draggedRowIdsRef.current - } - } - - if (isInvalidDropTarget(rowId, sourceRowIds)) return - - const fileIds = sourceRowIds - .map(parseRowId) - .filter((source) => source.kind === 'file') - .map((source) => source.id) - const folderIds = sourceRowIds - .map(parseRowId) - .filter((source) => source.kind === 'folder') - .map((source) => source.id) - - if (fileIds.length === 0 && folderIds.length === 0) return - - void moveItems - .mutateAsync({ - workspaceId, - fileIds, - folderIds, - targetFolderId: target.id, - }) - .then(() => { - setSelectedRowIds(new Set()) - }) - .catch((error) => { - logger.error('Failed to move items via drag and drop:', error) - }) - }, - onDragEnd: () => { - if (dragGhostRef.current) { - dragGhostRef.current.remove() - dragGhostRef.current = null - } - dragCounterRef.current = 0 - draggedRowIdsRef.current = [] - setDraggedRowIds(new Set()) - setIsDraggingOver(false) - setActiveDropTargetId(null) + const rowDragDropConfig = useFolderRowDragDrop({ + dragMime: FILE_ROW_DRAG_MIME, + canEdit, + editingRowId: listRename.editingId, + descendantsByFolderId: descendantFolderIdsByFolderId, + getFolderParentId: (folderId) => folderByIdRef.current.get(folderId)?.parentId ?? null, + getResourceFolderId: (fileId) => fileByIdRef.current.get(fileId)?.folderId ?? null, + getRowLabel: (rowId) => { + const parsed = parseFolderedRowId(rowId) + return parsed.kind === 'folder' + ? (folderByIdRef.current.get(parsed.id)?.name ?? 'Folder') + : (fileByIdRef.current.get(parsed.id)?.name ?? 'File') + }, + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => { + void moveItems + .mutateAsync({ workspaceId, fileIds: resourceIds, folderIds, targetFolderId }) + .then(() => clearSelection()) + .catch((error) => logger.error('Failed to move items:', error)) + }, + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: (folderId, options) => { + void setFilesParams({ folderId, new: null }, options) + }, + currentFolderId, + /** + * The one thing this list does that the others do not. Folder rows still highlight and + * spring open for an OS file drag — filing an upload into a nested folder is the same + * gesture — while the body and breadcrumb decline so the page-level "Drop to upload" + * overlay owns those regions instead of competing with them. + */ + externalDrop: { + matches: hasExternalFiles, + onDropIntoFolder: (dataTransfer, targetFolderId) => { + dismissUploadOverlay() + const dropped = Array.from(dataTransfer.files ?? []) + if (dropped.length > 0) void uploadFiles(dropped, targetFolderId) }, - }), - [ - activeDropTargetId, - draggedRowIds, - canEdit, - listRename.editingId, - selectedRowIds, - visibleRowIds, - isInvalidDropTarget, - uploadFiles, - workspaceId, - ] - ) + }, + }) const handleFileChange = async (e: React.ChangeEvent) => { const list = e.target.files @@ -1055,8 +858,13 @@ export function Files() { const handleDrop = async (e: React.DragEvent) => { if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() - dragCounterRef.current = 0 - setIsDraggingOver(false) + /** + * The upload lands in the folder currently open, so the view must stay there. Without this + * the window-level teardown treats the drag as unconsumed and returns to the folder it + * began in — pulling the user out of the folder they just spring-opened to receive it. + */ + rowDragDropConfig.externalDropHandled() + dismissUploadOverlay() const dropped = Array.from(e.dataTransfer.files) if (dropped.length > 0) await uploadFiles(dropped) } @@ -1106,7 +914,7 @@ export function Files() { } setShowDeleteConfirm(false) setDeleteTarget(null) - setSelectedRowIds(new Set()) + clearSelection() if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') @@ -1179,12 +987,11 @@ export function Files() { setDeleteTarget({ fileIds: selectedFileIds, folderIds: selectedFolderIds, - name: - selectedFileIds.length + selectedFolderIds.length === 1 - ? (files.find((file) => file.id === selectedFileIds[0])?.name ?? - folders.find((folder) => folder.id === selectedFolderIds[0])?.name ?? - 'selected item') - : `${selectedFileIds.length + selectedFolderIds.length} selected items`, + name: selectionLabel( + selectedFileIds.length + selectedFolderIds.length, + files.find((file) => file.id === selectedFileIds[0])?.name ?? + folders.find((folder) => folder.id === selectedFolderIds[0])?.name + ), }) setShowDeleteConfirm(true) }, [selectedFileIds, selectedFolderIds, files, folders]) @@ -1352,7 +1159,7 @@ export function Files() { const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const parsed = parseRowId(rowId) + const parsed = parseFolderedRowId(rowId) const item = parsed.kind === 'folder' ? folders.find((folder) => folder.id === parsed.id) @@ -1363,12 +1170,11 @@ export function Files() { ? { kind: 'folder', id: parsed.id, folder: item as WorkspaceFileFolderApi } : { kind: 'file', id: parsed.id, file: item as WorkspaceFileRecord } if (!selectedRowIds.has(rowId)) { - lastSelectedIndexRef.current = visibleRowIds.indexOf(rowId) - setSelectedRowIds(new Set([rowId])) + replaceSelection([rowId]) } openContextMenu(e) }, - [folders, openContextMenu, selectedRowIds, visibleRowIds] + [folders, openContextMenu, selectedRowIds] ) const handleContextMenuOpen = useCallback(() => { @@ -1390,7 +1196,7 @@ export function Files() { const handleContextMenuDownload = useCallback(() => { const item = contextMenuItemRef.current if (!item) return - const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + const rowId = item.kind === 'file' ? item.file.id : folderRowId(item.folder.id) if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { void handleBulkDownload() closeContextMenu() @@ -1408,7 +1214,7 @@ export function Files() { const handleContextMenuRename = useCallback(() => { const item = contextMenuItemRef.current - if (item?.kind === 'file') listRename.startRename(fileRowId(item.file.id), item.file.name) + if (item?.kind === 'file') listRename.startRename(item.file.id, item.file.name) if (item?.kind === 'folder') listRename.startRename(folderRowId(item.folder.id), item.folder.name) closeContextMenu() @@ -1423,7 +1229,7 @@ export function Files() { const handleContextMenuDelete = useCallback(() => { const item = contextMenuItemRef.current if (!item) return - const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + const rowId = item.kind === 'file' ? item.file.id : folderRowId(item.folder.id) if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { handleBulkDelete() closeContextMenu() @@ -1459,7 +1265,7 @@ export function Files() { folderIds: selectedFolderIds, targetFolderId, }) - setSelectedRowIds(new Set()) + clearSelection() closeContextMenu() } catch (error) { logger.error('Failed to move items:', error) @@ -1532,49 +1338,6 @@ export function Files() { return () => window.removeEventListener('keydown', handleKeyDown) }, [handleSave]) - const selectedRowIdsRef = useRef(selectedRowIds) - selectedRowIdsRef.current = selectedRowIds - const visibleRowIdsRef = useRef(visibleRowIds) - visibleRowIdsRef.current = visibleRowIds - const listRenameActiveRef = useRef(listRename.editingId) - listRenameActiveRef.current = listRename.editingId - const handleBulkDeleteRef = useRef(handleBulkDelete) - handleBulkDeleteRef.current = handleBulkDelete - - useEffect(() => { - const handleListKeyDown = (e: KeyboardEvent) => { - if (fileIdFromRouteRef.current) return - const active = document.activeElement - if ( - active && - (active.tagName === 'INPUT' || - active.tagName === 'TEXTAREA' || - (active as HTMLElement).isContentEditable) - ) - return - if (listRenameActiveRef.current) return - - if ((e.key === 'Delete' || e.key === 'Backspace') && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - handleBulkDeleteRef.current() - return - } - - if (e.key === 'Escape' && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - setSelectedRowIds(new Set()) - return - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { - e.preventDefault() - setSelectedRowIds(new Set(visibleRowIdsRef.current)) - } - } - window.addEventListener('keydown', handleListKeyDown) - return () => window.removeEventListener('keydown', handleListKeyDown) - }, []) - const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -1662,7 +1425,7 @@ export function Files() { const handleRowClick = useCallback( (rowId: string) => { if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { - const parsed = parseRowId(rowId) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { void setFilesParams({ folderId: parsed.id, new: null }) return @@ -1692,21 +1455,21 @@ export function Files() { { id: 'file-delete', handler: () => handleDeleteSelected() }, ]) - const searchConfig: SearchConfig = { - value: urlSearchTerm, - onChange: setSearchTerm, - onClearAll: () => setSearchTerm(''), - placeholder: 'Search files...', - } + const searchConfig: SearchConfig = useMemo( + () => ({ + value: urlSearchTerm, + onChange: setSearchTerm, + onClearAll: () => setSearchTerm(''), + placeholder: 'Search files...', + }), + [urlSearchTerm, setSearchTerm] + ) - const uploadButtonLabel = - uploading && uploadProgress.total > 0 - ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 - ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` - : `${uploadProgress.completed}/${uploadProgress.total}` - : uploading - ? 'Uploading...' - : 'Upload' + const uploadButtonLabel = uploading + ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 + ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` + : `${uploadProgress.completed}/${uploadProgress.total}` + : 'Upload' const headerActionsConfig = useMemo( () => [ @@ -1827,45 +1590,21 @@ export function Files() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) - const contextMenuMoveOptions = useMemo((): MoveOptionNode[] => { - // Index children by parent ONCE (the same pattern used for folder sizes + descendant maps above), - // so building the tree is O(N) instead of a full `folders.filter` scan at every node (O(N²)). - const childrenByParent = new Map() - for (const f of folders) { - const key = f.parentId ?? null - const arr = childrenByParent.get(key) - if (arr) arr.push(f) - else childrenByParent.set(key, [f]) - } - const buildSubtree = (parentId: string | null): MoveOptionNode[] => - (childrenByParent.get(parentId) ?? []) - .filter((f) => { - if (selectedFolderIds.includes(f.id)) return false - return selectedFolderIds.every( - (sid) => !descendantFolderIdsByFolderId.get(sid)?.has(f.id) - ) - }) - .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - .map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) })) - - return [{ value: ROOT_MOVE_OPTION_VALUE, label: 'Files', children: [] }, ...buildSubtree(null)] - }, [folders, selectedFolderIds, descendantFolderIdsByFolderId]) + const contextMenuMoveOptions = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Files', + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIdsByFolderId, + }), + [folders, selectedFolderIds, descendantFolderIdsByFolderId] + ) const sortConfig: SortConfig = useMemo( () => ({ @@ -1921,7 +1660,7 @@ export function Files() { return (
- File Type + File Type
- Size + Size {memberOptions.length > 0 && (
- Uploaded By + Uploaded By ({ content: filterContent }), [filterContent]) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (typeFilter.length > 0) { @@ -2124,13 +1866,14 @@ export function Files() { icon={FILES_HEADER.rootIcon} title={FILES_HEADER.rootLabel} breadcrumbs={listBreadcrumbs} + breadcrumbDrop={rowDragDropConfig.breadcrumb} actions={headerActionsConfig} /> - {isDraggingOver ? ( -
+
-
-

Drop to upload

-

- Release files here to add them to this workspace -

-
+

Drop to upload

) : null} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index ba923cc1ffd..449e1dbe83a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -23,7 +23,12 @@ import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, } from '@/hooks/queries/credentials' -import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { + buildSlackManifest, + getSlackManagedUserAuthorizationManifestConfig, + SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +} from '@/triggers/slack/capabilities' const logger = createLogger('ConnectSlackBotModal') @@ -31,11 +36,16 @@ const DEFAULT_APP_NAME = 'Sim Bot' const DONE_STEP = 4 /** Every capability is granted by default; trimming is an opt-in dropdown. */ -const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id)) +const CUSTOM_BOT_CAPABILITIES = [ + ...SLACK_CAPABILITIES, + SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY, +] as const + +const ALL_CAPABILITIES = new Set(CUSTOM_BOT_CAPABILITIES.map((capability) => capability.id)) -const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({ - value: c.id, - label: c.label, +const CAPABILITY_OPTIONS: ChipDropdownOption[] = CUSTOM_BOT_CAPABILITIES.map((capability) => ({ + value: capability.id, + label: capability.label, })) interface ConnectSlackBotModalProps { @@ -118,10 +128,14 @@ export function ConnectSlackBotModal({ ) const manifestJson = useMemo(() => { + const managedUserAuthorization = selected.has(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id) + ? getSlackManagedUserAuthorizationManifestConfig(getBaseUrl()) + : undefined const manifest = buildSlackManifest(selected, { appName: appName.trim() || DEFAULT_APP_NAME, webhookUrl: requestUrl, description: appDescription, + ...(managedUserAuthorization ? { managedUserAuthorization } : {}), }) return JSON.stringify(manifest, null, 2) }, [selected, appName, appDescription, requestUrl]) @@ -269,7 +283,7 @@ function StepConfigure({ capabilityIds, onCapabilityIdsChange, }: StepConfigureProps) { - const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length + const allSelected = capabilityIds.length === CUSTOM_BOT_CAPABILITIES.length return (
@@ -310,7 +324,7 @@ function StepConfigure({ {allSelected && (

Full access — the bot can read and send messages, react, upload files, and chat as an AI - assistant. + assistant, and people can authorize it through Credential Groups.

)}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 2b1ae99f392..f4da0081688 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -46,7 +46,6 @@ import { } from '@/hooks/queries/credentials' import { useConnectOAuthService, - useDisconnectOAuthService, useOAuthConnections, } from '@/hooks/queries/oauth/oauth-connections' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -74,7 +73,6 @@ export function ConnectedCredentialDetail({ const { data: oauthConnections = [] } = useOAuthConnections() const connectOAuthService = useConnectOAuthService() - const disconnectOAuthService = useDisconnectOAuthService() const createDraft = useCreateCredentialDraft() const deleteCredential = useDeleteWorkspaceCredential() @@ -113,7 +111,7 @@ export function ConnectedCredentialDetail({ const handleReconnectOAuth = async () => { if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return try { - await createDraft.mutateAsync({ + const draft = await createDraft.mutateAsync({ workspaceId, providerId: credential.providerId, displayName: credential.displayName, @@ -137,6 +135,7 @@ export function ConnectedCredentialDetail({ await connectOAuthService.mutateAsync({ providerId: credential.providerId, callbackURL: window.location.href, + draftId: draft.draftId, }) } catch (error: unknown) { toast.error("Couldn't start reconnect", { @@ -146,30 +145,15 @@ export function ConnectedCredentialDetail({ } } + /** + * Every credential type disconnects through the workspace-scoped credential + * delete, which authorizes against credential admin — explicit members and + * derived workspace admins alike. + */ const handleConfirmDelete = async () => { if (!credential) return try { - if (credential.type === 'service_account') { - await deleteCredential.mutateAsync(credential.id) - } else { - if (!credential.accountId || !credential.providerId) { - toast.error("Can't disconnect", { - description: 'Missing account information. Try reconnecting this credential first.', - }) - return - } - await disconnectOAuthService.mutateAsync({ - provider: credential.providerId.split('-')[0] || credential.providerId, - providerId: credential.providerId, - serviceId: credential.providerId, - accountId: credential.accountId, - }) - window.dispatchEvent( - new CustomEvent('oauth-credentials-updated', { - detail: { providerId: credential.providerId, workspaceId }, - }) - ) - } + await deleteCredential.mutateAsync(credential.id) setShowDeleteConfirmDialog(false) router.push(integrationsHref) } catch (error) { @@ -207,7 +191,7 @@ export function ConnectedCredentialDetail({ setShowDeleteConfirmDialog(true)} - disabled={disconnectOAuthService.isPending || deleteCredential.isPending} + disabled={deleteCredential.isPending} > Disconnect @@ -303,7 +287,7 @@ export function ConnectedCredentialDetail({ confirm={{ label: 'Disconnect', onClick: handleConfirmDelete, - pending: disconnectOAuthService.isPending || deleteCredential.isPending, + pending: deleteCredential.isPending, pendingLabel: 'Disconnecting...', }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 3d71ef5e63b..b64b4642abb 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -50,7 +50,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FILTER_SECTION_LABEL_CLASS, + FloatingOverflowText, + Resource, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -125,8 +129,6 @@ const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'disabled', label: 'Disabled' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' - interface KnowledgeBaseProps { id: string knowledgeBaseName?: string diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 449e287a438..dc1fadf781a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -573,6 +573,7 @@ function ConnectorCard({ providerId: providerId!, preCount: credentials?.length ?? 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) } @@ -607,6 +608,7 @@ function ConnectorCard({ providerId: providerId!, preCount: credentials?.length ?? 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index d9f123c4ea7..9722c695371 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -25,6 +25,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { type FieldErrors, useForm } from 'react-hook-form' import { z } from 'zod' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' import type { StrategyOptions } from '@/lib/chunkers/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' import { @@ -57,6 +58,14 @@ const STRATEGY_OPTIONS = [ { value: 'regex', label: 'Regex (custom pattern)' }, ] as const +/** Splits the comma-separated separator field into the list the API receives. */ +function parseSeparators(value: string | undefined): string[] { + if (!value?.trim()) return [] + return value + .split(',') + .map((separator) => separator.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')) +} + const STRATEGY_COMBOBOX_OPTIONS: ComboboxOption[] = STRATEGY_OPTIONS.map((o) => ({ label: o.label, value: o.value, @@ -124,6 +133,31 @@ const FormSchema = z path: ['regexPattern'], } ) + /** + * Gated on the strategy for the same reason the regex pattern is: the field only + * renders for `recursive` and only that strategy submits it, so an out-of-bound + * value left behind by a strategy switch must not block a submit that drops it. + */ + .refine( + (data) => + data.strategy !== 'recursive' || + parseSeparators(data.customSeparators).length <= MAX_CHUNKING_SEPARATORS, + { + message: `At most ${MAX_CHUNKING_SEPARATORS} separators are allowed`, + path: ['customSeparators'], + } + ) + .refine( + (data) => + data.strategy !== 'recursive' || + parseSeparators(data.customSeparators).every( + (separator) => separator.length <= MAX_CHUNKING_SEPARATOR_LENGTH + ), + { + message: `Each separator must be ${MAX_CHUNKING_SEPARATOR_LENGTH} characters or less`, + path: ['customSeparators'], + } + ) type FormInputValues = z.input type FormValues = z.output @@ -265,11 +299,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ ...(data.regexStrictBoundaries && { strictBoundaries: true }), } : data.strategy === 'recursive' && data.customSeparators?.trim() - ? { - separators: data.customSeparators - .split(',') - .map((s) => s.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')), - } + ? { separators: parseSeparators(data.customSeparators) } : undefined const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({ @@ -465,11 +495,13 @@ export const CreateBaseModal = memo(function CreateBaseModal({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index e01bee4bd83..0cb354d4c45 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { @@ -22,9 +23,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +39,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +49,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -65,7 +74,12 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' -import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { + useBulkDeleteKnowledgeBases, + useBulkMoveKnowledgeBases, + useDeleteKnowledgeBase, + useUpdateKnowledgeBase, +} from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' @@ -110,7 +124,9 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'empty', label: 'Empty' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' +/** This list's private drag MIME, so a drag started on another list is never mistaken for one + * of these rows. */ +const KNOWLEDGE_ROW_DRAG_MIME = 'application/x-sim-workspace-knowledge-rows' const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel @@ -200,9 +216,14 @@ export function Knowledge() { }, [error]) const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true + const canEditRef = useRef(canEdit) + canEditRef.current = canEdit const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) - const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) + const deleteKnowledgeBase = useDeleteKnowledgeBase(workspaceId) + const bulkMoveKnowledgeBases = useBulkMoveKnowledgeBases(workspaceId) + const bulkDeleteKnowledgeBases = useBulkDeleteKnowledgeBases(workspaceId) const { currentFolderId, @@ -268,8 +289,8 @@ export function Knowledge() { ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false) const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [activeFolder, setActiveFolder] = useState(null) const [folderPendingDelete, setFolderPendingDelete] = useState(null) @@ -310,6 +331,22 @@ export function Knowledge() { const activeFolderRef = useRef(activeFolder) activeFolderRef.current = activeFolder + /** + * Indexed once. These resolve a dragged row's current placement and run per dragged row inside + * `dragover`, which fires continuously — a linear scan there is O(selection x resources) per + * event, and the worst case (hesitating over the folder the selection already lives in) does + * not short-circuit. + */ + const knowledgeBaseById = useMemo(() => { + const byId = new Map() + for (const base of knowledgeBases) byId.set(base.id, base as KnowledgeBaseWithDocCount) + return byId + }, [knowledgeBases]) + const knowledgeBaseByIdRef = useRef(knowledgeBaseById) + knowledgeBaseByIdRef.current = knowledgeBaseById + const folderByIdRef = useRef(folderById) + folderByIdRef.current = folderById + const foldersRef = useRef(folders) foldersRef.current = folders @@ -400,10 +437,11 @@ export function Knowledge() { const handleDeleteKnowledgeBase = useCallback( async (id: string) => { - await deleteKnowledgeBaseMutation({ knowledgeBaseId: id }) + await deleteKnowledgeBase.mutateAsync({ knowledgeBaseId: id }) logger.info(`Knowledge base deleted: ${id}`) }, - [deleteKnowledgeBaseMutation] + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + [] ) /** @@ -613,6 +651,58 @@ export function Knowledge() { listRename.cancelRename, ]) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isCreateModalOpen || + isEditModalOpen || + isDeleteModalOpen || + isBulkDeleteModalOpen || + isTagsModalOpen || + folderPendingDelete !== null + + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => + !canEdit || listRenameRef.current.editingId !== null || isAnyDialogOpen(), + onDeleteSelected: () => handleBulkDelete(), + }) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * the menu handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const { folderIds: selectedFolderIds, resourceIds: selectedKnowledgeBaseIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteLabel = useMemo(() => { + const count = selectedKnowledgeBaseIds.length + selectedFolderIds.length + const firstName = + selectedKnowledgeBaseIds.length > 0 + ? knowledgeBasesRef.current.find((kb) => kb.id === selectedKnowledgeBaseIds[0])?.name + : foldersRef.current.find((folder) => folder.id === selectedFolderIds[0])?.name + return selectionLabel(count, firstName) + }, [selectedKnowledgeBaseIds, selectedFolderIds]) + const handleRowClick = useCallback( (rowId: string) => { if (isRowContextMenuOpenRef.current || isFolderContextMenuOpenRef.current) return @@ -634,6 +724,13 @@ export function Knowledge() { const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEditRef.current && !selectedRowIdsRef.current.has(rowId)) replaceSelection([rowId]) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { const folder = foldersRef.current.find((item) => item.id === parsed.id) @@ -655,14 +752,9 @@ export function Knowledge() { const handleConfirmDelete = useCallback(async () => { const kb = activeKnowledgeBaseRef.current if (!kb) return - setIsDeleting(true) - try { - await handleDeleteKnowledgeBase(kb.id) - setIsDeleteModalOpen(false) - setActiveKnowledgeBase(null) - } finally { - setIsDeleting(false) - } + await handleDeleteKnowledgeBase(kb.id) + setIsDeleteModalOpen(false) + setActiveKnowledgeBase(null) }, [handleDeleteKnowledgeBase]) const handleCloseDeleteModal = useCallback(() => { @@ -696,8 +788,6 @@ export function Knowledge() { setIsDeleteModalOpen(true) }, []) - const canEdit = userPermissions.canEdit === true - const handleCreateFolder = useCallback(async () => { if (!workspaceId) return const parentId = currentFolderIdRef.current @@ -800,16 +890,18 @@ export function Knowledge() { }, [workspaceId, pinnedFolderIds, closeFolderContextMenu]) /** Move targets for the folder under the cursor: itself and its subtree are unreachable. */ - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantsByFolderId.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ - folders, - rootLabel: ROOT_BREADCRUMB_LABEL, - excludedFolderIds: excluded, - }) - }, [folders, activeFolder, descendantsByFolderId]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId, + }) + : [], + [folders, activeFolder, descendantsByFolderId] + ) /** Move targets for a knowledge base: every folder, since a base has no subtree. */ const knowledgeBaseMoveOptions: MoveOptionNode[] = useMemo( @@ -855,8 +947,7 @@ export function Knowledge() { if (!folder) return const parentId = parseMoveOptionValue(optionValue) // Live placement, not the snapshot taken when the menu opened — a refetch or concurrent - // move in between would otherwise skip the write the user just chose. Matches the - // knowledge-base move below and both Tables handlers. + // move in between would otherwise skip the write the user just chose. const current = foldersRef.current.find((item) => item.id === folder.id) ?? folder if ((current.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId) closeFolderContextMenu() @@ -869,8 +960,7 @@ export function Knowledge() { const kb = activeKnowledgeBaseRef.current if (!kb) return const folderId = parseMoveOptionValue(optionValue) - // Re-read placement from the live list: `activeKnowledgeBase` is a snapshot from when - // the menu opened, and a refetch since then would make the no-op check wrong. + // Same reasoning as `handleMoveFolder`: compare against the live row, not the snapshot. const current = knowledgeBasesRef.current.find((item) => item.id === kb.id) ?? kb if ((current.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId) closeRowContextMenu() @@ -878,22 +968,138 @@ export function Knowledge() { [moveKnowledgeBaseTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of knowledge bases and + * folders commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { knowledgeBaseIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.knowledgeBaseIds.length === 0 && rows.folderIds.length === 0) return + if (rows.knowledgeBaseIds.length + rows.folderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to move at once`) + return + } + bulkMoveKnowledgeBases.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { knowledgeBaseIds: selectedKnowledgeBaseIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedKnowledgeBaseIds, selectedFolderIds] + ) + + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = + selectedKnowledgeBaseIds.length + selectedFolderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS + + const handleBulkDelete = useCallback(() => { + if (selectedKnowledgeBaseIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to delete at once`) + return + } + setIsBulkDeleteModalOpen(true) + }, [selectedKnowledgeBaseIds, selectedFolderIds, exceedsBatchCap]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteKnowledgeBases.mutateAsync({ + knowledgeBaseIds: selectedKnowledgeBaseIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteModalOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (deleteError) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items', deleteError) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedKnowledgeBaseIds, selectedFolderIds, clearSelection]) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId, + }), + [selectedFolderIds, folders, descendantsByFolderId] + ) + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : knowledgeBaseMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleDelete() + }, [handleBulkDelete, handleDelete]) + + const handleFolderDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleRequestFolderDelete() + }, [handleBulkDelete, handleRequestFolderDelete]) + + const handleMoveKnowledgeBaseFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveKnowledgeBase(optionValue) + }, + [handleBulkMove, handleMoveKnowledgeBase] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + const rowDragDropConfig = useFolderRowDragDrop({ + dragMime: KNOWLEDGE_ROW_DRAG_MIME, canEdit, editingRowId: listRename.editingId, descendantsByFolderId, - getFolderParentId: (folderId) => foldersRef.current.find((f) => f.id === folderId)?.parentId, + getFolderParentId: (folderId) => folderByIdRef.current.get(folderId)?.parentId ?? null, getResourceFolderId: (knowledgeBaseId) => - knowledgeBasesRef.current.find((kb) => kb.id === knowledgeBaseId)?.folderId ?? null, + knowledgeBaseByIdRef.current.get(knowledgeBaseId)?.folderId ?? null, getRowLabel: (rowId) => { const parsed = parseFolderedRowId(rowId) return parsed.kind === 'folder' - ? (foldersRef.current.find((f) => f.id === parsed.id)?.name ?? 'Folder') - : (knowledgeBasesRef.current.find((kb) => kb.id === parsed.id)?.name ?? 'Knowledge base') + ? (folderByIdRef.current.get(parsed.id)?.name ?? 'Folder') + : (knowledgeBaseByIdRef.current.get(parsed.id)?.name ?? 'Knowledge base') }, - onMoveFolder: (folderId, targetFolderId) => void moveFolderTo(folderId, targetFolderId), - onMoveResource: (knowledgeBaseId, targetFolderId) => - void moveKnowledgeBaseTo(knowledgeBaseId, targetFolderId), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, knowledgeBaseIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, + currentFolderId, }) const headerActions: ResourceAction[] = useMemo( @@ -996,18 +1202,7 @@ export function Knowledge() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -1089,6 +1284,36 @@ export function Knowledge() { [connectorFilter, contentFilter, ownerFilter, memberOptions] ) + /** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */ + const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) + + /** + * Memoized element, not inline JSX: `Resource.Table` is `memo`'d, and a fresh overlay element + * every render would fail its shallow compare and re-render the whole list on any parent + * render — during an upload or a drag, that is every frame. + */ + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveKnowledgeBases.isPending, + bulkDeleteKnowledgeBases.isPending, + ] + ) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (connectorFilter.length > 0) { @@ -1123,19 +1348,22 @@ export function Knowledge() { title={ROOT_BREADCRUMB_LABEL} breadcrumbs={listBreadcrumbs} actions={headerActions} + breadcrumbDrop={rowDragDropConfig.breadcrumb} /> @@ -1160,9 +1388,9 @@ export function Knowledge() { onTogglePin={handleToggleBasePin} pinned={pinnedBaseIds.has(activeKnowledgeBase.id)} onEdit={handleEdit} - onDelete={handleDelete} - onMove={handleMoveKnowledgeBase} - moveOptions={knowledgeBaseMoveOptions} + onDelete={handleDeleteFromMenu} + onMove={handleMoveKnowledgeBaseFromMenu} + moveOptions={activeMoveOptions} showOpenInNewTab showViewTags showEdit @@ -1179,12 +1407,12 @@ export function Knowledge() { onClose={closeFolderContextMenu} onOpen={handleOpenFolder} onRename={handleRenameFolder} - onDelete={handleRequestFolderDelete} + onDelete={handleFolderDeleteFromMenu} onCopyId={handleCopyFolderId} onTogglePin={handleToggleFolderPin} pinned={pinnedFolderIds.has(activeFolder.id)} - onMove={handleMoveFolder} - moveOptions={folderMoveOptions} + onMove={handleMoveFolderFromMenu} + moveOptions={activeFolderMoveOptions} canEdit={canEdit} /> )} @@ -1209,6 +1437,26 @@ export function Knowledge() { }} /> + 0 + ? '? This also deletes the knowledge bases and folders inside the selected folders. You can restore them from Recently Deleted in Settings.' + : '? You can restore them from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteKnowledgeBases.isPending, + pendingLabel: 'Deleting...', + }} + /> + {activeKnowledgeBase && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index ef9f1a9e5c5..9cdae3dcb08 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -69,7 +69,6 @@ describe('unified settings navigation', () => { expect(idsForSection('workspace')).toEqual([ 'teammates', 'secrets', - 'credential-groups', 'mcp', 'custom-tools', 'byok', @@ -77,6 +76,7 @@ describe('unified settings navigation', () => { 'workflow-mcp-servers', 'apikeys', 'sandboxes', + 'credential-groups', 'recently-deleted', ]) expect(idsForSection('organization')).toEqual([ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index f369e93d363..af00af4b441 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -1,6 +1,15 @@ /** Tailwind class applied to selected rows / columns / cells. */ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]' +/** + * Fill marking every cell matching the active find query. Reuses the app's + * search-highlight token (the knowledge-base search highlight paints with the + * same one) rather than inventing a third match colour, so the two stay + * theme-tuned together. The ACTIVE match is told apart by the selection + * outline drawn over it, not by a different fill. + */ +export const FIND_MATCH_TINT_BG = 'bg-[var(--highlight-match-bg)]' + /** Default column width in pixels. Used as a fallback when a column hasn't * been measured yet and as the initial width for newly-added columns. */ export const COL_WIDTH = 160 @@ -23,5 +32,7 @@ export const CELL_HEADER_CHECKBOX = /** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */ export const CELL_CONTENT = 'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small' -export const SELECTION_OVERLAY = - 'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]' +/** Inset shared by every full-cell overlay, so the tints and the selection + * outline can't drift apart on a border-geometry change. */ +export const CELL_OVERLAY_INSET = 'pointer-events-none absolute -top-px -right-px -bottom-px' +export const SELECTION_OVERLAY = `${CELL_OVERLAY_INSET} z-[5] border-[2px] border-[var(--selection)]` diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index acf192e3002..077f73fb2e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -12,6 +12,8 @@ import { CELL, CELL_CHECKBOX, CELL_CONTENT, + CELL_OVERLAY_INSET, + FIND_MATCH_TINT_BG, SELECTION_OVERLAY, SELECTION_TINT_BG, } from './constants' @@ -67,6 +69,13 @@ export interface DataRowProps { pinnedOffsets?: Map /** Key of the rightmost pinned column, used to render a separator shadow. */ lastPinnedColKey?: string | null + /** + * Column keys in this row matching the active find query, tinted so every hit + * is visible at once rather than only the one being navigated to. Absent when + * the row has no match, which is the common case and keeps this row's memo + * from re-running for a search elsewhere in the table. + */ + findMatchColumns?: ReadonlySet } function cellRangeRowChanged( @@ -128,7 +137,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.workflowGroups !== next.workflowGroups || prev.activeDispatches !== next.activeDispatches || prev.pinnedOffsets !== next.pinnedOffsets || - prev.lastPinnedColKey !== next.lastPinnedColKey + prev.lastPinnedColKey !== next.lastPinnedColKey || + prev.findMatchColumns !== next.findMatchColumns ) { return false } @@ -177,6 +187,7 @@ export const DataRow = React.memo(function DataRow({ activeDispatches, pinnedOffsets, lastPinnedColKey, + findMatchColumns, }: DataRowProps) { const sel = normalizedSelection /** @@ -299,6 +310,7 @@ export const DataRow = React.memo(function DataRow({ const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol const isEditing = editingColumnName === column.key const isHighlighted = inRange || isRowChecked + const isFindMatch = findMatchColumns?.has(column.key) const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked @@ -323,7 +335,7 @@ export const DataRow = React.memo(function DataRow({ data-pinned={isPinnedCell ? '' : undefined} className={cn( CELL, - (isHighlighted || isAnchor || isEditing) && 'relative', + (isHighlighted || isAnchor || isEditing || isFindMatch) && 'relative', isPinnedCell && 'z-[6] bg-[var(--bg)]', isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]' )} @@ -342,10 +354,26 @@ export const DataRow = React.memo(function DataRow({ } onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)} > + {/* No z-index on purpose: with `auto` it paints in DOM order, so it + sits above the cell background but BELOW the cell text, the + selection tint (z-4) and the anchor outline (z-5). The active + match therefore still reads as the selected cell, and the wash + never dims the value it is pointing at. */} + {isFindMatch && ( +
+ )} {isHighlighted && (isMultiCell || isRowChecked) && (
ghost.parentNode?.removeChild(ghost)) - onDragStart(columnName) + onDragStart(columnKey) } function handleDragOver(e: React.DragEvent) { - if (!onDragOver || !columnName) return + if (!onDragOver) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const midX = rect.left + rect.width / 2 const side = e.clientX < midX ? 'left' : 'right' - onDragOver(columnName, side) + onDragOver(columnKey, side) } function handleDragEnd() { @@ -457,6 +459,8 @@ export function WorkflowGroupMetaCell({ return ( ({ + Button: ({ children, ...props }: { children: ReactNode } & Record) => ( + + ), + ChipInput: ({ + endAdornment, + icon: _icon, + ...props + }: { endAdornment?: ReactNode } & Record) => ( + <> + + {endAdornment} + + ), +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => , + ChevronUp: () => , + Loader: () => , + Search: () => , + X: () => , +})) + +import { TableFind, type TableFindProps } from './table-find' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function render(overrides: Partial = {}) { + const props: TableFindProps = { + query: '', + onQueryChange: vi.fn(), + onNext: vi.fn(), + onPrev: vi.fn(), + onSubmit: vi.fn(), + onClose: vi.fn(), + isStale: false, + canNavigate: true, + count: 0, + currentIndex: 0, + truncated: false, + isLoading: false, + inputRef: createRef(), + ...overrides, + } + act(() => root.render()) + return props +} + +function input(): HTMLInputElement { + const el = container.querySelector('input') + if (!el) throw new Error('find input not rendered') + return el +} + +function counterText(): string | null { + return container.querySelector('[aria-live="polite"]')?.textContent ?? null +} + +function buttonByLabel(label: string): HTMLButtonElement { + const el = container.querySelector(`button[aria-label="${label}"]`) + if (!el) throw new Error(`no button labelled ${label}`) + return el as HTMLButtonElement +} + +function press(key: string, init: KeyboardEventInit = {}) { + act(() => { + input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +describe('TableFind counter', () => { + it('shows nothing before the user has typed', () => { + render({ query: '' }) + expect(counterText()).toBe('') + }) + + it('counts matches as 1-based', () => { + render({ query: 'a', count: 12, currentIndex: 0 }) + expect(counterText()).toBe('1 of 12') + render({ query: 'a', count: 12, currentIndex: 11 }) + expect(counterText()).toBe('12 of 12') + }) + + it('marks a server-capped result set', () => { + render({ query: 'a', count: 1000, currentIndex: 0, truncated: true }) + expect(counterText()).toBe('1 of 1000+') + }) + + it('says No results only once the search has settled', () => { + render({ query: 'zzz', count: 0, isLoading: true }) + expect(counterText()).toBe('') + expect(container.querySelector('[data-icon="loader"]')).not.toBeNull() + + render({ query: 'zzz', count: 0, isLoading: false }) + expect(counterText()).toBe('No results') + }) + + // Blanking the tally on each keystroke reads as the search breaking; the + // previous term's count holds until the new one lands. + it('keeps the previous count visible while the next result set loads', () => { + render({ query: 'ab', count: 3, currentIndex: 1, isLoading: true }) + expect(counterText()).toBe('2 of 3') + }) + + it('keeps the counter mounted and width-reserved before the user types', () => { + render({ query: '' }) + const region = container.querySelector('[aria-live="polite"]') + expect(region).not.toBeNull() + expect(region?.className).toContain('min-w-[64px]') + }) +}) + +describe('TableFind keyboard', () => { + it('navigates on Enter rather than submitting a search', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter') + expect(props.onNext).toHaveBeenCalledTimes(1) + expect(props.onPrev).not.toHaveBeenCalled() + }) + + it('steps backwards on Shift+Enter', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter', { shiftKey: true }) + expect(props.onPrev).toHaveBeenCalledTimes(1) + expect(props.onNext).not.toHaveBeenCalled() + }) + + // Committing makes the typed and submitted terms agree instantly, but the + // matches on screen still belong to the previous term until the request + // lands — stepping there would select a cell the box no longer names. + it('does not step while the committed term is still loading', () => { + const props = render({ query: 'abcd', count: 3, isStale: false, canNavigate: false }) + press('Enter') + expect(props.onNext).not.toHaveBeenCalled() + expect(props.onSubmit).not.toHaveBeenCalled() + + press('Enter', { shiftKey: true }) + expect(props.onPrev).not.toHaveBeenCalled() + }) + + it('disables the arrows until the results describe the term', () => { + render({ query: 'abcd', count: 3, canNavigate: false }) + expect(buttonByLabel('Next match').disabled).toBe(true) + expect(buttonByLabel('Previous match').disabled).toBe(true) + }) + + it('closes on Escape', () => { + const props = render({ query: 'a', count: 3 }) + press('Escape') + expect(props.onClose).toHaveBeenCalledTimes(1) + }) + + // Mid-debounce the visible matches still belong to the previous term, so + // stepping through them would land on a cell the box no longer describes. + it('commits instead of stepping while the results are stale', () => { + const props = render({ query: 'abcd', count: 3, isStale: true }) + press('Enter') + expect(props.onSubmit).toHaveBeenCalledTimes(1) + expect(props.onNext).not.toHaveBeenCalled() + + press('Enter', { shiftKey: true }) + expect(props.onSubmit).toHaveBeenCalledTimes(2) + expect(props.onPrev).not.toHaveBeenCalled() + }) +}) + +describe('TableFind controls', () => { + it('offers a clear button only once there is text', () => { + render({ query: '' }) + expect(container.querySelector('button[aria-label="Clear search"]')).toBeNull() + + const props = render({ query: 'abc' }) + act(() => buttonByLabel('Clear search').click()) + expect(props.onQueryChange).toHaveBeenCalledWith('') + }) + + it('disables navigation while there is nothing to navigate', () => { + render({ query: 'zzz', count: 0 }) + expect(buttonByLabel('Next match').disabled).toBe(true) + expect(buttonByLabel('Previous match').disabled).toBe(true) + + render({ query: 'a', count: 2 }) + expect(buttonByLabel('Next match').disabled).toBe(false) + expect(buttonByLabel('Previous match').disabled).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx index 58b9220bdcb..a48a7e0122e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx @@ -1,53 +1,68 @@ 'use client' import type React from 'react' +import { memo } from 'react' import { Button, ChipInput } from '@sim/emcn' -import { ChevronDown, ChevronUp, Loader, X } from '@sim/emcn/icons' +import { ChevronDown, ChevronUp, Loader, Search, X } from '@sim/emcn/icons' export interface TableFindProps { query: string onQueryChange: (query: string) => void - /** Run the search (dirty Enter / search button). */ - onSubmit: () => void onNext: () => void onPrev: () => void + /** Adopts the typed term immediately, skipping the debounce. */ + onSubmit: () => void onClose: () => void + /** Whether the typed term has yet to be searched, so Enter should commit it. */ + isStale: boolean + /** + * Whether the matches on screen belong to the term that was searched. False + * while a term's own results are in flight, when the count still describes + * the previous term and stepping through it would land on a cell the box no + * longer names. + */ + canNavigate: boolean /** Number of matches after dropping columns not in the current view. */ count: number - /** 0-based index of the active match, or -1 when there are none. */ + /** 0-based index of the active match. Ignored when `count` is 0. */ currentIndex: number /** Whether the server capped the match set. */ truncated: boolean isLoading: boolean - /** Whether the input differs from the last submitted term. */ - isDirty: boolean inputRef: React.RefObject } -export function TableFind({ +/** + * Memoized: while the bar is open it is a child of the grid, which re-renders + * on scroll, hover and selection. Every prop is a primitive or a stable + * identity, so this collapses to renders where a find value actually changed. + */ +export const TableFind = memo(function TableFind({ query, onQueryChange, - onSubmit, onNext, onPrev, + onSubmit, onClose, + isStale, + canNavigate, count, currentIndex, truncated, isLoading, - isDirty, inputRef, }: TableFindProps) { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() - if (e.shiftKey) { - onPrev() - } else if (isDirty) { - onSubmit() - } else { - onNext() - } + // Commit an unsearched term; otherwise step — but only once the results + // describe it. In between (committed, still loading) Enter does nothing + // rather than walk the previous term's matches; the auto-reveal lands on + // the first hit as soon as they arrive. + if (isStale) onSubmit() + else if (!canNavigate) return + else if (e.shiftKey) onPrev() + else onNext() return } if (e.key === 'Escape') { @@ -56,9 +71,17 @@ export function TableFind({ } } + const hasQuery = query.trim().length > 0 const hasMatches = count > 0 - const label = - count === 0 ? 'No results' : `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` + const navEnabled = hasMatches && canNavigate + + /** The tally holds its last value while the next result set loads — blanking + * it on every keystroke reads as the feature breaking rather than working. */ + function counterContent() { + if (!hasQuery) return null + if (hasMatches) return `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` + return isLoading ? : 'No results' + } return (
@@ -66,42 +89,77 @@ export function TableFind({ ref={inputRef} value={query} placeholder='Search' + aria-label='Find in table' + spellCheck={false} + autoComplete='off' + icon={Search} className='w-[200px]' onChange={(e) => onQueryChange(e.target.value)} onKeyDown={handleKeyDown} + // Untrimmed on purpose: whitespace searches nothing, but it is still + // text the user may want cleared. + endAdornment={ + query.length > 0 ? ( + + ) : undefined + } /> - - {isLoading ? : label} + {/* Always mounted, reserving its width: rendering it only once there is a + query would resize the bar on the first keystroke, and a live region + inserted together with its text is announced unreliably. */} + + {counterContent()}
) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 59222045098..f69e0527069 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -27,6 +27,7 @@ import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' @@ -78,6 +79,7 @@ import { drainTargetForChip, type ExecStatusMix, expandToDisplayColumns, + horizontalEdgeScrollVelocity, isCellInSelection, moveCell, ROW_SELECTION_ALL, @@ -94,11 +96,14 @@ const logger = createLogger('TableView') const EMPTY_RUNNING_BY_ROW: Readonly> = Object.freeze({}) const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([]) +const EMPTY_FIND_MATCH_COLUMNS: ReadonlyMap> = Object.freeze(new Map()) const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([]) const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 const ROW_HEIGHT_ESTIMATE = 35 +const COLUMN_DRAG_SCROLL_HOT_ZONE_PX = 48 +const COLUMN_DRAG_SCROLL_MAX_VELOCITY_PX = 14 /** * Snapshot of grid selection state the wrapper needs to render ``. @@ -481,11 +486,9 @@ export function TableGrid({ const [selectionFocus, setSelectionFocus] = useState(null) const [rowSelection, setRowSelection] = useState(ROW_SELECTION_NONE) const [isColumnSelection, setIsColumnSelection] = useState(false) - // Find (Cmd/Ctrl+F): `findQuery` is the live input, `submittedQuery` is the - // last Enter/search-triggered term the query hook runs on. + // Find (Cmd/Ctrl+F): `findQuery` is the live input. const [findOpen, setFindOpen] = useState(false) const [findQuery, setFindQuery] = useState('') - const [submittedQuery, setSubmittedQuery] = useState('') const [currentMatchIndex, setCurrentMatchIndex] = useState(0) const [isJumping, setIsJumping] = useState(false) // Bumped on every navigation so the reveal effect re-runs even when the target @@ -493,6 +496,18 @@ export function TableGrid({ const [pendingMatchTick, setPendingMatchTick] = useState(0) const findInputRef = useRef(null) const pendingMatchRef = useRef(null) + /** Monotonic id for the in-flight match jump; see `goToMatch`. */ + const goToMatchSeqRef = useRef(0) + /** The match the cursor is on, by identity rather than position, so a + * reordered result set can re-point at the same cell. */ + const activeMatchRef = useRef(null) + /** Term the auto-reveal has already run for, so a background refetch of the + * same term doesn't re-jump the viewport. */ + const autoRevealedTermRef = useRef('') + /** Whether the selection currently sits on the match at `currentMatchIndex`. + * False when the auto-reveal was skipped, so next/prev knows to land on that + * index rather than step past it. */ + const cursorIsOnMatchRef = useRef(false) const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) const [columnWidths, setColumnWidths] = useState>({}) @@ -532,6 +547,8 @@ export function TableGrid({ const seededLayoutKeyRef = useRef(null) const containerRef = useRef(null) const scrollRef = useRef(null) + const columnDragPointerXRef = useRef(null) + const columnDragScrollFrameRef = useRef(null) const theadRef = useRef(null) const tbodyRef = useRef(null) const isDraggingRef = useRef(false) @@ -1093,7 +1110,51 @@ export function TableGrid({ emitCellSelection({ anchor, focus, editing: editingCell !== null }) }, [selectionAnchor, selectionFocus, editingCell, rows, displayColumns, emitCellSelection]) - const { data: findData, isFetching: isFindFetching } = useFindTableRows({ + /** + * The term the search actually runs on: the live input, debounced so results + * follow typing without a request per keystroke. + * + * Owned here rather than via `useDebounce` because closing or clearing has to + * take effect IMMEDIATELY and cancel anything pending. `useDebounce` is + * trailing-edge and keeps serving its last value until the next timer fires, + * so after Esc it still holds the old term — and a guard on the *input* can't + * mask that, because the first keystroke of the next search makes the input + * non-empty again while the debounce is still holding the previous term. The + * result would be the old search replayed from cache (highlights, count and a + * viewport jump) under a box showing one fresh character. Cmd+F, Esc, Cmd+F + * is an ordinary correction, so that window gets hit. + */ + const trimmedFindQuery = findQuery.trim() + const [submittedQuery, setSubmittedQuery] = useState('') + useEffect(() => { + if (!findOpen || trimmedFindQuery.length === 0) { + setSubmittedQuery('') + return + } + const timer = setTimeout(() => setSubmittedQuery(trimmedFindQuery), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [findOpen, trimmedFindQuery]) + + const trimmedFindQueryRef = useRef(trimmedFindQuery) + trimmedFindQueryRef.current = trimmedFindQuery + + /** + * Adopt the typed term now instead of waiting out the debounce. Enter uses + * this while the two disagree: navigating there would step through the + * PREVIOUS term's matches — `keepPreviousData` still holds them — and land on + * a cell that doesn't match the box. Pressing Enter means "search this now", + * so it commits rather than navigates, and the auto-reveal takes it from + * there. The pending timer is harmless: it later sets the same string. + */ + const handleFindSubmit = useCallback(() => { + setSubmittedQuery(trimmedFindQueryRef.current) + }, []) + + const { + data: findData, + isFetching: isFindFetching, + isPlaceholderData: isFindPlaceholder, + } = useFindTableRows({ workspaceId, tableId, q: submittedQuery, @@ -1108,6 +1169,11 @@ export function TableGrid({ * to a cell that isn't rendered. */ const findMatches = useMemo(() => { + // `keepPreviousData` serves the previous term's matches while a new term + // loads, which is what keeps the counter steady mid-typing — but with an + // empty term the query is disabled, so that placeholder would otherwise + // linger as highlights over a cleared search box. + if (submittedQuery.length === 0) return EMPTY_FIND_MATCHES const raw = findData?.matches if (!raw || raw.length === 0) return EMPTY_FIND_MATCHES // `m.column` is the stable column id (the JSONB storage key); index display @@ -1120,15 +1186,58 @@ export function TableGrid({ a.ordinal - b.ordinal || (colIndexByKey.get(a.column) ?? 0) - (colIndexByKey.get(b.column) ?? 0) ) - }, [findData, displayColumns]) + }, [findData, displayColumns, submittedQuery]) + + /** + * Match column ids grouped by row id, so a row can mark its matching cells in + * O(1) without scanning the whole match list. Rebuilt only when the match set + * changes; `DataRow` is memoized on the per-row `Set`, so rows without a match + * keep the same `undefined` and never re-render for a search. + */ + const findMatchColumnsByRowId = useMemo>>(() => { + if (findMatches.length === 0) return EMPTY_FIND_MATCH_COLUMNS + const byRow = new Map>() + for (const match of findMatches) { + const existing = byRow.get(match.rowId) + if (existing) existing.add(match.column) + else byRow.set(match.rowId, new Set([match.column])) + } + return byRow + }, [findMatches]) + + /** + * Whether the matches on screen actually belong to the submitted term. + * + * False while a term's own results are still in flight — `keepPreviousData` + * keeps serving the PREVIOUS term's matches until they land, and the first + * search of a session has no data at all. Navigation is gated on this: + * committing with Enter makes the typed and submitted terms agree instantly, + * so without it a second Enter would step through the old term's matches. + * + * A background refetch of the SAME term keeps this true — its data is still + * for this key — so an SSE row update doesn't disable the arrows mid-search. + */ + const findResultsAreCurrent = + submittedQuery.length > 0 && findData !== undefined && !isFindPlaceholder const findMatchesRef = useRef(findMatches) findMatchesRef.current = findMatches + const findResultsAreCurrentRef = useRef(findResultsAreCurrent) + findResultsAreCurrentRef.current = findResultsAreCurrent const currentMatchIndexRef = useRef(currentMatchIndex) currentMatchIndexRef.current = currentMatchIndex const findOpenRef = useRef(findOpen) findOpenRef.current = findOpen + /** + * Whether `match` is still in the live result set. Both the paging await and + * the deferred reveal can outlast a refetch that removed it, and revealing a + * cell that no longer matches would select a non-hit and mark the cursor as + * sitting on a result. + */ + const isStillAMatch = (match: TableFindMatch) => + findMatchesRef.current.some((m) => m.rowId === match.rowId && m.column === match.column) + /** Loads the row containing match `index` (wrapping), then queues the cell reveal. */ const goToMatch = useCallback(async (index: number) => { const matches = findMatchesRef.current @@ -1136,11 +1245,30 @@ export function TableGrid({ const wrapped = ((index % matches.length) + matches.length) % matches.length const match = matches[wrapped] setCurrentMatchIndex(wrapped) + // Claim the target NOW, not when the reveal lands. Paging is awaited below, + // and a same-term refetch during that window would otherwise re-point the + // cursor at the cell we are navigating AWAY from. + activeMatchRef.current = match setIsJumping(true) + // Paging to a distant match can outlast the next keystroke now that the + // search runs as the user types. Stamp this jump and drop it on return if a + // newer one started, or the grid would land on a superseded term's match. + const seq = ++goToMatchSeqRef.current try { await ensureRowsLoadedUpToRef.current(match.ordinal + 1) } finally { - setIsJumping(false) + if (seq === goToMatchSeqRef.current) setIsJumping(false) + } + if (seq !== goToMatchSeqRef.current) return + // The match set can change while we page — find hangs off the rows cache, + // so any row write or SSE update refetches it. If the target is gone, + // revealing it would select a cell that no longer matches and mark the + // cursor as sitting on a result, which then makes the next step skip the + // match that replaced it. + if (!isStillAMatch(match)) { + activeMatchRef.current = null + cursorIsOnMatchRef.current = false + return } // Defer the anchor set to the reveal effect: it must run after the freshly // loaded rows have committed, else scrollToIndex clamps to the stale count. @@ -1148,6 +1276,31 @@ export function TableGrid({ setPendingMatchTick((t) => t + 1) }, []) + /** + * Editing the query strands a jump still paging toward the previous term's + * match: without this, that jump can finish, pass its own sequence check, and + * reveal a cell that no longer matches — most visibly when the new term's + * first hit isn't loaded, so nothing else moves the selection afterwards. + * + * Declared above BOTH the reveal and the auto-reveal effects so it runs + * first. Effects fire in declaration order, so if a queued reveal and a + * keystroke land in the same commit, a cancel declared later would clear + * `pendingMatchRef` only after the reveal had already applied the stale match. + * + * Keyed on the LIVE input, not the debounced term: during the debounce window + * the submitted term still names the old search, so keying on it would leave + * that jump valid for another `SEARCH_DEBOUNCE_MS` after the box already + * shows something else. Clearing and closing land here too — both blank the + * input. + */ + useEffect(() => { + goToMatchSeqRef.current++ + pendingMatchRef.current = null + cursorIsOnMatchRef.current = false + activeMatchRef.current = null + setIsJumping(false) + }, [trimmedFindQuery, findOpen]) + /** * Reveal the pending match's cell once its row is in the loaded window. Keyed * on `rows` (new pages) and `pendingMatchTick` (so it fires even when the row @@ -1157,6 +1310,23 @@ export function TableGrid({ useEffect(() => { const match = pendingMatchRef.current if (!match) return + // Last gate before the selection moves: the queue-to-reveal hop is another + // commit the result set can change under, so re-check here too rather than + // trusting the check `goToMatch` made before its await. + if (!isStillAMatch(match)) { + pendingMatchRef.current = null + // Release the cursor only if this reveal still owns it. A pending reveal + // waits here for its row to load, and the user can start a newer jump in + // that window — which has already claimed the ref. Clearing it blindly + // would strand that newer jump with no identity to re-point from, which + // is the skip-on-next failure this guard exists to prevent. + const active = activeMatchRef.current + if (active && active.rowId === match.rowId && active.column === match.column) { + activeMatchRef.current = null + cursorIsOnMatchRef.current = false + } + return + } const rowIndex = rows.findIndex((r) => r.id === match.rowId) if (rowIndex === -1) return const colIndex = displayColumns.findIndex((c) => c.key === match.column) @@ -1166,35 +1336,150 @@ export function TableGrid({ setIsColumnSelection(false) setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setSelectionFocus(null) + cursorIsOnMatchRef.current = true setSelectionAnchor({ rowIndex, colIndex }) }, [rows, displayColumns, pendingMatchTick]) - /** New result set (new submitted term) → reset to and reveal the first match. */ + /** + * Re-point the cursor at the match it is actually on after the set changes. + * + * The cursor is stored as an index, but the list underneath it is mutable: a + * row insert or delete elsewhere in the table reorders matches for the SAME + * term, and index 1 can silently become a different cell. Stepping from it + * would then revisit the cell the user is on, or skip its neighbour. Matching + * on (rowId, column) — the match's identity — keeps the cursor attached to the + * cell rather than the position. + * + * When the active match is gone from the set — its row deleted, its cell + * edited so it no longer matches — the cursor is released instead: it is no + * longer sitting on a hit, so the next step must LAND on the clamped index + * rather than move past it. Without that, deleting the match under the cursor + * makes Next skip the one that took its place. + */ + useEffect(() => { + const active = activeMatchRef.current + if (!active || findMatches.length === 0) return + const index = findMatches.findIndex( + (m) => m.rowId === active.rowId && m.column === active.column + ) + if (index === -1) { + activeMatchRef.current = null + cursorIsOnMatchRef.current = false + setCurrentMatchIndex((i) => Math.min(i, findMatches.length - 1)) + return + } + if (index !== currentMatchIndexRef.current) setCurrentMatchIndex(index) + }, [findMatches]) + + /** + * A new TERM resets to its first match and reveals it. + * + * Keyed on the term, not on `findMatches` identity: the find query hangs off + * the rows cache, so any row write or SSE update refetches it, and keying on + * the result set would yank a user reading match 7 back to match 1 whenever + * a workflow cell landed. + * + * The reveal is skipped when the match is outside the loaded window. + * `ensureRowsLoadedUpTo` pages sequentially, so a selective term whose first + * hit is 50k rows down would fire ~50 serial round trips — per typing pause, + * now that the search is live. Highlights and the count still cover the whole + * table; only the viewport jump waits for a deliberate Enter or next-click. + * + * That deliberate path still runs the same unbounded, uncancellable paging it + * always has; this only stops typing from triggering it. Bounding it properly + * wants a fetch-at-offset on the rows endpoint, which is a server change. + */ useEffect(() => { + if (submittedQuery.length === 0) { + // Clearing the box has to un-latch, or retyping the same term — the + // ordinary "did I typo that?" correction — would match the stale latch + // and neither reset the cursor nor reveal anything. + autoRevealedTermRef.current = '' + return + } + // Wait for THIS term's own result set. `keepPreviousData` leaves + // `findMatches` describing the previous term while the new one loads, and + // on the session's first search there is no previous data at all — so + // `isPlaceholderData` is false while the query is still pending. Latching + // in either window would burn the one auto-reveal this term gets. + if (isFindPlaceholder || isFindFetching) return + if (autoRevealedTermRef.current === submittedQuery) return + autoRevealedTermRef.current = submittedQuery setCurrentMatchIndex(0) - if (findMatches.length > 0) goToMatch(0) - }, [findMatches, goToMatch]) + cursorIsOnMatchRef.current = false + activeMatchRef.current = null + const first = findMatches[0] + if (!first) return + if (!rowsRef.current.some((r) => r.id === first.rowId)) return + goToMatch(0) + }, [submittedQuery, findMatches, isFindPlaceholder, isFindFetching, goToMatch]) - const handleFindSubmit = useCallback(() => { - setSubmittedQuery(findQuery.trim()) - }, [findQuery]) + /** + * Step to the next/previous match — or, when the cursor is not on a match + * yet, to the current index itself. That second case is the term whose first + * hit the auto-reveal skipped because its row wasn't loaded: `+1` there would + * silently step over the very match the user pressed Enter to reach, and it + * would only come back around after wrapping the whole list. + */ + /** + * The index the next step counts from, clamped into the CURRENT match set. + * + * A row write or SSE update can shrink or reorder the matches for a term the + * user is still navigating; the term latch deliberately leaves the cursor + * alone in that case, so the stored index can now point past the end. Stepping + * from it would wrap off a stale base and land somewhere unrelated to the + * match on screen. Clamping here rather than in the two callers keeps the + * stepping base and the displayed index in agreement. + */ + const stepBaseIndex = () => + Math.min(currentMatchIndexRef.current, Math.max(0, findMatchesRef.current.length - 1)) const handleFindNext = useCallback(() => { - goToMatch(currentMatchIndexRef.current + 1) + if (!findResultsAreCurrentRef.current) return + const index = stepBaseIndex() + goToMatch(cursorIsOnMatchRef.current ? index + 1 : index) }, [goToMatch]) const handleFindPrev = useCallback(() => { - goToMatch(currentMatchIndexRef.current - 1) + if (!findResultsAreCurrentRef.current) return + const index = stepBaseIndex() + goToMatch(cursorIsOnMatchRef.current ? index - 1 : index) }, [goToMatch]) + /** + * Closes the bar and leaves no trace of the search: the term, the highlights + * (via the emptied term), and the match cursor all go. + * + * The cell selection is deliberately left where it is. Restoring the cell the + * user was on before opening find reads nicely, but deciding whether the + * current selection belongs to find or to the user is not answerable here — + * the grid has ~15 places that move the selection and no notion of who owns + * it, so every heuristic (compare the anchor, also check the focus, clear on + * click, clear on keydown) mis-fires on some ordinary gesture: extending a + * range from a match, clicking the match cell itself, arrowing away and back, + * Cmd+Z, or Cmd+F to refocus the bar. Leaving the cursor on the last match is + * what Sheets does and what this grid already did before find was reworked. + */ const handleFindClose = useCallback(() => { setFindOpen(false) setFindQuery('') - setSubmittedQuery('') + setCurrentMatchIndex(0) pendingMatchRef.current = null + // Strands any jump still paging toward a match, so it can't reveal a cell + // after the bar is gone. + goToMatchSeqRef.current++ + autoRevealedTermRef.current = '' + cursorIsOnMatchRef.current = false + activeMatchRef.current = null + setIsJumping(false) scrollRef.current?.focus({ preventScroll: true }) }, []) + /** The grid's own Escape handler is bound once and closes find through the + * same path as the bar's Escape, so the two can't drift. */ + const handleFindCloseRef = useRef(handleFindClose) + handleFindCloseRef.current = handleFindClose + const columnRename = useInlineRename({ // `columnName` is the column id; record the prior display name + id so undo // restores the label (not the id) and targets the right column. @@ -1763,54 +2048,176 @@ export function TableGrid({ ) }, []) - const handleColumnDragStart = useCallback((columnName: string) => { - setDragColumnName(columnName) - setSelectionAnchor(null) - setSelectionFocus(null) - setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) - setIsColumnSelection(false) + const stopColumnDragAutoScroll = useCallback(() => { + columnDragPointerXRef.current = null + if (columnDragScrollFrameRef.current !== null) { + cancelAnimationFrame(columnDragScrollFrameRef.current) + columnDragScrollFrameRef.current = null + } }, []) - const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { - const dragged = dragColumnNameRef.current - const cols = schemaColumnsRef.current - const targetCol = cols.find((c) => getColumnId(c) === columnName) - const targetGid = targetCol?.workflowGroupId + const handleColumnDragLeave = useCallback(() => { + dropTargetColumnNameRef.current = null + setDropTargetColumnName(null) + }, []) - // Suppress drop targeting while hovering siblings of the dragged column's - // own group: reordering inside a group is meaningless (the group renders - // as a unit) and the chasing indicator just flickers. - if (dragged) { + const updateColumnDropTarget = useCallback( + (columnName: string, side: 'left' | 'right') => { + const dragged = dragColumnNameRef.current + if (!dragged) return + + const cols = schemaColumnsRef.current const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId - if (draggedGid && draggedGid === targetGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) + const targetGid = cols.find((c) => getColumnId(c) === columnName)?.workflowGroupId + if ( + (draggedGid && draggedGid === targetGid) || + pinnedColumnsRef.current.includes(dragged) !== pinnedColumnsRef.current.includes(columnName) + ) { + handleColumnDragLeave() return } + + if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return + dropTargetColumnNameRef.current = columnName + dropSideRef.current = side + setDropTargetColumnName(columnName) + setDropSide(side) + }, + [handleColumnDragLeave] + ) + + function updateColumnDropTargetAtX(pointerX: number) { + const thead = theadRef.current + const scrollEl = scrollRef.current + const headerRow = thead?.rows.item((thead?.rows.length ?? 0) - 1) + if (!thead || !scrollEl || !headerRow) { + handleColumnDragLeave() + return } - // Reorder is restricted to within a single zone so a cross-zone drop - // indicator never appears for an insertion the grid would refuse. - if (dragged) { - const pinned = pinnedColumnsRef.current - if (pinned.includes(dragged) !== pinned.includes(columnName)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return + const headerRowRect = headerRow.getBoundingClientRect() + const headerY = headerRowRect.top + headerRowRect.height / 2 + const hoveredElement = document.elementFromPoint(pointerX, headerY) + let header = hoveredElement?.closest('th[data-column-drag-target]') ?? null + if (!header || !headerRow.contains(header)) { + const scrollRect = scrollEl.getBoundingClientRect() + const pinnedRight = Math.min(scrollRect.right, scrollRect.left + pinnedStickyLeftEdge) + let nearestDistance = Number.POSITIVE_INFINITY + header = null + + for (const candidate of headerRow.querySelectorAll( + 'th[data-column-drag-target]' + )) { + const candidateName = candidate.dataset.columnDragTarget + if (!candidateName) continue + + const rect = candidate.getBoundingClientRect() + const isPinned = pinnedColumnsRef.current.includes(candidateName) + const left = Math.max(rect.left, isPinned ? scrollRect.left : pinnedRight) + const right = Math.min(rect.right, isPinned ? pinnedRight : scrollRect.right) + if (right <= left) continue + + const distance = pointerX < left ? left - pointerX : pointerX > right ? pointerX - right : 0 + if (distance < nearestDistance) { + nearestDistance = distance + header = candidate + } + } + } + + if (!header) { + handleColumnDragLeave() + return + } + + let columnName = header.dataset.columnDragTarget + if (!columnName) { + handleColumnDragLeave() + return + } + + const targetGroupId = header.dataset.columnDragGroup + let { left, right } = header.getBoundingClientRect() + if (targetGroupId) { + const targetColumn = columnsRef.current.find((column) => column.key === columnName) + const groupStart = targetColumn + ? columnsRef.current[targetColumn.groupStartColIndex] + : undefined + if (!groupStart || groupStart.workflowGroupId !== targetGroupId) { + throw new Error(`Missing rendered start column for workflow group ${targetGroupId}`) + } + columnName = groupStart.key + + const groupHeaders = thead.querySelectorAll('th[data-column-drag-group]') + for (const groupHeader of groupHeaders) { + if (groupHeader.dataset.columnDragGroup !== targetGroupId) continue + const rect = groupHeader.getBoundingClientRect() + left = Math.min(left, rect.left) + right = Math.max(right, rect.right) } } - // Workflow groups: skip per-`` writes and let `handleScrollDragOver` - // do the bookkeeping. The scroll handler computes side from the group's - // full bounds, so it stays stable across sibling cursor moves; the per-th - // events would otherwise oscillate name + side as the cursor crosses each - // sibling's midpoint. - if (targetGid) return + updateColumnDropTarget(columnName, pointerX < left + (right - left) / 2 ? 'left' : 'right') + } - if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return - setDropTargetColumnName(columnName) - setDropSide(side) - }, []) + function startColumnDragAutoScroll(pointerX: number) { + columnDragPointerXRef.current = pointerX + if (columnDragScrollFrameRef.current !== null) return + + const tick = () => { + columnDragScrollFrameRef.current = null + const scrollEl = scrollRef.current + const currentPointerX = columnDragPointerXRef.current + if (!scrollEl || currentPointerX === null || !dragColumnNameRef.current) return + + const scrollRect = scrollEl.getBoundingClientRect() + const velocity = horizontalEdgeScrollVelocity({ + pointerX: currentPointerX, + visibleLeft: scrollRect.left + pinnedStickyLeftEdge, + visibleRight: scrollRect.right, + hotZone: COLUMN_DRAG_SCROLL_HOT_ZONE_PX, + maxVelocity: COLUMN_DRAG_SCROLL_MAX_VELOCITY_PX, + }) + if (velocity === 0) return + + const previousScrollLeft = scrollEl.scrollLeft + scrollEl.scrollLeft += velocity + if (scrollEl.scrollLeft !== previousScrollLeft) { + updateColumnDropTargetAtX(currentPointerX) + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + } + + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + + useEffect(() => stopColumnDragAutoScroll, [stopColumnDragAutoScroll]) + + const handleColumnDragStart = useCallback( + (columnName: string) => { + stopColumnDragAutoScroll() + dragColumnNameRef.current = columnName + setDragColumnName(columnName) + setSelectionAnchor(null) + setSelectionFocus(null) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) + setIsColumnSelection(false) + }, + [stopColumnDragAutoScroll] + ) + + const handleColumnDragOver = useCallback( + (columnName: string, side: 'left' | 'right') => { + const cols = schemaColumnsRef.current + const targetCol = cols.find((c) => getColumnId(c) === columnName) + if (targetCol?.workflowGroupId) return + updateColumnDropTarget(columnName, side) + }, + [updateColumnDropTarget] + ) const handleColumnDragEnd = useCallback(() => { + stopColumnDragAutoScroll() const dragged = dragColumnNameRef.current if (!dragged) { setDragColumnName(null) @@ -1945,64 +2352,27 @@ export function TableGrid({ setDragColumnName(null) setDropTargetColumnName(null) setDropSide('left') - }, []) - - const handleColumnDragLeave = useCallback(() => { - dropTargetColumnNameRef.current = null - setDropTargetColumnName(null) - }, []) + }, [stopColumnDragAutoScroll]) function handleScrollDragOver(e: React.DragEvent) { - if (!dragColumnNameRef.current) return + const draggedName = dragColumnNameRef.current + if (!draggedName) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const scrollEl = scrollRef.current if (!scrollEl) return - const scrollRect = scrollEl.getBoundingClientRect() - const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft - - const cols = columnsRef.current - const draggedGid = cols.find((c) => c.key === dragColumnNameRef.current)?.workflowGroupId - let left = checkboxColWidth - let i = 0 - while (i < cols.length) { - const col = cols[i] - // Treat fanned-out groups as monolithic drop targets; accumulate across siblings. - // Clamp `groupSize` to remaining columns: dragover fires constantly and can - // race a column removal where the cached `groupSize` outpaces `cols.length`. - const groupSize = Math.min(col.groupSize, cols.length - i) - let groupWidth = 0 - for (let j = 0; j < groupSize; j++) { - groupWidth += columnWidthsRef.current[cols[i + j].key] ?? COL_WIDTH - } - if (cursorX < left + groupWidth) { - // Inside the dragged column's own group → no-op drop, no indicator. - if (draggedGid && col.workflowGroupId === draggedGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const pinned = pinnedColumnsRef.current - const draggedName = dragColumnNameRef.current - if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.key)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const midX = left + groupWidth / 2 - const side = cursorX < midX ? 'left' : 'right' - if (col.key !== dropTargetColumnNameRef.current || side !== dropSideRef.current) { - setDropTargetColumnName(col.key) - setDropSide(side) - } - return - } - left += groupWidth - i += groupSize + if (pinnedColumnsRef.current.includes(draggedName)) { + stopColumnDragAutoScroll() + } else { + startColumnDragAutoScroll(e.clientX) } + updateColumnDropTargetAtX(e.clientX) } function handleScrollDrop(e: React.DragEvent) { e.preventDefault() + stopColumnDragAutoScroll() } useEffect(() => { @@ -2476,10 +2846,7 @@ export function TableGrid({ if (e.key === 'Escape') { e.preventDefault() if (findOpenRef.current) { - setFindOpen(false) - setFindQuery('') - setSubmittedQuery('') - pendingMatchRef.current = null + handleFindCloseRef.current() return } if (dragColumnNameRef.current) { @@ -4228,15 +4595,19 @@ export function TableGrid({ )} @@ -4275,7 +4646,12 @@ export function TableGrid({ {headerGroups.map((g) => { const firstCol = displayColumns[g.startColIndex] - const stickyLeft = firstCol ? pinnedOffsets.get(firstCol.key) : undefined + if (!firstCol) { + throw new Error( + `Missing display column for header group at index ${g.startColIndex}` + ) + } + const stickyLeft = pinnedOffsets.get(firstCol.key) if (g.kind === 'workflow') { const lastCol = displayColumns[g.startColIndex + g.size - 1] return ( @@ -4284,7 +4660,8 @@ export function TableGrid({ workflowId={g.workflowId} size={g.size} startColIndex={g.startColIndex} - columnName={firstCol?.name ?? ''} + columnName={firstCol.name} + columnKey={firstCol.key} column={firstCol} workflows={workflows} isGroupSelected={ @@ -4353,17 +4730,18 @@ export function TableGrid({ onDragLeave={ userPermissions.canEdit ? handleColumnDragLeave : undefined } - isPinned={firstCol ? pinnedColumnSet.has(firstCol.key) : false} + isPinned={pinnedColumnSet.has(firstCol.key)} onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} stickyLeft={stickyLeft} isLastPinned={lastCol?.key === lastPinnedColKey} /> ) } - const isLastFrz = firstCol?.key === lastPinnedColKey + const isLastFrz = firstCol.key === lastPinnedColKey return ( 0 ? pinnedOffsets : undefined} lastPinnedColKey={lastPinnedColKey} + findMatchColumns={findMatchColumnsByRowId.get(row.id)} /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts index 0dc6c3cd9b2..80534939ca4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts @@ -13,6 +13,7 @@ import { canWriteRowsWithChip, chipRowCount, drainTargetForChip, + horizontalEdgeScrollVelocity, selectedColumnIds, } from './utils' @@ -25,6 +26,55 @@ function columns(count: number): DisplayColumn[] { const rowIds = (count: number) => Array.from({ length: count }, (_, i) => `r${i}`) +describe('horizontalEdgeScrollVelocity', () => { + const getVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 140, + visibleRight: 900, + hotZone: 48, + maxVelocity: 14, + }) + + it('scrolls left when the pointer enters the visible edge after sticky columns', () => { + expect(getVelocity(140)).toBe(-14) + expect(getVelocity(164)).toBe(-7) + }) + + it('scrolls right at the opposite edge and stays still between edge zones', () => { + expect(getVelocity(876)).toBe(7) + expect(getVelocity(900)).toBe(14) + expect(getVelocity(500)).toBe(0) + }) + + it('stays still when pinned columns consume the visible viewport', () => { + expect( + horizontalEdgeScrollVelocity({ + pointerX: 100, + visibleLeft: 200, + visibleRight: 100, + hotZone: 48, + maxVelocity: 14, + }) + ).toBe(0) + }) + + it('uses the nearest edge when a narrow viewport would overlap both hot zones', () => { + const narrowVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 100, + visibleRight: 140, + hotZone: 48, + maxVelocity: 14, + }) + + expect(narrowVelocity(105)).toBe(-11) + expect(narrowVelocity(120)).toBe(0) + expect(narrowVelocity(135)).toBe(11) + }) +}) + describe('selectedColumnIds', () => { it('returns the ids the range spans', () => { expect(selectedColumnIds(columns(5), { startCol: 1, endCol: 3 })).toEqual(['c1', 'c2', 'c3']) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index b2486fcf969..4f3e9282d17 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -31,6 +31,43 @@ export type RowSelection = export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } +interface HorizontalEdgeScrollVelocityInput { + pointerX: number + visibleLeft: number + visibleRight: number + hotZone: number + maxVelocity: number +} + +export function horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft, + visibleRight, + hotZone, + maxVelocity, +}: HorizontalEdgeScrollVelocityInput): number { + if (hotZone <= 0) throw new Error('hotZone must be greater than zero') + if (maxVelocity <= 0) throw new Error('maxVelocity must be greater than zero') + const visibleWidth = visibleRight - visibleLeft + if (visibleWidth <= 0) return 0 + + const edgeZone = Math.min(hotZone, visibleWidth / 2) + + const distanceFromLeft = pointerX - visibleLeft + if (distanceFromLeft < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromLeft) / edgeZone + return -Math.ceil(intensity * maxVelocity) + } + + const distanceFromRight = visibleRight - pointerX + if (distanceFromRight < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromRight) / edgeZone + return Math.ceil(intensity * maxVelocity) + } + + return 0 +} + export function rowSelectionIncludes(sel: RowSelection, id: string): boolean { if (sel.kind === 'all') return !sel.excluded?.has(id) if (sel.kind === 'some') return sel.ids.has(id) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts index 5b510dc38e3..dd6776ded92 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts @@ -17,6 +17,15 @@ export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc' * recursive, arbitrarily-nested object (`$or`/`$and` combinators, per-column * operator objects); serializing it would put a large structured blob in the * URL, which the URL-state doctrine forbids. It stays in local `useState`. + * + * The in-grid `find` (Cmd+F) is likewise absent, for a different reason: it is + * a viewport cursor, not a destination. Two things rule it out. It is not one + * value but a cluster — the term, the match cursor, and the cell the user was + * on before opening find — and only the term is serializable; closing restores + * that pre-find cell from an in-memory ref, so a term that survived a reload + * would arrive with no origin to return to. And the search runs on every + * debounced keystroke rather than on submit, which is the write frequency this + * doctrine keeps out of the URL. Same call the browser's own Cmd+F makes. */ export const tableDetailParsers = { sort: parseAsString, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index cb2f0fdd6b7..dc5e89a8742 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -9,7 +9,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' -import { generateUniqueTableName } from '@/lib/table/constants' +import { generateUniqueTableName, MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { DropdownOption, @@ -22,9 +22,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +38,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +48,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -64,6 +72,8 @@ import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hoo import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { exportTable, + useBulkDeleteTables, + useBulkMoveTables, useCreateTable, useDeleteTable, useImportCsv, @@ -92,6 +102,10 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +/** This list's private drag MIME, so a drag started on another list is never mistaken for one + * of these rows. */ +const TABLE_ROW_DRAG_MIME = 'application/x-sim-workspace-table-rows' + /** Root label for breadcrumbs and the "move to workspace root" destination. */ const ROOT_LABEL = FOLDERED_RESOURCE_HEADERS.table.rootLabel @@ -154,6 +168,8 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const moveTable = useMoveTable(workspaceId) + const bulkMoveTables = useBulkMoveTables(workspaceId) + const bulkDeleteTables = useBulkDeleteTables(workspaceId) const importCsv = useImportCsv() const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() @@ -203,6 +219,7 @@ export function Tables() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] = useState(false) + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) const [activeFolder, setActiveFolder] = useState(null) @@ -241,6 +258,20 @@ export function Tables() { const uploading = uploadProgress.total > 0 const csvInputRef = useRef(null) + /** + * Indexed once. These resolve a dragged row's current placement and run per dragged row inside + * `dragover`, which fires continuously — a linear scan there is O(selection x resources) per + * event, and the worst case (hesitating over the folder the selection already lives in) does + * not short-circuit. + */ + const tableById = useMemo(() => { + const byId = new Map() + for (const table of tables) byId.set(table.id, table) + return byId + }, [tables]) + const tableByIdRef = useRef(tableById) + tableByIdRef.current = tableById + const tablesRef = useRef(tables) tablesRef.current = tables @@ -258,7 +289,8 @@ export function Tables() { closeMenu: closeRowContextMenu, } = useContextMenu() - const [contextMenuKind, setContextMenuKind] = useState<'table' | 'folder'>('table') + /** Which row kind the row context menu acts on — whichever active slot the handler filled. */ + const contextMenuKind: 'table' | 'folder' = activeFolder ? 'folder' : 'table' /** * Descendants of every folder, so a move destination that sits inside the moved folder can @@ -459,6 +491,41 @@ export function Tables() { [listRename.startRename] ) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isDeleteDialogOpen || isDeleteFolderDialogOpen || isBulkDeleteDialogOpen || isImportDialogOpen + + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => !canEdit || listRename.editingId !== null || isAnyDialogOpen(), + onDeleteSelected: () => handleBulkDelete(), + }) + + const { folderIds: selectedFolderIds, resourceIds: selectedTableIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteLabel = useMemo(() => { + const count = selectedTableIds.length + selectedFolderIds.length + const firstName = + selectedTableIds.length > 0 + ? tables.find((table) => table.id === selectedTableIds[0])?.name + : folderById.get(selectedFolderIds[0])?.name + return selectionLabel(count, firstName) + }, [selectedTableIds, selectedFolderIds, tables, folderById]) + const currentFolderActions: DropdownOption[] | undefined = useMemo(() => { if (!currentFolderId) return undefined const folder = folderById.get(currentFolderId) @@ -570,18 +637,7 @@ export function Tables() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -592,7 +648,7 @@ export function Tables() { () => (
- Row Count + Row Count {memberOptions.length > 0 && (
- Owner + Owner { const item = resolveRowItem(rowId) if (!item) return + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEdit && !selectedRowIds.has(rowId)) replaceSelection([rowId]) if (item.kind === 'folder') { setActiveFolder(item.folder) setActiveTable(null) - setContextMenuKind('folder') } else { setActiveTable(item.table) setActiveFolder(null) - setContextMenuKind('table') } handleRowCtxMenu(e) }, - [resolveRowItem, handleRowCtxMenu] + [resolveRowItem, handleRowCtxMenu, canEdit, selectedRowIds, replaceSelection] ) + /** Move targets for a table: every folder, since a table has no subtree. */ const tableMoveOptions: MoveOptionNode[] = useMemo( () => buildMoveOptions({ folders, rootLabel: ROOT_LABEL }), [folders] ) - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantFolderIds.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ folders, rootLabel: ROOT_LABEL, excludedFolderIds: excluded }) - }, [activeFolder, folders, descendantFolderIds]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId: descendantFolderIds, + }) + : [], + [activeFolder, folders, descendantFolderIds] + ) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIds, + }), + [selectedFolderIds, folders, descendantFolderIds] + ) const handleMoveTable = useCallback( (optionValue: string) => { @@ -753,7 +835,7 @@ export function Tables() { * Placement is re-read from the live list rather than trusted from `activeTable`, which * is a snapshot taken when the menu opened. A refetch or a concurrent move since then * would make the no-op check compare against a stale location and skip a write the user - * asked for. Matches the knowledge-base move. + * asked for. */ const current = tablesRef.current.find((table) => table.id === activeTable.id) ?? activeTable if ((current.folderId ?? null) === folderId) { @@ -794,22 +876,135 @@ export function Tables() { [activeFolder, folderById, moveFolderTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of tables and folders + * commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { tableIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.tableIds.length === 0 && rows.folderIds.length === 0) return + if (rows.tableIds.length + rows.folderIds.length > MAX_TABLE_BATCH_ITEMS) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to move at once`) + return + } + bulkMoveTables.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { tableIds: selectedTableIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedTableIds, selectedFolderIds] + ) + + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = selectedTableIds.length + selectedFolderIds.length > MAX_TABLE_BATCH_ITEMS + + const handleBulkDelete = useCallback(() => { + if (selectedTableIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to delete at once`) + return + } + setIsBulkDeleteDialogOpen(true) + }, [selectedTableIds, selectedFolderIds, exceedsBatchCap]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteTables.mutateAsync({ + tableIds: selectedTableIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteDialogOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (err) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items:', err) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedTableIds, selectedFolderIds, clearSelection]) + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * these handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : tableMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleMoveTableFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveTable(optionValue) + }, + [handleBulkMove, handleMoveTable] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + + const handleDeleteTableFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteDialogOpen(true) + }, [handleBulkDelete]) + + const handleDeleteFolderFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteFolderDialogOpen(true) + }, [handleBulkDelete]) + const rowDragDropConfig = useFolderRowDragDrop({ + dragMime: TABLE_ROW_DRAG_MIME, canEdit, editingRowId: listRename.editingId, descendantsByFolderId: descendantFolderIds, getFolderParentId: (folderId) => folderById.get(folderId)?.parentId ?? null, - getResourceFolderId: (tableId) => - tablesRef.current.find((table) => table.id === tableId)?.folderId ?? null, + getResourceFolderId: (tableId) => tableByIdRef.current.get(tableId)?.folderId ?? null, getRowLabel: (rowId) => { const parsed = parseFolderedRowId(rowId) return parsed.kind === 'folder' ? (folderById.get(parsed.id)?.name ?? 'Folder') - : (tablesRef.current.find((table) => table.id === parsed.id)?.name ?? 'Table') + : (tableByIdRef.current.get(parsed.id)?.name ?? 'Table') }, - onMoveFolder: (folderId, targetFolderId) => moveFolderTo(folderId, targetFolderId), - onMoveResource: (tableId, targetFolderId) => - moveTable.mutate({ tableId, folderId: targetFolderId }), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, tableIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, + currentFolderId, }) const handleDelete = async () => { @@ -1027,9 +1222,31 @@ export function Tables() { ] ) - // Stable identities so the memoized Resource.Header / Resource.Options can + // Stable identities so the memoized Resource.Header / Resource.Options / Resource.Table can // actually bail — inline object/element props would defeat their memo. const headerAside = useMemo(() => , [workspaceId]) + + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveTables.isPending, + bulkDeleteTables.isPending, + ] + ) const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) return ( @@ -1041,6 +1258,7 @@ export function Tables() { breadcrumbs={breadcrumbs} actions={headerActions} aside={headerAside} + breadcrumbDrop={rowDragDropConfig.breadcrumb} /> @@ -1086,7 +1306,7 @@ export function Tables() { onCopyId={() => { if (activeTable) navigator.clipboard.writeText(activeTable.id) }} - onDelete={() => setIsDeleteDialogOpen(true)} + onDelete={handleDeleteTableFromMenu} onRename={() => { if (activeTable) listRename.startRename(activeTable.id, activeTable.name) }} @@ -1103,8 +1323,8 @@ export function Tables() { }} onTogglePin={handleTogglePin} pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false} - onMove={canEdit ? handleMoveTable : undefined} - moveOptions={canEdit ? tableMoveOptions : undefined} + onMove={canEdit ? handleMoveTableFromMenu : undefined} + moveOptions={canEdit ? activeMoveOptions : undefined} disableDelete={!canEdit} disableRename={!canEdit} disableImport={!canEdit} @@ -1124,11 +1344,11 @@ export function Tables() { onCopyId={() => { if (activeFolder) navigator.clipboard.writeText(activeFolder.id) }} - onDelete={() => setIsDeleteFolderDialogOpen(true)} + onDelete={handleDeleteFolderFromMenu} onTogglePin={handleTogglePin} pinned={activeFolder ? pinnedFolderIds.has(activeFolder.id) : false} - onMove={canEdit ? handleMoveFolder : undefined} - moveOptions={canEdit ? folderMoveOptions : undefined} + onMove={canEdit ? handleMoveFolderFromMenu : undefined} + moveOptions={canEdit ? activeFolderMoveOptions : undefined} canEdit={canEdit} /> @@ -1189,6 +1409,32 @@ export function Tables() { pendingLabel: 'Deleting...', }} /> + + 0 + ? 'Every table and subfolder inside the selected folders will be deleted too.' + : 'All of their rows will be removed.', + error: true, + }, + ' You can restore those tables from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteTables.isPending, + pendingLabel: 'Deleting...', + }} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 4449886f4a0..a4d21d33df0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -471,6 +471,7 @@ export function CredentialSelector({ providerId: effectiveProviderId, preCount: credentials.filter((c) => c.type !== 'service_account').length, workspaceId, + reconnect: true, requestedAt: Date.now(), }) setShowOAuthModal(true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index aa51770bbed..8d6be7b8e21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -251,6 +251,7 @@ export function ToolCredentialSelector({ providerId: effectiveProviderId, preCount: credentials.length, workspaceId, + reconnect: true, requestedAt: Date.now(), }) setShowOAuthModal(true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 20492c4127d..e19bac41ca3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useRef } from 'react' +import { isUserSuppliedToolParam } from '@/lib/workflows/tool-input/param-visibility' import { buildToolSubBlockId, resolveToolParamSync, @@ -122,8 +123,10 @@ export function ToolSubBlockRenderer({ pushParamValueToStore(toolParamValue) }, [toolParamValue, pushParamValueToStore]) - const visibility = subBlock.paramVisibility ?? 'user-or-llm' - const isOptionalForUser = visibility !== 'user-only' + // Shared with the fork-sync gate so "is this the user's to fill?" is answered the same way + // in the editor and when a sync decides whether a blank value blocks. `required` itself + // stays for the field below to resolve in its own value context. + const isOptionalForUser = !isUserSuppliedToolParam(subBlock) const config = { ...subBlock, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 406dd5f06a0..3babe49ba7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -100,6 +100,7 @@ import { getDependsOnFields } from '@/blocks/utils' import { useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useDeployWorkflow } from '@/hooks/queries/deployments' +import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useSandboxes } from '@/hooks/queries/sandboxes' @@ -380,6 +381,12 @@ const SubBlockRow = memo(function SubBlockRow({ () => resolveDropdownLabel(subBlock, rawValue), [subBlock, rawValue] ) + const dynamicOptionDisplayName = useDynamicSubBlockOptionDisplayName({ + workspaceId, + blockId, + subBlock, + value: rawValue, + }) const resolveContextValue = useCallback( (key: string): string | undefined => { @@ -568,6 +575,7 @@ const SubBlockRow = memo(function SubBlockRow({ const hydratedName = credentialName || dropdownLabel || + dynamicOptionDisplayName || variablesDisplayValue || filterDisplayValue || toolsDisplayValue || diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2d035136aeb..966b83b2072 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -572,6 +572,7 @@ const WorkflowContent = React.memo( providerId: detail.providerId, preCount: 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) diff --git a/apps/sim/blocks/blocks/browser_use.ts b/apps/sim/blocks/blocks/browser_use.ts index 193a29d8329..6d0671d1867 100644 --- a/apps/sim/blocks/blocks/browser_use.ts +++ b/apps/sim/blocks/blocks/browser_use.ts @@ -41,6 +41,8 @@ export const BrowserUseBlock: BlockConfig = { id: 'variables', title: 'Variables (Secrets)', type: 'table', + password: true, + required: false, columns: ['Key', 'Value'], }, { diff --git a/apps/sim/blocks/blocks/cloudflare.ts b/apps/sim/blocks/blocks/cloudflare.ts index bc2707558d5..2f3322b0ae5 100644 --- a/apps/sim/blocks/blocks/cloudflare.ts +++ b/apps/sim/blocks/blocks/cloudflare.ts @@ -2,13 +2,82 @@ import { CloudflareIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' import type { CloudflareResponse } from '@/tools/cloudflare/types' +/** + * Per-operation aliases from a tool param name to the subBlock id that supplies + * it, keyed by operation. + * + * Block state is keyed by subBlock id, so two controls sharing an id share one + * stored value and the last definition in file order wins the seeded default. + * That is fine — and deliberate — when both controls mean the same thing (a zone + * id, a page number, a record name). It is not fine when they differ: a control + * in `mode: 'advanced'` is serialized whenever its stored value is non-empty, + * *before* its own `condition` is evaluated (`shouldSerializeSubBlock` in + * `serializer/index.ts`), so a hidden filter from one operation would otherwise + * arrive as a written value on another — a `list_dns_records` "Content Filter" + * silently overwriting a record's content on `update_dns_record`, or a + * `list_zones` status reaching `list_tunnels`, whose status enum is disjoint. + * + * Every such control therefore carries its own id and is republished here under + * the tool's param name, before any coercion in the mapper reads it. + * + * Which side of a collision gets the new id is not a free choice. Block state + * is never migrated, and `extractBlockParams` (`serializer/index.ts`) drops a + * stored value whose id matches no subBlock config — a deleted input — so the + * renamed side silently loses whatever shipped workflows stored. The read + * filters therefore keep their original ids, where losing a value means + * returning the whole zone under `success: true`, and the write controls take + * the new ones, where losing a value means a PATCH simply omits the field. + */ +const SUBBLOCK_ALIASES: Record> = { + create_zone: { type: 'zoneType' }, + create_dns_record: { type: 'recordType', proxied: 'recordProxied', tags: 'recordTags' }, + update_dns_record: { + type: 'updateRecordType', + name: 'updateRecordName', + content: 'updateRecordContent', + proxied: 'updateRecordProxied', + tags: 'updateRecordTags', + }, + list_dns_records: { order: 'dnsOrder' }, + list_certificates: { status: 'certificateStatus' }, + create_ruleset: { name: 'rulesetName' }, + update_ruleset_rule: { enabled: 'updateRuleEnabled' }, + create_rate_limit_rule: { action: 'rateLimitAction' }, + update_rate_limit_rule: { action: 'updateRateLimitAction', enabled: 'updateRuleEnabled' }, + create_access_application: { type: 'appType', tags: 'accessAppTags' }, + update_access_application: { type: 'updateAppType', tags: 'accessAppTags' }, + update_access_policy: { decision: 'updatePolicyDecision' }, + list_access_applications: { name: 'listNameFilter', domain: 'accessAppDomainFilter' }, + list_access_groups: { name: 'listNameFilter' }, + list_access_service_tokens: { name: 'listNameFilter' }, + list_worker_scripts: { tags: 'workerTagFilter' }, + list_tunnels: { status: 'tunnelStatus', name: 'listNameFilter' }, + list_r2_buckets: { cursor: 'r2Cursor' }, + list_rulesets: { cursor: 'rulesetCursor' }, +} + +/** + * Access application types whose request schema makes `domain` mandatory. + * + * `access_app_request` is an `anyOf` over per-type variants: `domain` is + * required on the self_hosted, ssh, vnc, and rdp variants, optional and + * writable on bookmark and mcp_portal, read-only on app_launcher, warp, biso, + * and proxy_endpoint, and absent from saas, infrastructure, and mcp. + */ +const DOMAIN_REQUIRED_APP_TYPES = ['self_hosted', 'ssh', 'vnc', 'rdp'] as const + +/** Every alias subBlock id, so none of them can reach a tool as a param. */ +const ALIASED_SUBBLOCK_IDS = [ + ...new Set(Object.values(SUBBLOCK_ALIASES).flatMap((aliases) => Object.values(aliases))), +] + export const CloudflareBlock: BlockConfig = { type: 'cloudflare', name: 'Cloudflare', - description: 'Manage DNS, domains, certificates, and cache', + description: 'Manage DNS, WAF, Zero Trust access, and edge infrastructure', authMode: AuthMode.ApiKey, longDescription: - 'Integrate Cloudflare into the workflow. Manage zones (domains), DNS records, SSL/TLS certificates, zone settings, DNS analytics, and cache purging via the Cloudflare API.', + 'Integrate Cloudflare into the workflow. Manage zones (domains), DNS records, SSL/TLS certificates, zone settings, DNS analytics, and cache purging. Configure WAF rulesets, managed rule overrides, and rate limiting rules through the current Rulesets engine. Administer Cloudflare Access (Zero Trust) applications, policies, groups, identity providers, and service tokens, and inspect R2 buckets, Workers scripts and routes, and Cloudflare Tunnels.', docsLink: 'https://docs.sim.ai/integrations/cloudflare', category: 'tools', integrationType: IntegrationType.DevOps, @@ -36,12 +105,12 @@ export const CloudflareBlock: BlockConfig = { ], create_dns_record: [ { text: 'Add DNS record', field: 'name', core: true }, - { text: 'of type', field: 'type' }, + { text: 'of type', field: 'recordType' }, { text: ', pointing at', field: 'content' }, ], update_dns_record: [ { text: 'Update DNS record', field: 'recordId', core: true }, - { text: ', pointing it at', field: 'content' }, + { text: ', pointing it at', field: 'updateRecordContent' }, { text: ', with TTL', field: 'ttl' }, ], delete_dns_record: [ @@ -50,7 +119,7 @@ export const CloudflareBlock: BlockConfig = { ], list_certificates: [ { text: 'List certificate packs for zone', field: 'zoneId', core: true }, - { text: ', with status', field: 'status' }, + { text: ', with status', field: 'certificateStatus' }, ], get_zone_settings: [{ text: 'Read all settings of zone', field: 'zoneId', core: true }], update_zone_setting: [ @@ -67,6 +136,123 @@ export const CloudflareBlock: BlockConfig = { { text: 'Purge cache for zone', field: 'zoneId', core: true }, { text: ', limited to', field: ['files', 'prefixes', 'hosts', 'tags'] }, ], + list_rulesets: [{ text: 'List rulesets in zone', field: 'zoneId', core: true }], + get_ruleset: [ + { text: 'Read ruleset', field: 'rulesetId', core: true }, + { text: 'in zone', field: 'zoneId' }, + ], + get_ruleset_entrypoint: [ + { text: 'Read the entry point ruleset for phase', field: 'phase', core: true }, + { text: 'in zone', field: 'zoneId' }, + ], + create_ruleset: [ + { text: 'Create a ruleset for phase', field: 'phase', core: true }, + { text: 'in zone', field: 'zoneId' }, + ], + create_ruleset_rule: [ + { text: 'Add a rule that runs', field: 'action', core: true }, + { text: 'when', field: 'expression', core: true }, + { text: ', to ruleset', field: 'rulesetId' }, + ], + update_ruleset_rule: [ + { text: 'Update rule', field: 'ruleId', core: true }, + { text: 'in ruleset', field: 'rulesetId' }, + { text: ', to run', field: 'action' }, + ], + delete_ruleset_rule: [ + { text: 'Delete rule', field: 'ruleId', core: true }, + { text: 'from ruleset', field: 'rulesetId' }, + ], + list_managed_ruleset_overrides: [ + { text: 'List WAF managed ruleset overrides for zone', field: 'zoneId', core: true }, + ], + list_rate_limit_rules: [ + { text: 'List rate limiting rules in zone', field: 'zoneId', core: true }, + ], + create_rate_limit_rule: [ + { text: 'Rate limit', field: 'expression', core: true }, + { text: 'to', field: 'requestsPerPeriod', core: true }, + { text: 'requests every', field: 'period' }, + ], + update_rate_limit_rule: [ + { text: 'Update rate limiting rule', field: 'ruleId', core: true }, + { text: 'to', field: 'requestsPerPeriod' }, + { text: 'requests every', field: 'period' }, + ], + list_access_applications: [ + { text: 'List Access applications in account', field: 'accountId', core: true }, + { text: ', matching', field: ['listNameFilter', 'accessAppDomainFilter', 'search'] }, + ], + get_access_application: [{ text: 'Read Access application', field: 'appId', core: true }], + create_access_application: [ + { text: 'Protect', field: 'domain', core: true }, + { text: 'with an Access application named', field: 'name' }, + ], + update_access_application: [ + { text: 'Update Access application', field: 'appId', core: true }, + { text: ', securing', field: 'domain' }, + ], + delete_access_application: [ + { text: 'Delete Access application', field: 'appId', core: true }, + ], + list_access_policies: [ + { text: 'List Access policies on application', field: 'appId', core: true }, + ], + create_access_policy: [ + { text: 'Add Access policy', field: 'name', core: true }, + { text: 'that will', field: 'decision', core: true }, + { text: 'on application', field: 'appId' }, + ], + update_access_policy: [ + { text: 'Update Access policy', field: 'policyId', core: true }, + { text: 'to', field: 'updatePolicyDecision' }, + { text: 'on application', field: 'appId' }, + ], + delete_access_policy: [ + { text: 'Delete Access policy', field: 'policyId', core: true }, + { text: 'from application', field: 'appId' }, + ], + list_access_groups: [ + { text: 'List Access groups in account', field: 'accountId', core: true }, + ], + list_access_identity_providers: [ + { text: 'List Access identity providers in account', field: 'accountId', core: true }, + ], + list_access_service_tokens: [ + { text: 'List Access service tokens in account', field: 'accountId', core: true }, + ], + create_access_service_token: [ + { text: 'Create Access service token', field: 'name', core: true }, + { text: ', valid for', field: 'duration' }, + ], + revoke_access_service_token: [ + { text: 'Revoke Access service token', field: 'serviceTokenId', core: true }, + ], + list_r2_buckets: [ + { text: 'List R2 buckets in account', field: 'accountId', core: true }, + { text: ', named like', field: 'name_contains' }, + ], + get_r2_bucket: [{ text: 'Read R2 bucket', field: 'bucketName', core: true }], + create_r2_bucket: [ + { text: 'Create R2 bucket', field: 'bucketName', core: true }, + { text: 'in', field: 'locationHint' }, + ], + delete_r2_bucket: [{ text: 'Delete R2 bucket', field: 'bucketName', core: true }], + list_worker_scripts: [ + { text: 'List Worker scripts in account', field: 'accountId', core: true }, + ], + get_worker_script_settings: [ + { text: 'Read settings of Worker script', field: 'scriptName', core: true }, + ], + list_worker_routes: [{ text: 'List Worker routes in zone', field: 'zoneId', core: true }], + list_tunnels: [ + { text: 'List tunnels in account', field: 'accountId', core: true }, + { text: ', with status', field: 'tunnelStatus' }, + ], + get_tunnel: [{ text: 'Read tunnel', field: 'tunnelId', core: true }], + get_tunnel_configuration: [ + { text: 'Read the configuration of tunnel', field: 'tunnelId', core: true }, + ], }, }, }, @@ -89,6 +275,41 @@ export const CloudflareBlock: BlockConfig = { { label: 'Update Zone Setting', id: 'update_zone_setting' }, { label: 'DNS Analytics', id: 'dns_analytics' }, { label: 'Purge Cache', id: 'purge_cache' }, + { label: 'List Rulesets', id: 'list_rulesets' }, + { label: 'Get Ruleset', id: 'get_ruleset' }, + { label: 'Get Phase Entry Point Ruleset', id: 'get_ruleset_entrypoint' }, + { label: 'Create Ruleset', id: 'create_ruleset' }, + { label: 'Create Ruleset Rule', id: 'create_ruleset_rule' }, + { label: 'Update Ruleset Rule', id: 'update_ruleset_rule' }, + { label: 'Delete Ruleset Rule', id: 'delete_ruleset_rule' }, + { label: 'List Managed Ruleset Overrides', id: 'list_managed_ruleset_overrides' }, + { label: 'List Rate Limiting Rules', id: 'list_rate_limit_rules' }, + { label: 'Create Rate Limiting Rule', id: 'create_rate_limit_rule' }, + { label: 'Update Rate Limiting Rule', id: 'update_rate_limit_rule' }, + { label: 'List Access Applications', id: 'list_access_applications' }, + { label: 'Get Access Application', id: 'get_access_application' }, + { label: 'Create Access Application', id: 'create_access_application' }, + { label: 'Update Access Application', id: 'update_access_application' }, + { label: 'Delete Access Application', id: 'delete_access_application' }, + { label: 'List Access Policies', id: 'list_access_policies' }, + { label: 'Create Access Policy', id: 'create_access_policy' }, + { label: 'Update Access Policy', id: 'update_access_policy' }, + { label: 'Delete Access Policy', id: 'delete_access_policy' }, + { label: 'List Access Groups', id: 'list_access_groups' }, + { label: 'List Access Identity Providers', id: 'list_access_identity_providers' }, + { label: 'List Access Service Tokens', id: 'list_access_service_tokens' }, + { label: 'Create Access Service Token', id: 'create_access_service_token' }, + { label: 'Revoke Access Service Token', id: 'revoke_access_service_token' }, + { label: 'List R2 Buckets', id: 'list_r2_buckets' }, + { label: 'Get R2 Bucket', id: 'get_r2_bucket' }, + { label: 'Create R2 Bucket', id: 'create_r2_bucket' }, + { label: 'Delete R2 Bucket', id: 'delete_r2_bucket' }, + { label: 'List Worker Scripts', id: 'list_worker_scripts' }, + { label: 'Get Worker Script Settings', id: 'get_worker_script_settings' }, + { label: 'List Worker Routes', id: 'list_worker_routes' }, + { label: 'List Tunnels', id: 'list_tunnels' }, + { label: 'Get Tunnel', id: 'get_tunnel' }, + { label: 'Get Tunnel Configuration', id: 'get_tunnel_configuration' }, ], value: () => 'list_zones', }, @@ -304,7 +525,7 @@ export const CloudflareBlock: BlockConfig = { mode: 'advanced', }, { - id: 'order', + id: 'dnsOrder', title: 'Sort Field', type: 'dropdown', options: [ @@ -396,7 +617,7 @@ export const CloudflareBlock: BlockConfig = { condition: { field: 'operation', value: 'create_dns_record' }, }, { - id: 'type', + id: 'recordType', title: 'Record Type', type: 'dropdown', options: [ @@ -436,7 +657,7 @@ export const CloudflareBlock: BlockConfig = { mode: 'advanced', }, { - id: 'proxied', + id: 'recordProxied', title: 'Proxied', type: 'dropdown', options: [ @@ -464,7 +685,7 @@ export const CloudflareBlock: BlockConfig = { mode: 'advanced', }, { - id: 'tags', + id: 'recordTags', title: 'Tags', type: 'short-input', placeholder: 'Comma-separated tags (e.g., production,web)', @@ -490,7 +711,7 @@ export const CloudflareBlock: BlockConfig = { condition: { field: 'operation', value: 'update_dns_record' }, }, { - id: 'type', + id: 'updateRecordType', title: 'Record Type', type: 'dropdown', options: [ @@ -508,7 +729,12 @@ export const CloudflareBlock: BlockConfig = { mode: 'advanced', }, { - id: 'name', + /** + * Renaming a live DNS record is a write, and this control is advanced, so + * sharing the bare `name` id let a name typed under any other operation + * reach the PATCH and rename the record. + */ + id: 'updateRecordName', title: 'Record Name', type: 'short-input', placeholder: 'e.g., example.com or sub.example.com', @@ -516,7 +742,7 @@ export const CloudflareBlock: BlockConfig = { mode: 'advanced', }, { - id: 'content', + id: 'updateRecordContent', title: 'New Content', type: 'short-input', placeholder: 'e.g., 192.0.2.1', @@ -532,7 +758,7 @@ export const CloudflareBlock: BlockConfig = { mode: 'advanced', }, { - id: 'proxied', + id: 'updateRecordProxied', title: 'Proxied', type: 'dropdown', options: [ @@ -561,7 +787,7 @@ export const CloudflareBlock: BlockConfig = { mode: 'advanced', }, { - id: 'tags', + id: 'updateRecordTags', title: 'Tags', type: 'short-input', placeholder: 'Comma-separated tags (e.g., production,web)', @@ -597,13 +823,12 @@ export const CloudflareBlock: BlockConfig = { condition: { field: 'operation', value: 'list_certificates' }, }, { - id: 'status', + id: 'certificateStatus', title: 'Status Filter', type: 'dropdown', options: [ - { label: 'All', id: 'all' }, - { label: 'Active', id: 'active' }, - { label: 'Pending', id: 'pending' }, + { label: 'All statuses', id: 'all' }, + { label: 'Active only', id: '' }, ], value: () => 'all', condition: { field: 'operation', value: 'list_certificates' }, @@ -950,165 +1175,1726 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, mode: 'advanced', }, - // API Key (common) + // Zone ID for zone-scoped WAF, rate limiting, and Workers routes operations { - id: 'apiKey', - title: 'API Token', + id: 'zoneId', + title: 'Zone ID', type: 'short-input', required: true, - placeholder: 'Enter your Cloudflare API token', - password: true, + placeholder: 'Enter zone ID', + condition: { + field: 'operation', + value: [ + 'list_rulesets', + 'get_ruleset', + 'get_ruleset_entrypoint', + 'create_ruleset', + 'create_ruleset_rule', + 'update_ruleset_rule', + 'delete_ruleset_rule', + 'list_managed_ruleset_overrides', + 'list_rate_limit_rules', + 'create_rate_limit_rule', + 'update_rate_limit_rule', + 'list_worker_routes', + ], + }, }, - ], - tools: { - access: [ - 'cloudflare_list_zones', - 'cloudflare_get_zone', - 'cloudflare_create_zone', - 'cloudflare_delete_zone', - 'cloudflare_list_dns_records', - 'cloudflare_create_dns_record', - 'cloudflare_update_dns_record', - 'cloudflare_delete_dns_record', - 'cloudflare_list_certificates', - 'cloudflare_get_zone_settings', - 'cloudflare_update_zone_setting', - 'cloudflare_dns_analytics', - 'cloudflare_purge_cache', - ], - config: { - tool: (params) => `cloudflare_${params.operation}`, - params: (params) => { - const result = { ...params } - - if (result.ttl) result.ttl = Number(result.ttl) - if (result.priority) result.priority = Number(result.priority) - if (result.limit) result.limit = Number(result.limit) - if (result.page) result.page = Number(result.page) - if (result.per_page) result.per_page = Number(result.per_page) - if (result.proxied === 'true') result.proxied = true - else if (result.proxied === 'false') result.proxied = false - else if (result.proxied === '') result.proxied = undefined + // Account ID for account-scoped Access, R2, Workers scripts, and Tunnels operations + { + id: 'accountId', + title: 'Account ID', + type: 'short-input', + required: true, + placeholder: 'Enter Cloudflare account ID', + condition: { + field: 'operation', + value: [ + 'list_access_applications', + 'get_access_application', + 'create_access_application', + 'update_access_application', + 'delete_access_application', + 'list_access_policies', + 'create_access_policy', + 'update_access_policy', + 'delete_access_policy', + 'list_access_groups', + 'list_access_identity_providers', + 'list_access_service_tokens', + 'create_access_service_token', + 'revoke_access_service_token', + 'list_r2_buckets', + 'get_r2_bucket', + 'create_r2_bucket', + 'delete_r2_bucket', + 'list_worker_scripts', + 'get_worker_script_settings', + 'list_tunnels', + 'get_tunnel', + 'get_tunnel_configuration', + ], + }, + }, - if (result.purge_everything === 'true') result.purge_everything = true - else if (result.purge_everything === 'false') result.purge_everything = false + // Ruleset inputs + { + id: 'phase', + title: 'Phase', + type: 'dropdown', + options: [ + { label: 'WAF Custom Rules', id: 'http_request_firewall_custom' }, + { label: 'WAF Managed Rules', id: 'http_request_firewall_managed' }, + { label: 'Rate Limiting', id: 'http_ratelimit' }, + { label: 'Request Transform', id: 'http_request_transform' }, + { label: 'Late Request Transform', id: 'http_request_late_transform' }, + { label: 'Dynamic Redirects', id: 'http_request_dynamic_redirect' }, + { label: 'Request Cache Settings', id: 'http_request_cache_settings' }, + { label: 'Config Settings', id: 'http_config_settings' }, + { label: 'Origin', id: 'http_request_origin' }, + { label: 'Response Headers Transform', id: 'http_response_headers_transform' }, + { label: 'Response Compression', id: 'http_response_compression' }, + { label: 'Custom Errors', id: 'http_custom_errors' }, + { label: 'Super Bot Fight Mode', id: 'http_request_sbfm' }, + ], + value: () => 'http_request_firewall_custom', + required: true, + condition: { field: 'operation', value: ['get_ruleset_entrypoint', 'create_ruleset'] }, + }, + { + id: 'rulesetName', + title: 'Ruleset Name', + type: 'short-input', + required: true, + placeholder: 'e.g. Zone rate limiting ruleset', + condition: { field: 'operation', value: 'create_ruleset' }, + }, + { + id: 'kind', + title: 'Ruleset Kind', + type: 'dropdown', + options: [ + { label: 'Zone (phase entry point)', id: 'zone' }, + { label: 'Custom', id: 'custom' }, + ], + value: () => 'zone', + condition: { field: 'operation', value: 'create_ruleset' }, + mode: 'advanced', + }, + { + id: 'rules', + title: 'Initial Rules', + type: 'long-input', + placeholder: 'JSON array of rules to seed the ruleset with', + condition: { field: 'operation', value: 'create_ruleset' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `Generate a Cloudflare ruleset rules array from the user's description. - if (result.type === '' && result.operation !== 'create_dns_record') { - result.type = undefined - } - if (result.status === '') result.status = undefined - if (result.order === '') result.order = undefined - if (result.direction === '') result.direction = undefined - if (result.match === '') result.match = undefined - if (result.tag_match === '') result.tag_match = undefined - if (result.deploy === '') result.deploy = undefined +Each rule is an object with: +- "action": the action to take, e.g. "block", "challenge", "managed_challenge", "js_challenge", "log", "skip", "execute" +- "expression": a Cloudflare filter expression selecting matching requests +- "description": optional human-readable description +- "enabled": optional boolean, defaults to true - if (result.operation === 'update_dns_record') { - if (result.content === '') result.content = undefined - if (result.name === '') result.name = undefined - if (result.comment === '') result.comment = undefined - } +A rate limiting rule (http_ratelimit phase) also takes "ratelimit" with "characteristics", "period", and "requests_per_period". - if (result.operation === 'create_zone' && result.zoneType) { - result.type = result.zoneType - } +Examples: +- "block traffic from Russia" -> [{"action":"block","expression":"(ip.geoip.country eq \\"RU\\")","description":"Block RU"}] +- "rate limit the login endpoint to 10 requests a minute per IP" -> [{"action":"block","expression":"(http.request.uri.path eq \\"/login\\")","description":"Login rate limit","ratelimit":{"characteristics":["ip.src","cf.colo.id"],"period":60,"requests_per_period":10}}] - return result +Return ONLY the JSON array - no explanations, no markdown fences.`, + placeholder: 'Describe the rules to seed this ruleset with...', + generationType: 'json-array', }, }, - }, - inputs: { - operation: { type: 'string', description: 'Operation to perform' }, - apiKey: { type: 'string', description: 'Cloudflare API token' }, - zoneId: { type: 'string', description: 'Zone ID' }, - accountId: { type: 'string', description: 'Cloudflare account ID' }, - zoneType: { type: 'string', description: 'Zone type (full, partial, or secondary)' }, - order: { type: 'string', description: 'Sort field for listing zones' }, - direction: { type: 'string', description: 'Sort direction (asc, desc)' }, - match: { type: 'string', description: 'Match logic for filters (any, all)' }, - recordId: { type: 'string', description: 'DNS record ID' }, - name: { type: 'string', description: 'Domain or record name' }, - type: { type: 'string', description: 'DNS record type' }, - content: { type: 'string', description: 'DNS record content' }, - ttl: { type: 'number', description: 'Time to live in seconds' }, - proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, - priority: { type: 'number', description: 'Record priority (MX/SRV)' }, - comment: { type: 'string', description: 'Record comment' }, - search: { type: 'string', description: 'Free-text search across record properties' }, - tag: { type: 'string', description: 'Filter by an exact tag name' }, - tag_match: { type: 'string', description: 'Tag filter match logic (any, all)' }, - commentFilter: { type: 'string', description: 'Filter records by comment content' }, - settingId: { type: 'string', description: 'Zone setting ID' }, - value: { type: 'string', description: 'Setting value' }, - since: { type: 'string', description: 'Start date for analytics' }, - until: { type: 'string', description: 'End date for analytics' }, - metrics: { type: 'string', description: 'Comma-separated metrics to retrieve' }, - dimensions: { type: 'string', description: 'Comma-separated dimensions to group by' }, - filters: { type: 'string', description: 'Filters to apply (e.g., queryType==A)' }, - sort: { type: 'string', description: 'Sort order for results' }, - limit: { type: 'number', description: 'Maximum number of results' }, - status: { type: 'string', description: 'Status filter for zones or certificates' }, - page: { type: 'number', description: 'Page number for pagination' }, - per_page: { type: 'number', description: 'Number of results per page' }, - deploy: { - type: 'string', - description: 'Filter certificates by deployment environment (staging, production)', + { + id: 'rulesetId', + title: 'Ruleset ID', + type: 'short-input', + required: true, + placeholder: 'From "List Rulesets" or "Get Phase Entry Point Ruleset"', + condition: { + field: 'operation', + value: ['get_ruleset', 'create_ruleset_rule', 'update_ruleset_rule', 'delete_ruleset_rule'], + }, }, - purge_everything: { type: 'boolean', description: 'Purge all cached content' }, - files: { type: 'string', description: 'Comma-separated URLs to purge' }, - tags: { type: 'string', description: 'Comma-separated cache tags to purge (Enterprise only)' }, - hosts: { type: 'string', description: 'Comma-separated hostnames to purge (Enterprise only)' }, - prefixes: { - type: 'string', - description: 'Comma-separated URL prefixes to purge (Enterprise only)', + { + id: 'rulesetId', + title: 'Rate Limiting Ruleset ID', + type: 'short-input', + required: true, + placeholder: 'From "List Rate Limiting Rules"', + condition: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, }, - }, - outputs: { - zones: { type: 'json', description: 'List of zones/domains' }, - records: { type: 'json', description: 'List of DNS records' }, - certificates: { type: 'json', description: 'List of SSL/TLS certificate packs' }, - settings: { type: 'json', description: 'List of zone settings' }, - totals: { type: 'json', description: 'Aggregate DNS analytics totals' }, - min: { type: 'json', description: 'Minimum values across the DNS analytics period' }, - max: { type: 'json', description: 'Maximum values across the DNS analytics period' }, - query: { type: 'json', description: 'Echo of the DNS analytics query parameters sent' }, - validation_errors: { type: 'json', description: 'Validation issues for certificate packs' }, - data: { type: 'json', description: 'Raw analytics data rows from the DNS analytics report' }, - data_lag: { - type: 'number', - description: 'Processing lag in seconds before analytics data becomes available', + { + id: 'ruleId', + title: 'Rule ID', + type: 'short-input', + required: true, + placeholder: 'Enter rule ID', + condition: { + field: 'operation', + value: ['update_ruleset_rule', 'delete_ruleset_rule', 'update_rate_limit_rule'], + }, }, - rows: { type: 'number', description: 'Total number of rows in the DNS analytics result set' }, - id: { type: 'string', description: 'Resource ID' }, - zone_id: { type: 'string', description: 'Zone ID the record belongs to' }, - zone_name: { type: 'string', description: 'Zone domain name' }, - name: { type: 'string', description: 'Resource name' }, - status: { type: 'string', description: 'Resource status' }, - paused: { type: 'boolean', description: 'Whether the zone is paused' }, - type: { type: 'string', description: 'Zone or record type' }, - name_servers: { type: 'json', description: 'Assigned Cloudflare name servers' }, - original_name_servers: { type: 'json', description: 'Original registrar name servers' }, - plan: { - type: 'json', - description: - 'Zone plan information (id, name, price, currency, frequency, is_subscribed, legacy_id)', + { + id: 'action', + title: 'Action', + type: 'short-input', + required: { + field: 'operation', + value: ['create_ruleset_rule', 'update_ruleset_rule'], + }, + placeholder: 'block, challenge, managed_challenge, js_challenge, log, skip, execute', + condition: { + field: 'operation', + value: ['create_ruleset_rule', 'update_ruleset_rule'], + }, }, - account: { type: 'json', description: 'Account the zone belongs to (id, name)' }, - owner: { type: 'json', description: 'Zone owner information (id, name, type)' }, - activated_on: { type: 'string', description: 'ISO 8601 date when the zone was activated' }, - development_mode: { - type: 'number', - description: 'Seconds remaining in development mode (0 = off)', + { + id: 'rateLimitAction', + title: 'Action', + type: 'dropdown', + options: [ + { label: 'Block', id: 'block' }, + { label: 'Managed Challenge', id: 'managed_challenge' }, + { label: 'JS Challenge', id: 'js_challenge' }, + { label: 'Interactive Challenge', id: 'challenge' }, + { label: 'Log', id: 'log' }, + ], + value: () => 'block', + condition: { field: 'operation', value: 'create_rate_limit_rule' }, + }, + { + /** + * The update endpoint replaces the rule, so a seeded action would rewrite + * whatever the rule currently does the moment anything else is edited. + * This control carries no default: the user has to state the action the + * replaced rule should end up with. + */ + id: 'updateRateLimitAction', + title: 'Action', + type: 'dropdown', + options: [ + { label: 'Block', id: 'block' }, + { label: 'Managed Challenge', id: 'managed_challenge' }, + { label: 'JS Challenge', id: 'js_challenge' }, + { label: 'Interactive Challenge', id: 'challenge' }, + { label: 'Log', id: 'log' }, + ], + required: true, + condition: { field: 'operation', value: 'update_rate_limit_rule' }, }, - meta: { - type: 'json', - description: 'Resource metadata (zone: cdn_only, dns_only, etc.; DNS record: source)', + { + id: 'expression', + title: 'Expression', + type: 'long-input', + required: { + field: 'operation', + value: [ + 'create_ruleset_rule', + 'update_ruleset_rule', + 'create_rate_limit_rule', + 'update_rate_limit_rule', + ], + }, + placeholder: '(http.request.uri.path matches "^/api/")', + condition: { + field: 'operation', + value: [ + 'create_ruleset_rule', + 'update_ruleset_rule', + 'create_rate_limit_rule', + 'update_rate_limit_rule', + ], + }, + wandConfig: { + enabled: true, + prompt: `Generate a Cloudflare Ruleset Engine filter expression from the user's description. + +Syntax uses fields, operators, and logical connectors: +- ip.src, ip.src.country, ip.src.asnum +- http.host, http.request.uri.path, http.request.uri.query, http.request.method +- http.user_agent, http.referer +- cf.threat_score, cf.bot_management.score, cf.client.bot +- ssl + +Operators: eq, ne, lt, gt, contains, matches (regex), in { } +Connectors: and, or, not, with parentheses + +Examples: +- "block traffic from the UK and France" -> (ip.src.country in {"GB" "FR"}) +- "everything under /api" -> (http.request.uri.path matches "^/api/") +- "the login page from non-verified bots" -> (http.request.uri.path eq "/login" and cf.client.bot) +- "all requests" -> true + +Return ONLY the expression - no explanations, no quotes around the whole expression, no extra text.`, + placeholder: + 'Describe the traffic to match (e.g., "requests to /api from outside the US")...', + }, }, - vanity_name_servers: { type: 'json', description: 'Custom vanity name servers' }, - permissions: { type: 'json', description: 'User permissions for the zone' }, - content: { type: 'string', description: 'DNS record value (e.g., IP address)' }, - proxiable: { type: 'boolean', description: 'Whether the record can be proxied' }, + { + id: 'description', + title: 'Description', + type: 'short-input', + placeholder: 'Human-readable description of the rule', + condition: { + field: 'operation', + value: [ + 'create_ruleset', + 'create_ruleset_rule', + 'update_ruleset_rule', + 'create_rate_limit_rule', + 'update_rate_limit_rule', + ], + }, + }, + { + id: 'enabled', + title: 'Enabled', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_ruleset_rule', 'create_rate_limit_rule'], + }, + mode: 'advanced', + }, + { + /** + * The update endpoints replace the rule, so `enabled` is a live on/off + * switch for WAF and rate limiting there rather than a starting state. + * Sharing the create control's id let a `false` chosen while drafting a + * new rule reach a later update and disable an enforcing rule — from a + * field the operation does not render in basic mode, since an advanced + * control serializes on stored value alone, before its `condition` runs. + */ + id: 'updateRuleEnabled', + title: 'Enabled', + type: 'dropdown', + options: [ + { label: 'Leave unchanged (Cloudflare re-enables the rule)', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['update_ruleset_rule', 'update_rate_limit_rule'], + }, + mode: 'advanced', + }, + { + id: 'ref', + title: 'Reference Tag', + type: 'short-input', + placeholder: 'Stable reference that survives rule updates', + condition: { + field: 'operation', + value: ['create_ruleset_rule', 'update_ruleset_rule'], + }, + mode: 'advanced', + }, + { + id: 'ratelimit', + title: 'Rate Limiting Configuration', + type: 'long-input', + placeholder: + '{"characteristics":["cf.colo.id","ip.src"],"period":60,"requests_per_period":100}', + condition: { field: 'operation', value: 'update_ruleset_rule' }, + mode: 'advanced', + }, + { + id: 'logging', + title: 'Logging Configuration', + type: 'long-input', + placeholder: '{"enabled":true}', + condition: { field: 'operation', value: 'update_ruleset_rule' }, + mode: 'advanced', + }, + { + id: 'actionParameters', + title: 'Action Parameters', + type: 'long-input', + /** + * An `execute` rule carries the managed ruleset it deploys here, and the + * update endpoint replaces the rule — so leaving this blank resets + * action_parameters to {} and unbinds that ruleset. + */ + required: { field: 'action', value: 'execute' }, + placeholder: '{"id":"","overrides":{"action":"log"}}', + condition: { + field: 'operation', + value: ['create_ruleset_rule', 'update_ruleset_rule'], + }, + wandConfig: { + enabled: true, + prompt: `Generate the JSON action_parameters object for a Cloudflare ruleset rule from the user's description. + +For an "execute" rule that deploys a WAF managed ruleset: +{"id":""} + +To override the whole managed ruleset: +{"id":"","overrides":{"action":"log"}} + +To override specific categories: +{"id":"","overrides":{"categories":[{"category":"wordpress","action":"block","enabled":true}]}} + +To override specific rules: +{"id":"","overrides":{"rules":[{"id":"","action":"log","enabled":true}]}} + +"action" and "enabled" are overridable at every level. Individual managed rulesets may accept more: an OWASP Core Ruleset rule override also takes "score_threshold", e.g. +{"id":"","overrides":{"rules":[{"id":"","score_threshold":60,"action":"managed_challenge"}]}} + +Return ONLY the JSON object - no explanations, no markdown fences.`, + placeholder: + 'Describe the action parameters (e.g., "deploy the Cloudflare Managed Ruleset in log mode")...', + generationType: 'json-object', + }, + }, + { + id: 'position', + title: 'Position', + type: 'short-input', + placeholder: '{"index":1} or {"before":""} or {"after":""}', + condition: { field: 'operation', value: 'create_ruleset_rule' }, + mode: 'advanced', + }, + + // Rate limiting inputs + { + id: 'characteristics', + title: 'Characteristics', + type: 'short-input', + required: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + placeholder: 'cf.colo.id,ip.src', + condition: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Cloudflare rate limiting counting characteristics from the user's description. + +cf.colo.id is mandatory in every list. Include exactly one of ip.src or cf.unique_visitor_id. + +Available characteristics: +- cf.colo.id (mandatory) +- ip.src +- cf.unique_visitor_id +- http.host +- http.request.uri.path +- ip.src.asnum +- ip.src.country +- http.request.headers[""] +- http.request.cookies[""] +- http.request.uri.args[""] +- cf.bot_management.ja3_hash +- cf.bot_management.ja4 + +Examples: +- "per IP address" -> cf.colo.id,ip.src +- "per visitor" -> cf.colo.id,cf.unique_visitor_id +- "per API key header" -> cf.colo.id,ip.src,http.request.headers["x-api-key"] +- "per country" -> cf.colo.id,ip.src.country + +Return ONLY the comma-separated list - no explanations, no extra text.`, + placeholder: + 'Describe how to group counters (e.g., "per IP address", "per API key header")...', + }, + }, + { + id: 'period', + title: 'Period (seconds)', + type: 'dropdown', + options: [ + { label: '10 seconds', id: '10' }, + { label: '1 minute', id: '60' }, + { label: '2 minutes', id: '120' }, + { label: '5 minutes', id: '300' }, + { label: '10 minutes', id: '600' }, + { label: '1 hour', id: '3600' }, + ], + value: () => '60', + required: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + condition: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + }, + { + id: 'requestsPerPeriod', + title: 'Requests Per Period', + type: 'short-input', + required: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + placeholder: 'e.g., 100', + condition: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + }, + { + id: 'mitigationTimeout', + title: 'Mitigation Timeout (seconds)', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'No timeout', id: '0' }, + { label: '10 seconds', id: '10' }, + { label: '1 minute', id: '60' }, + { label: '2 minutes', id: '120' }, + { label: '5 minutes', id: '300' }, + { label: '10 minutes', id: '600' }, + { label: '1 hour', id: '3600' }, + { label: '1 day', id: '86400' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + mode: 'advanced', + }, + { + id: 'counting_expression', + title: 'Counting Expression', + type: 'long-input', + placeholder: 'Expression selecting which requests are counted, if different from the match', + condition: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + mode: 'advanced', + }, + { + id: 'requestsToOrigin', + title: 'Count Only Origin Requests', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_rate_limit_rule', 'update_rate_limit_rule'], + }, + mode: 'advanced', + }, + + // Access application inputs + { + id: 'appId', + title: 'Application ID', + type: 'short-input', + required: true, + placeholder: 'Enter Access application ID', + condition: { + field: 'operation', + value: [ + 'get_access_application', + 'update_access_application', + 'delete_access_application', + 'list_access_policies', + 'create_access_policy', + 'update_access_policy', + 'delete_access_policy', + ], + }, + }, + { + id: 'appType', + title: 'Application Type', + type: 'dropdown', + options: [ + { label: 'Self-Hosted', id: 'self_hosted' }, + { label: 'SaaS', id: 'saas' }, + { label: 'SSH', id: 'ssh' }, + { label: 'VNC', id: 'vnc' }, + { label: 'App Launcher', id: 'app_launcher' }, + { label: 'WARP', id: 'warp' }, + { label: 'Browser Isolation', id: 'biso' }, + { label: 'Bookmark', id: 'bookmark' }, + { label: 'Infrastructure', id: 'infrastructure' }, + { label: 'RDP', id: 'rdp' }, + { label: 'MCP', id: 'mcp' }, + { label: 'MCP Portal', id: 'mcp_portal' }, + { label: 'Proxy Endpoint', id: 'proxy_endpoint' }, + ], + value: () => 'self_hosted', + required: true, + condition: { field: 'operation', value: 'create_access_application' }, + }, + { + /** + * Updating an Access application replaces it, so a seeded type would + * rewrite what a live application IS as soon as anything else is edited. + * No default: the caller states the type the replaced application keeps. + */ + id: 'updateAppType', + title: 'Application Type', + type: 'dropdown', + options: [ + { label: 'Self-Hosted', id: 'self_hosted' }, + { label: 'SaaS', id: 'saas' }, + { label: 'SSH', id: 'ssh' }, + { label: 'VNC', id: 'vnc' }, + { label: 'App Launcher', id: 'app_launcher' }, + { label: 'WARP', id: 'warp' }, + { label: 'Browser Isolation', id: 'biso' }, + { label: 'Bookmark', id: 'bookmark' }, + { label: 'Infrastructure', id: 'infrastructure' }, + { label: 'RDP', id: 'rdp' }, + { label: 'MCP', id: 'mcp' }, + { label: 'MCP Portal', id: 'mcp_portal' }, + { label: 'Proxy Endpoint', id: 'proxy_endpoint' }, + ], + required: true, + condition: { field: 'operation', value: 'update_access_application' }, + }, + { + id: 'domain', + title: 'Domain', + type: 'short-input', + placeholder: 'e.g., internal.example.com — required for self_hosted, ssh, vnc, and rdp apps', + required: (values) => + values?.operation === 'update_access_application' + ? { field: 'updateAppType', value: [...DOMAIN_REQUIRED_APP_TYPES] } + : { field: 'appType', value: [...DOMAIN_REQUIRED_APP_TYPES] }, + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + }, + { + /** `saas_app` is required on the saas request variant and rejected elsewhere. */ + id: 'saasApp', + title: 'SaaS Application', + type: 'long-input', + required: true, + placeholder: '{"auth_type":"saml","consumer_service_url":"https://example.com/acs"}', + condition: (values) => + values?.operation === 'update_access_application' + ? { + field: 'updateAppType', + value: 'saas', + and: { field: 'operation', value: 'update_access_application' }, + } + : { + field: 'appType', + value: 'saas', + and: { field: 'operation', value: 'create_access_application' }, + }, + }, + { + /** `target_criteria` is required on the infrastructure and rdp variants. */ + id: 'targetCriteria', + title: 'Target Criteria', + type: 'long-input', + required: true, + placeholder: '[{"port":22,"protocol":"ssh","target_attributes":{"hostname":["app"]}}]', + condition: (values) => + values?.operation === 'update_access_application' + ? { + field: 'updateAppType', + value: ['infrastructure', 'rdp'], + and: { field: 'operation', value: 'update_access_application' }, + } + : { + field: 'appType', + value: ['infrastructure', 'rdp'], + and: { field: 'operation', value: 'create_access_application' }, + }, + }, + { + id: 'accessAppDomainFilter', + title: 'Domain Filter', + type: 'short-input', + placeholder: 'Filter by the hostname the application secures', + condition: { field: 'operation', value: 'list_access_applications' }, + mode: 'advanced', + }, + { + id: 'name', + title: 'Application Name', + type: 'short-input', + placeholder: 'Friendly name shown in the dashboard', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + }, + { + id: 'listNameFilter', + title: 'Name Filter', + type: 'short-input', + placeholder: 'Filter by name', + condition: { + field: 'operation', + value: [ + 'list_access_applications', + 'list_access_groups', + 'list_access_service_tokens', + 'list_tunnels', + ], + }, + mode: 'advanced', + }, + { + id: 'aud', + title: 'Audience Tag', + type: 'short-input', + placeholder: 'Filter by application AUD tag', + condition: { field: 'operation', value: 'list_access_applications' }, + mode: 'advanced', + }, + { + id: 'search', + title: 'Search', + type: 'short-input', + placeholder: 'Free-text search', + condition: { + field: 'operation', + value: ['list_access_applications', 'list_access_groups', 'list_access_service_tokens'], + }, + mode: 'advanced', + }, + { + id: 'exact', + title: 'Exact Match', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_access_applications' }, + mode: 'advanced', + }, + { + id: 'sessionDuration', + title: 'Session Duration', + type: 'short-input', + placeholder: 'e.g., 24h', + condition: { + field: 'operation', + value: [ + 'create_access_application', + 'update_access_application', + 'create_access_policy', + 'update_access_policy', + ], + }, + mode: 'advanced', + }, + { + id: 'allowedIdps', + title: 'Allowed Identity Providers', + type: 'short-input', + placeholder: 'Comma-separated identity provider IDs', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + { + id: 'appLauncherVisible', + title: 'Show in App Launcher', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + { + id: 'autoRedirectToIdentity', + title: 'Auto Redirect to Identity Provider', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + { + id: 'customDenyMessage', + title: 'Custom Deny Message', + type: 'short-input', + placeholder: 'Message shown to denied users', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + { + id: 'customDenyUrl', + title: 'Custom Deny URL', + type: 'short-input', + placeholder: 'https://example.com/denied', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + { + id: 'logoUrl', + title: 'Logo URL', + type: 'short-input', + placeholder: 'https://example.com/logo.png', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + { + id: 'accessAppTags', + title: 'Tags', + type: 'short-input', + placeholder: 'Comma-separated tag names', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + { + id: 'policies', + title: 'Policies', + type: 'long-input', + placeholder: 'JSON array of reusable policy IDs or inline policy objects', + condition: { + field: 'operation', + value: ['create_access_application', 'update_access_application'], + }, + mode: 'advanced', + }, + + // Access policy inputs + { + id: 'policyId', + title: 'Policy ID', + type: 'short-input', + required: true, + placeholder: 'Enter Access policy ID', + condition: { + field: 'operation', + value: ['update_access_policy', 'delete_access_policy'], + }, + }, + { + id: 'name', + title: 'Policy Name', + type: 'short-input', + required: true, + placeholder: 'e.g., Allow engineering team', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + }, + { + id: 'decision', + title: 'Decision', + type: 'dropdown', + options: [ + { label: 'Allow', id: 'allow' }, + { label: 'Deny', id: 'deny' }, + { label: 'Non-Identity (service tokens)', id: 'non_identity' }, + { label: 'Bypass (skip Access entirely)', id: 'bypass' }, + ], + value: () => 'allow', + required: true, + condition: { field: 'operation', value: 'create_access_policy' }, + }, + { + /** + * Updating a policy replaces it, so a seeded allow would silently widen a + * live deny, bypass, or non_identity policy the moment its rules are + * edited. No default: the caller states the decision. + */ + id: 'updatePolicyDecision', + title: 'Decision', + type: 'dropdown', + options: [ + { label: 'Allow', id: 'allow' }, + { label: 'Deny', id: 'deny' }, + { label: 'Non-Identity (service tokens)', id: 'non_identity' }, + { label: 'Bypass (skip Access entirely)', id: 'bypass' }, + ], + required: true, + condition: { field: 'operation', value: 'update_access_policy' }, + }, + { + id: 'include', + title: 'Include Rules', + type: 'long-input', + required: true, + placeholder: '[{"email_domain":{"domain":"example.com"}}]', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of Cloudflare Access rules from the user's description. These are evaluated with OR logic. + +Rule shapes: +- {"email":{"email":"user@example.com"}} +- {"email_domain":{"domain":"example.com"}} +- {"email_list":{"id":""}} +- {"everyone":{}} +- {"ip":{"ip":"192.0.2.1/32"}} +- {"ip_list":{"id":""}} +- {"group":{"id":""}} +- {"service_token":{"token_id":""}} +- {"any_valid_service_token":{}} +- {"certificate":{}} +- {"geo":{"country_code":"US"}} +- {"azureAD":{"id":"","connection_id":""}} +- {"okta":{"name":"","connection_id":""}} +- {"gsuite":{"email":"","connection_id":""}} + +Examples: +- "anyone at example.com" -> [{"email_domain":{"domain":"example.com"}}] +- "a specific person" -> [{"email":{"email":"user@example.com"}}] +- "any service token" -> [{"any_valid_service_token":{}}] +- "everyone" -> [{"everyone":{}}] + +Return ONLY the JSON array - no explanations, no markdown fences.`, + placeholder: 'Describe who should match (e.g., "anyone with an @example.com email")...', + generationType: 'json-array', + }, + }, + { + id: 'exclude', + title: 'Exclude Rules', + type: 'long-input', + placeholder: 'JSON array of Access rules evaluated with NOT logic', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + mode: 'advanced', + }, + { + id: 'require', + title: 'Require Rules', + type: 'long-input', + placeholder: 'JSON array of Access rules evaluated with AND logic', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + mode: 'advanced', + }, + { + id: 'precedence', + title: 'Precedence', + type: 'short-input', + placeholder: 'Evaluation order within the application', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + mode: 'advanced', + }, + { + id: 'approvalRequired', + title: 'Approval Required', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + mode: 'advanced', + }, + { + id: 'isolationRequired', + title: 'Browser Isolation Required', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + mode: 'advanced', + }, + { + id: 'purposeJustificationRequired', + title: 'Purpose Justification Required', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + mode: 'advanced', + }, + { + id: 'purposeJustificationPrompt', + title: 'Purpose Justification Prompt', + type: 'short-input', + placeholder: 'Why do you need access?', + condition: { + field: 'operation', + value: ['create_access_policy', 'update_access_policy'], + }, + mode: 'advanced', + }, + + // Access service token inputs + { + id: 'name', + title: 'Service Token Name', + type: 'short-input', + required: true, + placeholder: 'e.g., ci-pipeline', + condition: { field: 'operation', value: 'create_access_service_token' }, + }, + { + id: 'duration', + title: 'Duration', + type: 'short-input', + placeholder: 'e.g., 8760h', + condition: { field: 'operation', value: 'create_access_service_token' }, + mode: 'advanced', + }, + { + id: 'serviceTokenId', + title: 'Service Token ID', + type: 'short-input', + required: true, + placeholder: 'Enter the service token ID to revoke', + condition: { field: 'operation', value: 'revoke_access_service_token' }, + }, + + // R2 inputs + { + id: 'bucketName', + title: 'Bucket Name', + type: 'short-input', + required: true, + placeholder: 'Enter R2 bucket name', + condition: { + field: 'operation', + value: ['get_r2_bucket', 'create_r2_bucket', 'delete_r2_bucket'], + }, + }, + { + id: 'locationHint', + title: 'Location Hint', + type: 'dropdown', + options: [ + { label: 'Automatic', id: '' }, + { label: 'Asia-Pacific', id: 'apac' }, + { label: 'Eastern Europe', id: 'eeur' }, + { label: 'Eastern North America', id: 'enam' }, + { label: 'Western Europe', id: 'weur' }, + { label: 'Western North America', id: 'wnam' }, + { label: 'Oceania', id: 'oc' }, + ], + value: () => '', + condition: { field: 'operation', value: 'create_r2_bucket' }, + mode: 'advanced', + }, + { + id: 'storageClass', + title: 'Storage Class', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Standard', id: 'Standard' }, + { label: 'Infrequent Access', id: 'InfrequentAccess' }, + ], + value: () => '', + condition: { field: 'operation', value: 'create_r2_bucket' }, + mode: 'advanced', + }, + { + id: 'jurisdiction', + title: 'Jurisdiction', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'European Union', id: 'eu' }, + { label: 'FedRAMP', id: 'fedramp' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['list_r2_buckets', 'get_r2_bucket', 'create_r2_bucket', 'delete_r2_bucket'], + }, + mode: 'advanced', + }, + { + id: 'name_contains', + title: 'Name Contains', + type: 'short-input', + placeholder: 'Filter buckets by name substring', + condition: { field: 'operation', value: 'list_r2_buckets' }, + mode: 'advanced', + }, + { + id: 'start_after', + title: 'Start After', + type: 'short-input', + placeholder: 'Bucket name to start listing after', + condition: { field: 'operation', value: 'list_r2_buckets' }, + mode: 'advanced', + }, + { + /** + * R2 returns its cursor at `result_info.cursor` and the Rulesets API at + * `result_info.cursors.after`. The two are not interchangeable, so a + * cursor carried across from the other list 400s. + */ + id: 'r2Cursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Pagination cursor from a previous call', + condition: { field: 'operation', value: 'list_r2_buckets' }, + mode: 'advanced', + }, + { + id: 'rulesetCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Pagination cursor from a previous call', + condition: { field: 'operation', value: 'list_rulesets' }, + mode: 'advanced', + }, + { + id: 'direction', + title: 'Sort Direction', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Ascending', id: 'asc' }, + { label: 'Descending', id: 'desc' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_r2_buckets' }, + mode: 'advanced', + }, + + // Workers inputs + { + id: 'scriptName', + title: 'Script Name', + type: 'short-input', + required: true, + placeholder: 'Enter Worker script name', + condition: { field: 'operation', value: 'get_worker_script_settings' }, + }, + { + id: 'workerTagFilter', + title: 'Tag Filter', + type: 'short-input', + placeholder: 'Filter scripts by tag', + condition: { field: 'operation', value: 'list_worker_scripts' }, + mode: 'advanced', + }, + + // Tunnel inputs + { + id: 'tunnelId', + title: 'Tunnel ID', + type: 'short-input', + required: true, + placeholder: 'Enter tunnel ID', + condition: { field: 'operation', value: ['get_tunnel', 'get_tunnel_configuration'] }, + }, + { + id: 'tunnelStatus', + title: 'Status Filter', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Healthy', id: 'healthy' }, + { label: 'Degraded', id: 'degraded' }, + { label: 'Down', id: 'down' }, + { label: 'Inactive', id: 'inactive' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + { + id: 'uuid', + title: 'Tunnel UUID', + type: 'short-input', + placeholder: 'Filter by tunnel UUID', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + { + id: 'is_deleted', + title: 'Show Deleted Tunnels', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + { + id: 'include_prefix', + title: 'Include Name Prefix', + type: 'short-input', + placeholder: 'Only tunnels whose name starts with this prefix', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + { + id: 'exclude_prefix', + title: 'Exclude Name Prefix', + type: 'short-input', + placeholder: 'Skip tunnels whose name starts with this prefix', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + { + id: 'existed_at', + title: 'Existed At', + type: 'short-input', + placeholder: 'RFC 3339 timestamp', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + { + id: 'was_active_at', + title: 'Was Active At', + type: 'short-input', + placeholder: 'RFC 3339 timestamp', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + { + id: 'was_inactive_at', + title: 'Was Inactive At', + type: 'short-input', + placeholder: 'RFC 3339 timestamp', + condition: { field: 'operation', value: 'list_tunnels' }, + mode: 'advanced', + }, + + // Shared pagination for the new list operations + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: 'Page number (default: 1)', + condition: { + field: 'operation', + value: [ + 'list_access_applications', + 'list_access_policies', + 'list_access_groups', + 'list_access_service_tokens', + 'list_tunnels', + ], + }, + mode: 'advanced', + }, + { + id: 'per_page', + title: 'Per Page', + type: 'short-input', + placeholder: 'Results per page', + condition: { + field: 'operation', + value: [ + 'list_access_applications', + 'list_access_policies', + 'list_access_groups', + 'list_access_service_tokens', + 'list_r2_buckets', + 'list_rulesets', + 'list_tunnels', + ], + }, + mode: 'advanced', + }, + + // API Key (common) + { + id: 'apiKey', + title: 'API Token', + type: 'short-input', + required: true, + placeholder: 'Enter your Cloudflare API token', + password: true, + }, + ], + tools: { + access: [ + 'cloudflare_list_zones', + 'cloudflare_get_zone', + 'cloudflare_create_zone', + 'cloudflare_delete_zone', + 'cloudflare_list_dns_records', + 'cloudflare_create_dns_record', + 'cloudflare_update_dns_record', + 'cloudflare_delete_dns_record', + 'cloudflare_list_certificates', + 'cloudflare_get_zone_settings', + 'cloudflare_update_zone_setting', + 'cloudflare_dns_analytics', + 'cloudflare_purge_cache', + 'cloudflare_list_rulesets', + 'cloudflare_get_ruleset', + 'cloudflare_get_ruleset_entrypoint', + 'cloudflare_create_ruleset', + 'cloudflare_create_ruleset_rule', + 'cloudflare_update_ruleset_rule', + 'cloudflare_delete_ruleset_rule', + 'cloudflare_list_managed_ruleset_overrides', + 'cloudflare_list_rate_limit_rules', + 'cloudflare_create_rate_limit_rule', + 'cloudflare_update_rate_limit_rule', + 'cloudflare_list_access_applications', + 'cloudflare_get_access_application', + 'cloudflare_create_access_application', + 'cloudflare_update_access_application', + 'cloudflare_delete_access_application', + 'cloudflare_list_access_policies', + 'cloudflare_create_access_policy', + 'cloudflare_update_access_policy', + 'cloudflare_delete_access_policy', + 'cloudflare_list_access_groups', + 'cloudflare_list_access_identity_providers', + 'cloudflare_list_access_service_tokens', + 'cloudflare_create_access_service_token', + 'cloudflare_revoke_access_service_token', + 'cloudflare_list_r2_buckets', + 'cloudflare_get_r2_bucket', + 'cloudflare_create_r2_bucket', + 'cloudflare_delete_r2_bucket', + 'cloudflare_list_worker_scripts', + 'cloudflare_get_worker_script_settings', + 'cloudflare_list_worker_routes', + 'cloudflare_list_tunnels', + 'cloudflare_get_tunnel', + 'cloudflare_get_tunnel_configuration', + ], + config: { + tool: (params) => `cloudflare_${params.operation}`, + params: (params) => { + const result: Record = { ...params } + const operation = typeof result.operation === 'string' ? result.operation : '' + + for (const [toolParam, aliasId] of Object.entries(SUBBLOCK_ALIASES[operation] ?? {})) { + result[toolParam] = result[aliasId] + } + /** + * The executor merges this mapper's output over the raw inputs + * (`finalInputs = { ...inputs, ...transformedParams }` in + * `executor/handlers/generic/generic-handler.ts`), so a key this mapper + * merely omits survives as its raw subBlock string. Every alias id must + * therefore be assigned `undefined` rather than destructured away. + */ + for (const aliasId of ALIASED_SUBBLOCK_IDS) { + result[aliasId] = undefined + } + + if (result.ttl) result.ttl = Number(result.ttl) + if (result.priority) result.priority = Number(result.priority) + if (result.limit) result.limit = Number(result.limit) + if (result.page) result.page = Number(result.page) + if (result.per_page) result.per_page = Number(result.per_page) + + if (result.proxied === 'true') result.proxied = true + else if (result.proxied === 'false') result.proxied = false + else if (result.proxied === '') result.proxied = undefined + + if (result.purge_everything === 'true') result.purge_everything = true + else if (result.purge_everything === 'false') result.purge_everything = false + + if (result.type === '') result.type = undefined + if (result.status === '') result.status = undefined + if (result.order === '') result.order = undefined + if (result.direction === '') result.direction = undefined + if (result.match === '') result.match = undefined + if (result.tag_match === '') result.tag_match = undefined + if (result.deploy === '') result.deploy = undefined + + if (result.operation === 'update_dns_record') { + if (result.content === '') result.content = undefined + if (result.name === '') result.name = undefined + if (result.comment === '') result.comment = undefined + } + + const numericFields = [ + 'period', + 'requestsPerPeriod', + 'mitigationTimeout', + 'precedence', + ] as const + for (const field of numericFields) { + if (result[field] === '' || result[field] === undefined) { + result[field] = undefined + } else { + result[field] = Number(result[field]) + } + } + + const booleanFields = [ + 'enabled', + 'exact', + 'appLauncherVisible', + 'autoRedirectToIdentity', + 'approvalRequired', + 'isolationRequired', + 'purposeJustificationRequired', + 'requestsToOrigin', + 'is_deleted', + ] as const + for (const field of booleanFields) { + if (result[field] === 'true') result[field] = true + else if (result[field] === 'false') result[field] = false + else if (result[field] === '') result[field] = undefined + } + + const optionalStringFields = [ + 'phase', + 'action', + 'locationHint', + 'storageClass', + 'jurisdiction', + ] as const + for (const field of optionalStringFields) { + if (result[field] === '') result[field] = undefined + } + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Cloudflare API token' }, + zoneId: { type: 'string', description: 'Zone ID' }, + accountId: { type: 'string', description: 'Cloudflare account ID' }, + zoneType: { type: 'string', description: 'Zone type (full, partial, or secondary)' }, + order: { type: 'string', description: 'Sort field when listing zones' }, + direction: { type: 'string', description: 'Sort direction (asc, desc)' }, + match: { type: 'string', description: 'Match logic for filters (any, all)' }, + recordId: { type: 'string', description: 'DNS record ID' }, + name: { type: 'string', description: 'Domain or record name' }, + type: { type: 'string', description: 'DNS record type' }, + recordType: { type: 'string', description: 'DNS record type for record creation' }, + recordProxied: { + type: 'string', + description: 'Whether the created DNS record is proxied through Cloudflare', + }, + certificateStatus: { type: 'string', description: 'Certificate pack status filter' }, + dnsOrder: { type: 'string', description: 'Sort field when listing DNS records' }, + recordTags: { type: 'string', description: 'Tags applied to a created DNS record' }, + updateRecordType: { + type: 'string', + description: 'Record type a replaced DNS record ends up with', + }, + updateRecordName: { + type: 'string', + description: 'Record name a replaced DNS record ends up with', + }, + updateRecordContent: { + type: 'string', + description: 'Content a replaced DNS record ends up with', + }, + updateRecordProxied: { + type: 'string', + description: 'Whether a replaced DNS record ends up proxied through Cloudflare', + }, + updateRecordTags: { type: 'string', description: 'Tags a replaced DNS record ends up with' }, + updateRuleEnabled: { + type: 'string', + description: 'Whether a replaced WAF or rate limiting rule ends up enabled', + }, + r2Cursor: { type: 'string', description: 'Pagination cursor when listing R2 buckets' }, + rulesetCursor: { type: 'string', description: 'Pagination cursor when listing rulesets' }, + workerTagFilter: { type: 'string', description: 'Tag filter when listing Worker scripts' }, + accessAppTags: { type: 'string', description: 'Tag names applied to an Access application' }, + listNameFilter: { + type: 'string', + description: + 'Name filter shared by the Access application, group, service token, and tunnel list operations', + }, + accessAppDomainFilter: { + type: 'string', + description: 'Domain filter when listing Access applications', + }, + tunnelStatus: { type: 'string', description: 'Status filter when listing tunnels' }, + + appType: { type: 'string', description: 'Access application type' }, + updateAppType: { + type: 'string', + description: 'Access application type a replaced application ends up with', + }, + updatePolicyDecision: { + type: 'string', + description: 'Decision a replaced Access policy ends up applying', + }, + rateLimitAction: { + type: 'string', + description: 'Action applied once a rate limit is exceeded', + }, + updateRateLimitAction: { + type: 'string', + description: 'Action a replaced rate limiting rule ends up applying', + }, + content: { type: 'string', description: 'DNS record content' }, + ttl: { type: 'number', description: 'Time to live in seconds' }, + proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, + priority: { type: 'number', description: 'Record priority (MX/SRV)' }, + comment: { type: 'string', description: 'Record comment' }, + search: { type: 'string', description: 'Free-text search across record properties' }, + tag: { type: 'string', description: 'Filter by an exact tag name' }, + tag_match: { type: 'string', description: 'Tag filter match logic (any, all)' }, + commentFilter: { type: 'string', description: 'Filter records by comment content' }, + settingId: { type: 'string', description: 'Zone setting ID' }, + value: { type: 'string', description: 'Setting value' }, + since: { type: 'string', description: 'Start date for analytics' }, + until: { type: 'string', description: 'End date for analytics' }, + metrics: { type: 'string', description: 'Comma-separated metrics to retrieve' }, + dimensions: { type: 'string', description: 'Comma-separated dimensions to group by' }, + filters: { type: 'string', description: 'Filters to apply (e.g., queryType==A)' }, + sort: { type: 'string', description: 'Sort order for results' }, + limit: { type: 'number', description: 'Maximum number of results' }, + status: { type: 'string', description: 'Status filter when listing zones' }, + page: { type: 'number', description: 'Page number for pagination' }, + per_page: { type: 'number', description: 'Number of results per page' }, + deploy: { + type: 'string', + description: 'Filter certificates by deployment environment (staging, production)', + }, + purge_everything: { type: 'boolean', description: 'Purge all cached content' }, + files: { type: 'string', description: 'Comma-separated URLs to purge' }, + tags: { type: 'string', description: 'Comma-separated DNS record tags' }, + hosts: { type: 'string', description: 'Comma-separated hostnames to purge (Enterprise only)' }, + prefixes: { + type: 'string', + description: 'Comma-separated URL prefixes to purge (Enterprise only)', + }, + rulesetId: { type: 'string', description: 'Ruleset ID' }, + ruleId: { type: 'string', description: 'Rule ID within a ruleset' }, + phase: { type: 'string', description: 'Ruleset phase' }, + rulesetName: { type: 'string', description: 'Name for a newly created ruleset' }, + kind: { type: 'string', description: 'Ruleset kind for ruleset creation' }, + rules: { type: 'string', description: 'JSON array of rules to seed a new ruleset with' }, + action: { type: 'string', description: 'Action a rule performs' }, + expression: { type: 'string', description: 'Cloudflare filter expression' }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { type: 'string', description: 'Rule reference tag' }, + actionParameters: { type: 'string', description: 'JSON action parameters for a rule' }, + ratelimit: { + type: 'string', + description: 'JSON rate limiting configuration to preserve when replacing a rule', + }, + logging: { + type: 'string', + description: 'JSON logging configuration to preserve when replacing a rule', + }, + position: { type: 'string', description: 'JSON position object for a new rule' }, + characteristics: { + type: 'string', + description: 'Comma-separated rate limiting counting characteristics', + }, + period: { type: 'number', description: 'Rate limiting counting period in seconds' }, + requestsPerPeriod: { + type: 'number', + description: 'Requests allowed within the rate limiting period', + }, + mitigationTimeout: { + type: 'number', + description: 'Seconds the rate limiting action stays applied', + }, + counting_expression: { + type: 'string', + description: 'Expression selecting which requests are counted', + }, + requestsToOrigin: { + type: 'boolean', + description: 'Whether only requests reaching the origin are counted', + }, + appId: { type: 'string', description: 'Access application ID' }, + policyId: { type: 'string', description: 'Access policy ID' }, + serviceTokenId: { type: 'string', description: 'Access service token ID' }, + domain: { type: 'string', description: 'Hostname and path secured by Access' }, + aud: { type: 'string', description: 'Access application audience tag' }, + exact: { type: 'boolean', description: 'Whether Access list filters must match exactly' }, + sessionDuration: { type: 'string', description: 'Access session duration' }, + allowedIdps: { type: 'string', description: 'Comma-separated identity provider IDs' }, + appLauncherVisible: { + type: 'boolean', + description: 'Whether the application appears in the App Launcher', + }, + autoRedirectToIdentity: { + type: 'boolean', + description: 'Whether users skip the identity provider picker', + }, + customDenyMessage: { type: 'string', description: 'Message shown when access is denied' }, + customDenyUrl: { type: 'string', description: 'URL denied users are redirected to' }, + logoUrl: { type: 'string', description: 'Application logo URL' }, + policies: { type: 'string', description: 'JSON array of policies to attach' }, + saasApp: { + type: 'string', + description: 'JSON SaaS configuration for a saas-typed Access application', + }, + targetCriteria: { + type: 'string', + description: 'JSON target criteria for an infrastructure- or rdp-typed Access application', + }, + decision: { type: 'string', description: 'Access policy decision' }, + include: { type: 'string', description: 'JSON array of Access rules evaluated with OR logic' }, + exclude: { type: 'string', description: 'JSON array of Access rules evaluated with NOT logic' }, + require: { type: 'string', description: 'JSON array of Access rules evaluated with AND logic' }, + precedence: { type: 'number', description: 'Access policy evaluation order' }, + approvalRequired: { + type: 'boolean', + description: 'Whether an approver must grant each access request', + }, + isolationRequired: { + type: 'boolean', + description: 'Whether sessions must run in a remote isolated browser', + }, + purposeJustificationRequired: { + type: 'boolean', + description: 'Whether users must state a reason for access', + }, + purposeJustificationPrompt: { + type: 'string', + description: 'Prompt shown when a justification is required', + }, + duration: { type: 'string', description: 'Service token lifetime' }, + bucketName: { type: 'string', description: 'R2 bucket name' }, + locationHint: { type: 'string', description: 'R2 bucket location hint' }, + storageClass: { type: 'string', description: 'R2 default storage class' }, + jurisdiction: { type: 'string', description: 'R2 data-residency jurisdiction' }, + name_contains: { type: 'string', description: 'Filter R2 buckets by name substring' }, + start_after: { type: 'string', description: 'R2 bucket name to start listing after' }, + cursor: { type: 'string', description: 'Pagination cursor' }, + scriptName: { type: 'string', description: 'Workers script name' }, + tunnelId: { type: 'string', description: 'Cloudflare Tunnel ID' }, + uuid: { type: 'string', description: 'Filter tunnels by UUID' }, + is_deleted: { type: 'boolean', description: 'Whether to return deleted tunnels' }, + include_prefix: { type: 'string', description: 'Only include tunnels with this name prefix' }, + exclude_prefix: { type: 'string', description: 'Exclude tunnels with this name prefix' }, + existed_at: { type: 'string', description: 'Return tunnels that existed at this timestamp' }, + was_active_at: { + type: 'string', + description: 'Return tunnels that were active at this timestamp', + }, + was_inactive_at: { + type: 'string', + description: 'Return tunnels that were inactive at this timestamp', + }, + }, + outputs: { + zones: { type: 'json', description: 'List of zones/domains' }, + records: { type: 'json', description: 'List of DNS records' }, + certificates: { type: 'json', description: 'List of SSL/TLS certificate packs' }, + settings: { type: 'json', description: 'List of zone settings' }, + totals: { type: 'json', description: 'Aggregate DNS analytics totals' }, + min: { type: 'json', description: 'Minimum values across the DNS analytics period' }, + max: { type: 'json', description: 'Maximum values across the DNS analytics period' }, + query: { type: 'json', description: 'Echo of the DNS analytics query parameters sent' }, + validation_errors: { type: 'json', description: 'Validation issues for certificate packs' }, + data: { type: 'json', description: 'Raw analytics data rows from the DNS analytics report' }, + data_lag: { + type: 'number', + description: 'Processing lag in seconds before analytics data becomes available', + }, + rows: { type: 'number', description: 'Total number of rows in the DNS analytics result set' }, + id: { type: 'string', description: 'Resource ID' }, + zone_id: { type: 'string', description: 'Zone ID the record belongs to' }, + zone_name: { type: 'string', description: 'Zone domain name' }, + name: { type: 'string', description: 'Resource name' }, + status: { type: 'string', description: 'Resource status' }, + paused: { type: 'boolean', description: 'Whether the zone is paused' }, + type: { type: 'string', description: 'Zone or record type' }, + name_servers: { type: 'json', description: 'Assigned Cloudflare name servers' }, + original_name_servers: { type: 'json', description: 'Original registrar name servers' }, + plan: { + type: 'json', + description: + 'Zone plan information (id, name, price, currency, frequency, is_subscribed, legacy_id)', + }, + account: { type: 'json', description: 'Account the zone belongs to (id, name)' }, + owner: { type: 'json', description: 'Zone owner information (id, name, type)' }, + activated_on: { type: 'string', description: 'ISO 8601 date when the zone was activated' }, + development_mode: { + type: 'number', + description: 'Seconds remaining in development mode (0 = off)', + }, + meta: { + type: 'json', + description: 'Resource metadata (zone: cdn_only, dns_only, etc.; DNS record: source)', + }, + vanity_name_servers: { type: 'json', description: 'Custom vanity name servers' }, + permissions: { type: 'json', description: 'User permissions for the zone' }, + content: { type: 'string', description: 'DNS record value (e.g., IP address)' }, + proxiable: { type: 'boolean', description: 'Whether the record can be proxied' }, proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, ttl: { type: 'number', description: 'TTL in seconds (1 = automatic)' }, locked: { type: 'boolean', description: 'Whether the record is locked' }, @@ -1129,9 +2915,131 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, editable: { type: 'boolean', description: 'Whether the setting can be modified' }, time_remaining: { type: 'number', description: 'Seconds until setting can be modified again' }, total_count: { type: 'number', description: 'Total count of results' }, + rulesets: { type: 'json', description: 'Rulesets defined on the zone' }, + rules: { type: 'json', description: 'Rules contained in a ruleset, in evaluation order' }, + kind: { type: 'string', description: 'Ruleset kind (managed, custom, root, or zone)' }, + description: { type: 'string', description: 'Ruleset, rule, or resource description' }, + phase: { type: 'string', description: 'Phase the ruleset runs in' }, + version: { type: 'string', description: 'Ruleset or rule version' }, + last_updated: { type: 'string', description: 'RFC 3339 timestamp of the last change' }, + ruleset_id: { type: 'string', description: 'Entry point ruleset ID' }, + deployments: { + type: 'json', + description: 'Managed rulesets deployed on the zone and their overrides', + }, + applications: { type: 'json', description: 'Access applications in the account' }, + policies: { type: 'json', description: 'Access policies' }, + groups: { type: 'json', description: 'Access groups in the account' }, + identity_providers: { type: 'json', description: 'Access identity providers in the account' }, + service_tokens: { type: 'json', description: 'Access service tokens in the account' }, + domain: { type: 'string', description: 'Hostname and path secured by Access' }, + aud: { type: 'string', description: 'Access application audience tag' }, + session_duration: { type: 'string', description: 'How long an Access session stays valid' }, + allowed_idps: { type: 'json', description: 'Identity provider IDs allowed on the application' }, + app_launcher_visible: { + type: 'boolean', + description: 'Whether the application appears in the App Launcher', + }, + auto_redirect_to_identity: { + type: 'boolean', + description: 'Whether users skip the identity provider picker', + }, + custom_deny_message: { type: 'string', description: 'Message shown when access is denied' }, + custom_deny_url: { type: 'string', description: 'URL denied users are redirected to' }, + logo_url: { type: 'string', description: 'Application logo URL' }, + self_hosted_domains: { + type: 'json', + description: 'Additional hostnames and paths secured by the application', + }, + destinations: { type: 'json', description: 'Destinations secured by the application' }, + decision: { type: 'string', description: 'Access policy decision' }, + precedence: { type: 'number', description: 'Access policy evaluation order' }, + include: { type: 'json', description: 'Access rules evaluated with OR logic' }, + exclude: { type: 'json', description: 'Access rules evaluated with NOT logic' }, + require: { type: 'json', description: 'Access rules evaluated with AND logic' }, + approval_required: { + type: 'boolean', + description: 'Whether an approver must grant each access request', + }, + isolation_required: { + type: 'boolean', + description: 'Whether sessions must run in a remote isolated browser', + }, + purpose_justification_required: { + type: 'boolean', + description: 'Whether users must state a reason for access', + }, + purpose_justification_prompt: { + type: 'string', + description: 'Prompt shown when a justification is required', + }, + created_at: { type: 'string', description: 'Creation timestamp' }, + updated_at: { type: 'string', description: 'Last update timestamp' }, + client_id: { type: 'string', description: 'Service token client ID' }, + client_secret: { + type: 'string', + description: 'Service token client secret, returned only once at creation', + }, + expires_at: { type: 'string', description: 'Service token expiry timestamp' }, + last_seen_at: { type: 'string', description: 'When the service token was last used' }, + duration: { type: 'string', description: 'Service token lifetime' }, + enabled: { type: 'boolean', description: 'Whether the resource is enabled' }, + buckets: { type: 'json', description: 'R2 buckets in the account' }, + creation_date: { type: 'string', description: 'R2 bucket creation timestamp' }, + location: { type: 'string', description: 'R2 bucket location' }, + storage_class: { type: 'string', description: 'R2 bucket default storage class' }, + jurisdiction: { type: 'string', description: 'R2 data-residency jurisdiction' }, + cursor: { type: 'string', description: 'Pagination cursor for the next page' }, + scripts: { type: 'json', description: 'Workers scripts in the account' }, + routes: { type: 'json', description: 'Workers routes on the zone' }, + bindings: { type: 'json', description: 'Resource bindings available to a Workers script' }, + compatibility_date: { type: 'string', description: 'Workers runtime compatibility date' }, + compatibility_flags: { type: 'json', description: 'Workers runtime compatibility flags' }, + limits: { type: 'json', description: 'Workers execution limits' }, + logpush: { type: 'boolean', description: 'Whether Workers Logpush is enabled' }, + migrations: { type: 'json', description: 'Durable Object migrations' }, + observability: { type: 'json', description: 'Workers observability configuration' }, + placement: { type: 'json', description: 'Workers smart placement configuration' }, + tail_consumers: { type: 'json', description: 'Workers consuming tail events' }, + usage_model: { type: 'string', description: 'Workers billing usage model' }, + tunnels: { type: 'json', description: 'Cloudflare Tunnels in the account' }, + tunnel_id: { type: 'string', description: 'Tunnel the configuration belongs to' }, + account_id: { type: 'string', description: 'Account the tunnel belongs to' }, + account_tag: { type: 'string', description: 'Account tag the tunnel belongs to' }, + config: { type: 'json', description: 'Tunnel ingress and origin request configuration' }, + config_src: { type: 'string', description: 'Where the tunnel configuration is managed' }, + source: { type: 'string', description: 'Where the tunnel configuration is managed' }, + tun_type: { type: 'string', description: 'Tunnel type' }, + remote_config: { type: 'boolean', description: 'Whether the tunnel is remotely managed' }, + deleted_at: { type: 'string', description: 'Tunnel deletion timestamp' }, + conns_active_at: { + type: 'string', + description: 'When the tunnel last had active connections', + }, + conns_inactive_at: { + type: 'string', + description: 'When the tunnel last lost all connections', + }, + connections: { type: 'json', description: 'Active connector connections for the tunnel' }, }, } +/** + * Tool param names an alias may safely read a stored value back from. + * + * A value sitting under the bare param name is a workflow saved before that + * control was renamed — unless a control still claims the name and could have + * put the value there itself. Two claims disqualify a name: + * + * - a `mode: 'advanced'` control, because `shouldSerializeSubBlock` serializes + * one on stored value alone, before its `condition` runs, so the value may be + * another operation's hidden field bleeding across; + * - a control with a seeded default, because block state is seeded by subBlock + * id whatever the selected operation, so the value may be a default nobody + * chose (`decision` reads back as `allow` from the create-policy control). + * + * Excluding both keeps the legacy read from re-opening what the aliases closed. + */ export const CloudflareBlockMeta = { tags: ['cloud', 'monitoring'], url: 'https://www.cloudflare.com', @@ -1203,6 +3111,25 @@ export const CloudflareBlockMeta = { category: 'engineering', tags: ['devops', 'enterprise', 'monitoring'], }, + { + icon: CloudflareIcon, + title: 'Cloudflare WAF and rate limit rollout', + prompt: + 'Build a workflow that reads a table of WAF rules — expression, action, and rate limit — validates each row against the zone it targets, deploys it into the matching Cloudflare ruleset phase, and posts a Slack summary of every rule created, changed, or skipped.', + modules: ['tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'security', 'infrastructure'], + alsoIntegrations: ['slack'], + }, + { + icon: CloudflareIcon, + title: 'Cloudflare Zero Trust access review', + prompt: + 'Create a scheduled monthly workflow that lists every Cloudflare Access application and its policies, flags applications with no policy, overly broad email-domain rules, or bypass decisions, logs the findings to a table, and emails an access review to security leadership.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['security', 'enterprise', 'monitoring'], + }, ], skills: [ { @@ -1240,5 +3167,26 @@ export const CloudflareBlockMeta = { content: '# Set Up Email Authentication Records\n\nEmail providers (Google Workspace, Microsoft 365, transactional senders) require SPF, DKIM, and DMARC TXT records to authenticate mail and avoid it being marked as spam.\n\n## Steps\n1. Resolve the zone ID for the sending domain.\n2. List existing TXT records to check for conflicting or duplicate SPF/DMARC entries.\n3. Create or update the SPF TXT record (`v=spf1 ...`), the DKIM selector TXT record, and the DMARC TXT record (`_dmarc` name, `v=DMARC1; ...` policy) with the values the mail provider supplies.\n4. Confirm each record was created with the correct name, type, and content.\n\n## Output\nA confirmation of the SPF, DKIM, and DMARC records now in place, with their record IDs and TTLs.', }, + { + name: 'deploy-waf-custom-rule', + description: + 'Add a WAF custom rule to a Cloudflare zone through the Rulesets engine, blocking or challenging traffic that matches a filter expression.', + content: + '# Deploy a WAF Custom Rule\n\nWAF custom rules live in the `http_request_firewall_custom` phase of the Rulesets engine, so the ruleset ID has to be resolved before a rule can be added.\n\n## Steps\n1. Resolve the zone ID for the target domain.\n2. Read the `http_request_firewall_custom` phase entry point ruleset for that zone to get its ruleset ID and current rules.\n3. Write the filter expression for the traffic to act on (e.g. `(ip.src.country in {"GB" "FR"})` or `(http.request.uri.path matches "^/admin")`).\n4. Create the rule in that ruleset with the chosen action — `block`, `managed_challenge`, `js_challenge`, `challenge`, `skip`, or `log`. Start with `log` to observe impact before enforcing.\n5. Read the ruleset back and confirm the new rule appears in the intended evaluation order.\n\n## Output\nThe ruleset ID, the new rule ID, its expression and action, and its position in the evaluation order.\n\n## Cautions\nRules take effect on live traffic immediately. A broad expression with `block` can lock out legitimate users, so verify the expression in `log` mode first.', + }, + { + name: 'rate-limit-an-api-endpoint', + description: + 'Protect an API path from abuse with a Cloudflare rate limiting rule using the current Rulesets-based rate limiting API.', + content: + '# Rate Limit an API Endpoint\n\nRate limiting rules are rules in the `http_ratelimit` phase entry point ruleset. The legacy `rate_limits` endpoint is no longer the way to do this.\n\n## Steps\n1. Resolve the zone ID for the domain serving the API.\n2. List the existing rate limiting rules to get the `http_ratelimit` entry point ruleset ID and see what is already in place.\n3. Decide the counting characteristics. `cf.colo.id` is mandatory, plus exactly one of `ip.src` (per IP) or `cf.unique_visitor_id` (per visitor); add `http.request.headers[""]` to count per API key.\n4. Pick a counting period (10, 60, 120, 300, 600, or 3600 seconds) and the request allowance for that period.\n5. Create the rule with the matching expression (e.g. `(http.request.uri.path matches "^/api/")`), the counting configuration, and the mitigation action.\n6. Read the rules back and confirm the new rule and its limit.\n\n## Output\nThe ruleset ID, the new rule ID, the expression, and the effective limit (requests per period, characteristics, and mitigation timeout).\n\n## Cautions\nThe rule applies to live traffic as soon as it is created. Size the allowance against real traffic before choosing `block` over `log` or `managed_challenge`.', + }, + { + name: 'review-zero-trust-access', + description: + 'Audit Cloudflare Access applications and their policies to find unprotected apps, over-broad rules, and bypass decisions.', + content: + '# Review Zero Trust Access\n\nAccess applications and policies are account-scoped. An application with no policy denies everyone; a broad `allow` policy lets in more than intended.\n\n## Steps\n1. List the Access applications in the account.\n2. For each application, list its policies in precedence order.\n3. List the configured identity providers so `allowed_idps` values can be read as names rather than IDs.\n4. Flag applications with no policies, `allow` policies whose include rules are a whole email domain or `everyone`, any `bypass` decision, and applications with no `allowed_idps` restriction.\n5. Note session durations that are longer than the organization allows.\n\n## Output\nA per-application summary of domain, type, identity providers, policy decisions and include rules, and the flagged findings ranked by risk.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/codepipeline.ts b/apps/sim/blocks/blocks/codepipeline.ts index bb31bfe5eab..7611c690036 100644 --- a/apps/sim/blocks/blocks/codepipeline.ts +++ b/apps/sim/blocks/blocks/codepipeline.ts @@ -289,6 +289,7 @@ export const CodePipelineBlock: BlockConfig< id: 'approvalToken', title: 'Approval Token', type: 'short-input', + password: true, placeholder: 'Token from Get Pipeline State', condition: { field: 'operation', value: 'put_approval_result' }, required: { field: 'operation', value: 'put_approval_result' }, diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 5b1fbd3e7cc..5f821fe2db5 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -21,13 +21,14 @@ const CREDENTIAL_GROUP_CANONICAL_GROUP = { advancedIds: ['manualCredentialGroup'], } as const satisfies CanonicalGroup -async function fetchCachedCredentialGroups() { +async function fetchCachedCredentialGroups(signal?: AbortSignal) { const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId if (!workspaceId) return [] return getQueryClient().fetchQuery({ queryKey: credentialGroupKeys.list(workspaceId), - queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), + queryFn: ({ signal: querySignal }) => + fetchCredentialGroupList(workspaceId, signal ?? querySignal), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -102,7 +103,7 @@ export const CredentialGroupBlock: BlockConfig = { - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. `, docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group', - bgColor: '#7C3AED', + bgColor: '#8B5CF6', icon: GridOffset, canvasPresentation: { defaultTitle: 'Credential Groups', @@ -169,8 +170,8 @@ export const CredentialGroupBlock: BlockConfig = { .map((group) => ({ label: group.name, id: group.id })) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (_blockId: string, optionId: string) => { - const groups = await fetchCachedCredentialGroups() + fetchOptionById: async (_blockId: string, optionId: string, signal?: AbortSignal) => { + const groups = await fetchCachedCredentialGroups(signal) const group = groups.find((candidate) => candidate.id === optionId) return group ? { label: group.name, id: group.id } : null }, @@ -219,10 +220,10 @@ export const CredentialGroupBlock: BlockConfig = { }) .sort((a, b) => a.label.localeCompare(b.label)) }, - fetchOptionById: async (blockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string, signal?: AbortSignal) => { const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) if (!credentialGroupId) return null - const groups = await fetchCachedCredentialGroups() + const groups = await fetchCachedCredentialGroups(signal) const group = groups.find((candidate) => candidate.id === credentialGroupId) const option = group?.options.find( (candidate) => diff --git a/apps/sim/blocks/blocks/crowdstrike.test.ts b/apps/sim/blocks/blocks/crowdstrike.test.ts new file mode 100644 index 00000000000..e75ba076a29 --- /dev/null +++ b/apps/sim/blocks/blocks/crowdstrike.test.ts @@ -0,0 +1,217 @@ +/** + * @vitest-environment node + */ +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { RTR_READ_ONLY_BASE_COMMANDS } from '@/lib/api/contracts/tools/crowdstrike' +import { CrowdStrikeBlock } from '@/blocks/blocks/crowdstrike' + +/** + * `buildToolDescriptionMap` in `scripts/generate-docs.ts` searches only the 600 + * characters that follow a tool's `id:` for its `name:` and `description:`. A + * description whose closing quote falls outside that window does not fail the + * build — it silently publishes as an empty string in `integrations.json` and in + * the generated MDX. + */ +const DOCS_GENERATOR_ID_WINDOW = 600 + +/** Leave headroom so a small wording edit cannot silently cross the window. */ +const DESCRIPTION_SPAN_BUDGET = DOCS_GENERATOR_ID_WINDOW - 40 + +const mapParams = CrowdStrikeBlock.tools.config?.params +if (!mapParams) { + throw new Error('CrowdStrike block must define tools.config.params') +} + +const credentials = { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', +} + +/** + * The executor merges the mapped params over the raw block inputs + * (`{ ...inputs, ...transformedParams }`), so a key the mapper omits keeps its raw + * subBlock value. Every assertion here runs against the merged result, because a + * mapper-only assertion passes even when the raw value survives onto the wire. + */ +function merge(inputs: Record) { + return { ...inputs, ...mapParams(inputs) } +} + +describe('CrowdStrike block params', () => { + it('drops untouched optional subBlocks instead of forwarding their stored null', () => { + const merged = merge({ + ...credentials, + operation: 'crowdstrike_query_alerts', + filter: null, + q: null, + limit: null, + offset: null, + sort: null, + includeHidden: null, + }) + + expect(merged.filter).toBeUndefined() + expect(merged.q).toBeUndefined() + expect(merged.limit).toBeUndefined() + expect(merged.offset).toBeUndefined() + expect(merged.sort).toBeUndefined() + expect(merged.includeHidden).toBeUndefined() + }) + + it('drops a blank alert update field rather than sending an empty action value', () => { + const merged = merge({ + ...credentials, + operation: 'crowdstrike_update_alerts', + compositeIds: '["cid:aid:alert"]', + updateStatus: 'closed', + assignToUuid: null, + appendComment: '', + addTag: null, + }) + + expect(merged.updateStatus).toBe('closed') + expect(merged.assignToUuid).toBeUndefined() + expect(merged.appendComment).toBeUndefined() + expect(merged.addTag).toBeUndefined() + }) + + it('clears an advanced value left over from another operation', () => { + const merged = merge({ + ...credentials, + operation: 'crowdstrike_init_rtr_session', + deviceId: 'aid-1', + includeHidden: 'true', + q: 'stale free-text search', + after: 'stale-cursor', + updateStatus: 'closed', + }) + + expect(merged.deviceId).toBe('aid-1') + expect(merged.includeHidden).toBeUndefined() + expect(merged.q).toBeUndefined() + expect(merged.after).toBeUndefined() + expect(merged.updateStatus).toBeUndefined() + }) + + it('sends the free-text search only to the operations that accept it', () => { + expect( + merge({ ...credentials, operation: 'crowdstrike_query_alerts', q: 'ransomware' }).q + ).toBe('ransomware') + expect( + merge({ ...credentials, operation: 'crowdstrike_query_host_groups', q: 'ransomware' }).q + ).toBeUndefined() + }) + + it('sends the after cursor only to the cursor-paginated collections', () => { + expect( + merge({ ...credentials, operation: 'crowdstrike_query_indicators', after: 'cursor-1' }).after + ).toBe('cursor-1') + expect( + merge({ ...credentials, operation: 'crowdstrike_query_alerts', after: 'cursor-1' }).after + ).toBeUndefined() + }) + + it('never sends an offset to Spotlight, which paginates by cursor only', () => { + const merged = merge({ + ...credentials, + operation: 'crowdstrike_query_vulnerabilities', + filter: "status:'open'", + offset: '100', + }) + + expect(merged.filter).toBe("status:'open'") + expect(merged.offset).toBeUndefined() + }) + + it('keeps the alert filter out of a destructive indicator delete', () => { + const merged = merge({ + ...credentials, + operation: 'crowdstrike_delete_indicators', + indicatorIds: '["ioc-1"]', + filter: "status:'new'", + deleteFilter: null, + }) + + expect(merged.indicatorIds).toEqual(['ioc-1']) + expect(merged.filter).toBeUndefined() + }) + + it('forwards the dedicated delete filter', () => { + const merged = merge({ + ...credentials, + operation: 'crowdstrike_delete_indicators', + deleteFilter: "type:'sha256'", + }) + + expect(merged.filter).toBe("type:'sha256'") + }) + + it('rejects an IOC limit above the documented maximum of 500', () => { + expect(() => + merge({ ...credentials, operation: 'crowdstrike_query_indicators', limit: '2000' }) + ).toThrow(/500/) + }) + + /** + * Asserted against the contract constant rather than a second hand-maintained + * literal: the route validates `base_command` with `RTR_READ_ONLY_BASE_COMMANDS`, + * so a dropdown that drifts from it either hides a command the API accepts or + * offers one the API rejects. Duplicating the list here would just move the + * drift into the test. + */ + it('offers exactly the read-tier RTR base command families the contract accepts', () => { + const baseCommand = CrowdStrikeBlock.subBlocks.find((subBlock) => subBlock.id === 'baseCommand') + const ids = (baseCommand?.options as { id: string }[] | undefined)?.map((option) => option.id) + + expect(ids).toEqual([...RTR_READ_ONLY_BASE_COMMANDS]) + }) + + it('offers no write-tier RTR base command under the read-scoped tool', () => { + const baseCommand = CrowdStrikeBlock.subBlocks.find((subBlock) => subBlock.id === 'baseCommand') + const ids = (baseCommand?.options as { id: string }[] | undefined)?.map((option) => option.id) + + for (const writeTier of ['eventlog backup', 'eventlog export', 'put', 'get', 'runscript']) { + expect(ids).not.toContain(writeTier) + } + }) + + it('exposes every CrowdStrike commercial and GovCloud region', () => { + const cloud = CrowdStrikeBlock.subBlocks.find((subBlock) => subBlock.id === 'cloud') + const ids = (cloud?.options as { id: string }[] | undefined)?.map((option) => option.id) + + expect(ids).toEqual(['us-1', 'us-2', 'us-3', 'eu-1', 'us-gov-1', 'us-gov-2']) + }) + + it('does not preselect the network-isolating host action', () => { + const hostAction = CrowdStrikeBlock.subBlocks.find( + (subBlock) => subBlock.id === 'hostActionName' + ) + const ids = (hostAction?.options as { id: string }[] | undefined)?.map((option) => option.id) + + expect(hostAction?.value).toBeUndefined() + expect(ids).toContain('detection_suppress') + expect(ids).toContain('detection_unsuppress') + }) + + it('keeps every tool description inside the docs generator id-search window', () => { + const toolsDir = path.join(__dirname, '../../tools/crowdstrike') + const offenders: string[] = [] + + for (const file of fs.readdirSync(toolsDir)) { + if (file === 'index.ts' || file === 'types.ts') continue + const source = fs.readFileSync(path.join(toolsDir, file), 'utf-8') + const idIndex = source.search(/\bid\s*:\s*'crowdstrike_/) + const descriptionEnd = source.indexOf("',", source.indexOf('description:')) + if (idIndex < 0 || descriptionEnd < 0) continue + const span = descriptionEnd + 2 - idIndex + if (span > DESCRIPTION_SPAN_BUDGET) { + offenders.push(`${file} (${span} chars)`) + } + } + + expect(offenders).toEqual([]) + }) +}) diff --git a/apps/sim/blocks/blocks/crowdstrike.ts b/apps/sim/blocks/blocks/crowdstrike.ts index 80bdbc559fa..6ac65d5cb14 100644 --- a/apps/sim/blocks/blocks/crowdstrike.ts +++ b/apps/sim/blocks/blocks/crowdstrike.ts @@ -1,15 +1,99 @@ import { CrowdStrikeIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { parseOptionalJsonInput, parseOptionalNumberInput } from '@/blocks/utils' +import { + parseOptionalBooleanInput, + parseOptionalJsonInput, + parseOptionalNumberInput, +} from '@/blocks/utils' import type { CrowdStrikeResponse } from '@/tools/crowdstrike/types' +/** Documented maximum `limit` for each CrowdStrike query collection. */ +const QUERY_LIMITS: Record = { + crowdstrike_query_sensors: { min: 1, max: 200 }, + crowdstrike_query_alerts: { min: 1, max: 10000 }, + crowdstrike_query_host_groups: { min: 1, max: 5000 }, + crowdstrike_query_indicators: { min: 1, max: 500 }, + crowdstrike_query_vulnerabilities: { min: 1, max: 400 }, + crowdstrike_query_cases: { min: 1, max: 10000 }, +} + +/** Spotlight paginates by cursor only, so it accepts no `offset`. */ +const OFFSET_QUERY_OPERATIONS = new Set([ + 'crowdstrike_query_sensors', + 'crowdstrike_query_alerts', + 'crowdstrike_query_host_groups', + 'crowdstrike_query_indicators', + 'crowdstrike_query_cases', +]) + +/** Only the IOC and Spotlight query endpoints accept an `after` cursor. */ +const CURSOR_QUERY_OPERATIONS = new Set([ + 'crowdstrike_query_indicators', + 'crowdstrike_query_vulnerabilities', +]) + +/** Only Alerts and Case Management accept the free-text `q` parameter. */ +const FREE_TEXT_QUERY_OPERATIONS = new Set(['crowdstrike_query_alerts', 'crowdstrike_query_cases']) + +/** + * Every optional request key the block can produce, pre-cleared to `undefined`. + * The executor merges the mapped params over the raw block inputs, so a key left + * out of the mapped result silently keeps its raw subBlock value. + */ +const CLEARED_OPTIONAL_PARAMS: Record = Object.freeze({ + actionName: undefined, + actionParameters: undefined, + addTag: undefined, + after: undefined, + aggregateQuery: undefined, + appendComment: undefined, + assignToName: undefined, + assignToUserId: undefined, + assignToUuid: undefined, + baseCommand: undefined, + caseIds: undefined, + cloudRequestId: undefined, + commandString: undefined, + comment: undefined, + compositeIds: undefined, + deleteFilter: undefined, + deviceId: undefined, + deviceIds: undefined, + filter: undefined, + hostActionName: undefined, + hostGroupActionName: undefined, + hostGroupId: undefined, + hostGroupIds: undefined, + ids: undefined, + ignoreWarnings: undefined, + includeHidden: undefined, + indicatorIds: undefined, + indicators: undefined, + limit: undefined, + offset: undefined, + origin: undefined, + q: undefined, + queueOffline: undefined, + removeTag: undefined, + removeTagsByPrefix: undefined, + retrodetects: undefined, + sequenceId: undefined, + sessionId: undefined, + showInUi: undefined, + sort: undefined, + unassign: undefined, + updateStatus: undefined, + vulnerabilityIds: undefined, +}) + export const CrowdStrikeBlock: BlockConfig = { type: 'crowdstrike', name: 'CrowdStrike', - description: 'Query CrowdStrike Identity Protection sensors and documented aggregates', + description: + 'Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities', longDescription: - 'Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries.', + 'Integrate CrowdStrike Falcon into workflows to triage alerts, contain hosts, manage host groups and custom indicators of compromise, review Spotlight vulnerabilities, run read-only Real Time Response commands, read Case Management cases, and query Identity Protection sensors.', docsLink: 'https://docs.sim.ai/integrations/crowdstrike', category: 'tools', integrationType: IntegrationType.Security, @@ -33,6 +117,95 @@ export const CrowdStrikeBlock: BlockConfig = { crowdstrike_get_sensor_aggregates: [ { text: 'Aggregate sensors with', field: 'aggregateQuery', core: true }, ], + crowdstrike_query_alerts: [ + 'Search Falcon alerts', + { text: ', where', field: 'filter' }, + { text: ', matching', field: 'q' }, + { text: ', sorted by', field: 'sort' }, + { text: ', up to', field: 'limit', after: 'results' }, + ], + crowdstrike_get_alert_details: [ + { text: 'Fetch alert details for', field: 'compositeIds', core: true }, + ], + crowdstrike_update_alerts: [ + { text: 'Update alerts', field: 'compositeIds', core: true }, + { text: ', setting status to', field: 'updateStatus' }, + { text: ', assigning to', field: 'assignToUuid' }, + { text: ', commenting', field: 'appendComment' }, + ], + crowdstrike_perform_host_action: [ + { text: 'Run', field: 'hostActionName', core: true }, + { text: 'on hosts', field: 'deviceIds', core: true }, + ], + crowdstrike_query_host_groups: [ + 'Search host groups', + { text: ', where', field: 'filter' }, + { text: ', sorted by', field: 'sort' }, + { text: ', up to', field: 'limit', after: 'results' }, + ], + crowdstrike_get_host_group_details: [ + { text: 'Fetch host group details for', field: 'hostGroupIds', core: true }, + ], + crowdstrike_perform_host_group_action: [ + { text: 'Run', field: 'hostGroupActionName', core: true }, + { text: 'on group', field: 'hostGroupId', core: true }, + { text: 'for hosts', field: 'deviceIds' }, + ], + crowdstrike_query_indicators: [ + 'Search custom indicators', + { text: ', where', field: 'filter' }, + { text: ', sorted by', field: 'sort' }, + { text: ', up to', field: 'limit', after: 'results' }, + ], + crowdstrike_get_indicator_details: [ + { text: 'Fetch indicator details for', field: 'indicatorIds', core: true }, + ], + crowdstrike_create_indicators: [ + { text: 'Create indicators', field: 'indicators', core: true }, + { text: ', noting', field: 'comment' }, + ], + crowdstrike_update_indicators: [ + { text: 'Update indicators', field: 'indicators', core: true }, + { text: ', noting', field: 'comment' }, + ], + crowdstrike_delete_indicators: [ + 'Delete custom indicators', + { text: 'with IDs', field: 'indicatorIds' }, + { text: ', matching', field: 'deleteFilter' }, + { text: ', noting', field: 'comment' }, + ], + crowdstrike_query_vulnerabilities: [ + { text: 'Search Spotlight vulnerabilities where', field: 'filter', core: true }, + { text: ', sorted by', field: 'sort' }, + { text: ', up to', field: 'limit', after: 'results' }, + ], + crowdstrike_get_vulnerability_details: [ + { text: 'Fetch vulnerability details for', field: 'vulnerabilityIds', core: true }, + ], + crowdstrike_init_rtr_session: [ + { text: 'Open a Real Time Response session on', field: 'deviceId', core: true }, + ], + crowdstrike_execute_rtr_command: [ + { text: 'Run', field: 'commandString', core: true }, + { text: 'in session', field: 'sessionId', core: true }, + ], + crowdstrike_get_rtr_command_status: [ + { text: 'Check command', field: 'cloudRequestId', core: true }, + { text: ', chunk', field: 'sequenceId' }, + ], + crowdstrike_delete_rtr_session: [ + { text: 'Close Real Time Response session', field: 'sessionId', core: true }, + ], + crowdstrike_query_cases: [ + 'Search cases', + { text: ', where', field: 'filter' }, + { text: ', matching', field: 'q' }, + { text: ', sorted by', field: 'sort' }, + { text: ', up to', field: 'limit', after: 'results' }, + ], + crowdstrike_get_case_details: [ + { text: 'Fetch case details for', field: 'caseIds', core: true }, + ], }, }, }, @@ -43,11 +216,31 @@ export const CrowdStrikeBlock: BlockConfig = { title: 'Operation', type: 'dropdown', options: [ + { label: 'Query Alerts', id: 'crowdstrike_query_alerts' }, + { label: 'Get Alert Details', id: 'crowdstrike_get_alert_details' }, + { label: 'Update Alerts', id: 'crowdstrike_update_alerts' }, + { label: 'Perform Host Action', id: 'crowdstrike_perform_host_action' }, + { label: 'Query Host Groups', id: 'crowdstrike_query_host_groups' }, + { label: 'Get Host Group Details', id: 'crowdstrike_get_host_group_details' }, + { label: 'Perform Host Group Action', id: 'crowdstrike_perform_host_group_action' }, + { label: 'Query Indicators', id: 'crowdstrike_query_indicators' }, + { label: 'Get Indicator Details', id: 'crowdstrike_get_indicator_details' }, + { label: 'Create Indicators', id: 'crowdstrike_create_indicators' }, + { label: 'Update Indicators', id: 'crowdstrike_update_indicators' }, + { label: 'Delete Indicators', id: 'crowdstrike_delete_indicators' }, + { label: 'Query Vulnerabilities', id: 'crowdstrike_query_vulnerabilities' }, + { label: 'Get Vulnerability Details', id: 'crowdstrike_get_vulnerability_details' }, + { label: 'Init RTR Session', id: 'crowdstrike_init_rtr_session' }, + { label: 'Execute RTR Command', id: 'crowdstrike_execute_rtr_command' }, + { label: 'Get RTR Command Status', id: 'crowdstrike_get_rtr_command_status' }, + { label: 'Delete RTR Session', id: 'crowdstrike_delete_rtr_session' }, + { label: 'Query Cases', id: 'crowdstrike_query_cases' }, + { label: 'Get Case Details', id: 'crowdstrike_get_case_details' }, { label: 'Query Sensors', id: 'crowdstrike_query_sensors' }, { label: 'Get Sensor Details', id: 'crowdstrike_get_sensor_details' }, { label: 'Get Sensor Aggregates', id: 'crowdstrike_get_sensor_aggregates' }, ], - value: () => 'crowdstrike_query_sensors', + value: () => 'crowdstrike_query_alerts', required: true, }, { @@ -72,6 +265,7 @@ export const CrowdStrikeBlock: BlockConfig = { options: [ { label: 'US-1', id: 'us-1' }, { label: 'US-2', id: 'us-2' }, + { label: 'US-3', id: 'us-3' }, { label: 'EU-1', id: 'eu-1' }, { label: 'US-GOV-1', id: 'us-gov-1' }, { label: 'US-GOV-2', id: 'us-gov-2' }, @@ -83,22 +277,54 @@ export const CrowdStrikeBlock: BlockConfig = { id: 'filter', title: 'Filter', type: 'short-input', - placeholder: 'hostname:"server-01" or status:"protected"', - condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + placeholder: 'status:"new"+severity:>70', + condition: { + field: 'operation', + value: [ + 'crowdstrike_query_sensors', + 'crowdstrike_query_alerts', + 'crowdstrike_query_host_groups', + 'crowdstrike_query_indicators', + 'crowdstrike_query_vulnerabilities', + 'crowdstrike_query_cases', + ], + }, + required: { field: 'operation', value: 'crowdstrike_query_vulnerabilities' }, wandConfig: { enabled: true, prompt: - 'Generate a CrowdStrike Identity Protection Falcon Query Language filter string for sensor search. Use exact field names, operators, and values only. Return ONLY the filter string - no explanations, no extra text.', + 'Generate a CrowdStrike Falcon Query Language (FQL) filter string for the selected CrowdStrike collection. Use exact field names, operators, and values only. Return ONLY the filter string - no explanations, no extra text.', placeholder: - 'Describe the sensors you want to search, for example "sensors with hostnames starting with web" or "sensors with protected status"...', + 'Describe what you want to match, for example "new alerts with severity above 70" or "open vulnerabilities with a CISA KEV CVE"...', + }, + }, + { + id: 'q', + title: 'Search', + type: 'short-input', + placeholder: 'Free-text metadata search', + condition: { + field: 'operation', + value: ['crowdstrike_query_alerts', 'crowdstrike_query_cases'], }, + mode: 'advanced', }, { id: 'limit', title: 'Limit', type: 'short-input', placeholder: '100', - condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + condition: { + field: 'operation', + value: [ + 'crowdstrike_query_sensors', + 'crowdstrike_query_alerts', + 'crowdstrike_query_host_groups', + 'crowdstrike_query_indicators', + 'crowdstrike_query_vulnerabilities', + 'crowdstrike_query_cases', + ], + }, mode: 'advanced', }, { @@ -106,17 +332,430 @@ export const CrowdStrikeBlock: BlockConfig = { title: 'Offset', type: 'short-input', placeholder: '0', - condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + condition: { + field: 'operation', + value: [ + 'crowdstrike_query_sensors', + 'crowdstrike_query_alerts', + 'crowdstrike_query_host_groups', + 'crowdstrike_query_indicators', + 'crowdstrike_query_cases', + ], + }, + mode: 'advanced', + }, + { + id: 'after', + title: 'After Cursor', + type: 'short-input', + placeholder: 'Cursor from the previous page', + condition: { + field: 'operation', + value: ['crowdstrike_query_indicators', 'crowdstrike_query_vulnerabilities'], + }, + mode: 'advanced', + }, + /** + * Falcon has two sort spellings. Alerts, IOC Management, Spotlight, and Cases + * document `field|direction`; Host Groups and Identity Protection sensors + * document `field.direction`. One placeholder cannot show both, so the field + * is declared twice under the same id with mutually exclusive conditions. + */ + { + id: 'sort', + title: 'Sort', + type: 'short-input', + placeholder: 'created_timestamp|desc', + condition: { + field: 'operation', + value: [ + 'crowdstrike_query_alerts', + 'crowdstrike_query_indicators', + 'crowdstrike_query_vulnerabilities', + 'crowdstrike_query_cases', + ], + }, mode: 'advanced', }, { id: 'sort', title: 'Sort', type: 'short-input', - placeholder: 'status.asc', - condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + placeholder: 'name.asc', + condition: { + field: 'operation', + value: ['crowdstrike_query_sensors', 'crowdstrike_query_host_groups'], + }, + mode: 'advanced', + }, + { + id: 'includeHidden', + title: 'Include Hidden Alerts', + type: 'switch', + condition: { + field: 'operation', + value: [ + 'crowdstrike_query_alerts', + 'crowdstrike_get_alert_details', + 'crowdstrike_update_alerts', + ], + }, + mode: 'advanced', + }, + { + id: 'compositeIds', + title: 'Composite Alert IDs', + type: 'code', + language: 'json', + placeholder: '["cid:aid:alert-id"]', + condition: { + field: 'operation', + value: ['crowdstrike_get_alert_details', 'crowdstrike_update_alerts'], + }, + required: { + field: 'operation', + value: ['crowdstrike_get_alert_details', 'crowdstrike_update_alerts'], + }, + }, + { + id: 'updateStatus', + title: 'Status', + type: 'dropdown', + options: [ + { label: 'New', id: 'new' }, + { label: 'In Progress', id: 'in_progress' }, + { label: 'Reopened', id: 'reopened' }, + { label: 'Closed', id: 'closed' }, + ], + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + }, + { + id: 'assignToUuid', + title: 'Assign To UUID', + type: 'short-input', + placeholder: '00000000-0000-0000-0000-000000000000', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + }, + { + id: 'assignToUserId', + title: 'Assign To User ID', + type: 'short-input', + placeholder: 'analyst@example.com', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'assignToName', + title: 'Assign To Name', + type: 'short-input', + placeholder: 'Jane Doe', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'unassign', + title: 'Unassign', + type: 'switch', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'appendComment', + title: 'Comment', + type: 'long-input', + placeholder: 'Triage note to append to the alert', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + }, + { + id: 'addTag', + title: 'Add Tag', + type: 'short-input', + placeholder: 'triaged', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'removeTag', + title: 'Remove Tag', + type: 'short-input', + placeholder: 'needs-review', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'removeTagsByPrefix', + title: 'Remove Tags By Prefix', + type: 'short-input', + placeholder: 'auto-', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'showInUi', + title: 'Show In Falcon Console', + type: 'switch', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'actionParameters', + title: 'Additional Action Parameters', + type: 'code', + language: 'json', + placeholder: '[{ "name": "action_name", "value": "action_value" }]', + condition: { field: 'operation', value: 'crowdstrike_update_alerts' }, + mode: 'advanced', + }, + { + id: 'hostActionName', + title: 'Host Action', + type: 'dropdown', + options: [ + { label: 'Contain (network isolate)', id: 'contain' }, + { label: 'Lift Containment', id: 'lift_containment' }, + { label: 'Hide Host', id: 'hide_host' }, + { label: 'Unhide Host', id: 'unhide_host' }, + { label: 'Suppress Detections', id: 'detection_suppress' }, + { label: 'Unsuppress Detections', id: 'detection_unsuppress' }, + ], + condition: { field: 'operation', value: 'crowdstrike_perform_host_action' }, + required: { field: 'operation', value: 'crowdstrike_perform_host_action' }, + }, + { + id: 'deviceIds', + title: 'Host Agent IDs', + type: 'code', + language: 'json', + placeholder: '["aid-1", "aid-2"]', + condition: { + field: 'operation', + value: ['crowdstrike_perform_host_action', 'crowdstrike_perform_host_group_action'], + }, + required: { + field: 'operation', + value: ['crowdstrike_perform_host_action', 'crowdstrike_perform_host_group_action'], + }, + }, + { + id: 'hostGroupIds', + title: 'Host Group IDs', + type: 'code', + language: 'json', + placeholder: '["host-group-id"]', + condition: { field: 'operation', value: 'crowdstrike_get_host_group_details' }, + required: { field: 'operation', value: 'crowdstrike_get_host_group_details' }, + }, + { + id: 'hostGroupActionName', + title: 'Host Group Action', + type: 'dropdown', + options: [ + { label: 'Add Hosts', id: 'add-hosts' }, + { label: 'Remove Hosts', id: 'remove-hosts' }, + ], + value: () => 'add-hosts', + condition: { field: 'operation', value: 'crowdstrike_perform_host_group_action' }, + required: { field: 'operation', value: 'crowdstrike_perform_host_group_action' }, + }, + { + id: 'hostGroupId', + title: 'Host Group ID', + type: 'short-input', + placeholder: 'Static host group ID', + condition: { field: 'operation', value: 'crowdstrike_perform_host_group_action' }, + required: { field: 'operation', value: 'crowdstrike_perform_host_group_action' }, + }, + { + id: 'indicatorIds', + title: 'Indicator IDs', + type: 'code', + language: 'json', + placeholder: '["ioc-id-1"]', + condition: { + field: 'operation', + value: ['crowdstrike_get_indicator_details', 'crowdstrike_delete_indicators'], + }, + required: { field: 'operation', value: 'crowdstrike_get_indicator_details' }, + }, + { + id: 'deleteFilter', + title: 'Delete Filter', + type: 'short-input', + placeholder: "type:'sha256'+created_on:<'2026-01-01'", + condition: { field: 'operation', value: 'crowdstrike_delete_indicators' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a CrowdStrike IOC Management Falcon Query Language (FQL) filter string that selects the custom indicators to delete. Use exact IOC field names, operators, and values only. Return ONLY the filter string - no explanations, no extra text.', + placeholder: + 'Describe which indicators to delete, for example "every sha256 indicator created before 2026"...', + }, + }, + { + id: 'indicators', + title: 'Indicators', + type: 'code', + language: 'json', + placeholder: + '[\n {\n "type": "sha256",\n "value": "",\n "action": "prevent",\n "severity": "high",\n "platforms": ["windows"],\n "applied_globally": true\n }\n]', + condition: { + field: 'operation', + value: ['crowdstrike_create_indicators', 'crowdstrike_update_indicators'], + }, + required: { + field: 'operation', + value: ['crowdstrike_create_indicators', 'crowdstrike_update_indicators'], + }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of CrowdStrike IOC Management indicator objects. Documented fields are type, value, action, severity, platforms (array), applied_globally (boolean), host_groups (array), description, source, tags (array), expiration (ISO 8601), mobile_action, and metadata ({ filename }). Updates must include id and cannot change type or value. Return ONLY valid JSON.', + placeholder: + 'Describe the indicators you want, for example "block this SHA256 on Windows hosts globally"...', + generationType: 'json-object', + }, + }, + { + id: 'comment', + title: 'Audit Comment', + type: 'short-input', + placeholder: 'Why this change was made', + condition: { + field: 'operation', + value: [ + 'crowdstrike_create_indicators', + 'crowdstrike_update_indicators', + 'crowdstrike_delete_indicators', + ], + }, + }, + { + id: 'retrodetects', + title: 'Generate Retroactive Detections', + type: 'switch', + condition: { + field: 'operation', + value: ['crowdstrike_create_indicators', 'crowdstrike_update_indicators'], + }, + mode: 'advanced', + }, + { + id: 'ignoreWarnings', + title: 'Ignore Warnings', + type: 'switch', + condition: { + field: 'operation', + value: ['crowdstrike_create_indicators', 'crowdstrike_update_indicators'], + }, + mode: 'advanced', + }, + { + id: 'vulnerabilityIds', + title: 'Vulnerability IDs', + type: 'code', + language: 'json', + placeholder: '["vulnerability-id"]', + condition: { field: 'operation', value: 'crowdstrike_get_vulnerability_details' }, + required: { field: 'operation', value: 'crowdstrike_get_vulnerability_details' }, + }, + { + id: 'deviceId', + title: 'Host Agent ID', + type: 'short-input', + placeholder: 'Agent ID (AID) to connect to', + condition: { field: 'operation', value: 'crowdstrike_init_rtr_session' }, + required: { field: 'operation', value: 'crowdstrike_init_rtr_session' }, + }, + { + id: 'queueOffline', + title: 'Queue If Host Offline', + type: 'switch', + condition: { field: 'operation', value: 'crowdstrike_init_rtr_session' }, + mode: 'advanced', + }, + { + id: 'origin', + title: 'Session Origin', + type: 'short-input', + placeholder: 'Origin label recorded by CrowdStrike', + condition: { field: 'operation', value: 'crowdstrike_init_rtr_session' }, + mode: 'advanced', + }, + { + id: 'sessionId', + title: 'RTR Session ID', + type: 'short-input', + placeholder: 'Session ID from Init RTR Session', + condition: { + field: 'operation', + value: ['crowdstrike_execute_rtr_command', 'crowdstrike_delete_rtr_session'], + }, + required: { + field: 'operation', + value: ['crowdstrike_execute_rtr_command', 'crowdstrike_delete_rtr_session'], + }, + }, + { + id: 'baseCommand', + title: 'Base Command', + type: 'dropdown', + options: [ + { label: 'cat', id: 'cat' }, + { label: 'cd', id: 'cd' }, + { label: 'clear', id: 'clear' }, + { label: 'csrutil (macOS)', id: 'csrutil' }, + { label: 'env', id: 'env' }, + { label: 'eventlog (Windows)', id: 'eventlog' }, + { label: 'filehash', id: 'filehash' }, + { label: 'getsid (Windows, macOS)', id: 'getsid' }, + { label: 'help', id: 'help' }, + { label: 'history', id: 'history' }, + { label: 'ifconfig (macOS, Linux)', id: 'ifconfig' }, + { label: 'ipconfig', id: 'ipconfig' }, + { label: 'ls', id: 'ls' }, + { label: 'mount', id: 'mount' }, + { label: 'netstat', id: 'netstat' }, + { label: 'ps', id: 'ps' }, + { label: 'reg (Windows, query only)', id: 'reg' }, + { label: 'users (Windows)', id: 'users' }, + ], + value: () => 'ls', + condition: { field: 'operation', value: 'crowdstrike_execute_rtr_command' }, + required: { field: 'operation', value: 'crowdstrike_execute_rtr_command' }, + }, + { + id: 'commandString', + title: 'Command', + type: 'short-input', + placeholder: 'ls C:\\Windows\\Temp', + condition: { field: 'operation', value: 'crowdstrike_execute_rtr_command' }, + required: { field: 'operation', value: 'crowdstrike_execute_rtr_command' }, + }, + { + id: 'cloudRequestId', + title: 'Cloud Request ID', + type: 'short-input', + placeholder: 'Cloud request ID from Execute RTR Command', + condition: { field: 'operation', value: 'crowdstrike_get_rtr_command_status' }, + required: { field: 'operation', value: 'crowdstrike_get_rtr_command_status' }, + }, + { + id: 'sequenceId', + title: 'Sequence ID', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'crowdstrike_get_rtr_command_status' }, mode: 'advanced', }, + { + id: 'caseIds', + title: 'Case IDs', + type: 'code', + language: 'json', + placeholder: '["case-id"]', + condition: { field: 'operation', value: 'crowdstrike_get_case_details' }, + required: { field: 'operation', value: 'crowdstrike_get_case_details' }, + }, { id: 'ids', title: 'Sensor IDs', @@ -148,46 +787,172 @@ export const CrowdStrikeBlock: BlockConfig = { tools: { access: [ + 'crowdstrike_create_indicators', + 'crowdstrike_delete_indicators', + 'crowdstrike_delete_rtr_session', + 'crowdstrike_execute_rtr_command', + 'crowdstrike_get_alert_details', + 'crowdstrike_get_case_details', + 'crowdstrike_get_host_group_details', + 'crowdstrike_get_indicator_details', + 'crowdstrike_get_rtr_command_status', 'crowdstrike_get_sensor_aggregates', 'crowdstrike_get_sensor_details', + 'crowdstrike_get_vulnerability_details', + 'crowdstrike_init_rtr_session', + 'crowdstrike_perform_host_action', + 'crowdstrike_perform_host_group_action', + 'crowdstrike_query_alerts', + 'crowdstrike_query_cases', + 'crowdstrike_query_host_groups', + 'crowdstrike_query_indicators', 'crowdstrike_query_sensors', + 'crowdstrike_query_vulnerabilities', + 'crowdstrike_update_alerts', + 'crowdstrike_update_indicators', ], config: { tool: (params) => - typeof params.operation === 'string' ? params.operation : 'crowdstrike_query_sensors', + typeof params.operation === 'string' ? params.operation : 'crowdstrike_query_alerts', params: (params) => { + const operation = typeof params.operation === 'string' ? params.operation : '' + + /** + * The executor merges this result over the raw block inputs, so a key this + * mapper simply omits keeps the raw subBlock value — and an untouched + * subBlock is stored as `null`, which the route contract rejects. Seeding + * every optional key as `undefined` makes omission authoritative: a blank + * field is dropped, and a value left over from another operation cannot + * ride along on the request. + */ const mapped: Record = { + ...CLEARED_OPTIONAL_PARAMS, clientId: params.clientId, clientSecret: params.clientSecret, cloud: params.cloud, } - if (params.operation === 'crowdstrike_query_sensors') { - if (params.filter) mapped.filter = params.filter + const setString = (key: string, value: unknown) => { + mapped[key] = typeof value === 'string' && value.trim().length > 0 ? value : undefined + } - const limit = parseOptionalNumberInput(params.limit, 'limit', { - integer: true, - max: 200, - min: 1, - }) - const offset = parseOptionalNumberInput(params.offset, 'offset', { - integer: true, - min: 0, - }) + const setNumber = ( + key: string, + value: unknown, + label: string, + bounds: { min?: number; max?: number } + ) => { + mapped[key] = parseOptionalNumberInput(value, label, { integer: true, ...bounds }) + } + + const setBoolean = (key: string, value: unknown) => { + mapped[key] = parseOptionalBooleanInput(value) + } + + const setJson = (key: string, value: unknown, label: string) => { + mapped[key] = parseOptionalJsonInput(value, label) + } + + const queryLimit = QUERY_LIMITS[operation] + if (queryLimit) { + setString('filter', params.filter) + setString('sort', params.sort) + setNumber('limit', params.limit, 'limit', queryLimit) + } - if (limit != null) mapped.limit = limit - if (offset != null) mapped.offset = offset - if (params.sort) mapped.sort = params.sort + if (OFFSET_QUERY_OPERATIONS.has(operation)) { + setNumber('offset', params.offset, 'offset', { min: 0 }) } - if (params.operation === 'crowdstrike_get_sensor_details') { - const ids = parseOptionalJsonInput(params.ids, 'sensor IDs') - if (ids !== undefined) mapped.ids = ids + if (CURSOR_QUERY_OPERATIONS.has(operation)) { + setString('after', params.after) } - if (params.operation === 'crowdstrike_get_sensor_aggregates') { - const aggregateQuery = parseOptionalJsonInput(params.aggregateQuery, 'aggregate query') - if (aggregateQuery !== undefined) mapped.aggregateQuery = aggregateQuery + if (FREE_TEXT_QUERY_OPERATIONS.has(operation)) { + setString('q', params.q) + } + + switch (operation) { + case 'crowdstrike_get_sensor_details': + setJson('ids', params.ids, 'sensor IDs') + break + case 'crowdstrike_get_sensor_aggregates': + setJson('aggregateQuery', params.aggregateQuery, 'aggregate query') + break + case 'crowdstrike_query_alerts': + setBoolean('includeHidden', params.includeHidden) + break + case 'crowdstrike_get_alert_details': + setJson('compositeIds', params.compositeIds, 'composite alert IDs') + setBoolean('includeHidden', params.includeHidden) + break + case 'crowdstrike_update_alerts': + setJson('compositeIds', params.compositeIds, 'composite alert IDs') + setString('updateStatus', params.updateStatus) + setString('assignToUuid', params.assignToUuid) + setString('assignToUserId', params.assignToUserId) + setString('assignToName', params.assignToName) + setString('appendComment', params.appendComment) + setString('addTag', params.addTag) + setString('removeTag', params.removeTag) + setString('removeTagsByPrefix', params.removeTagsByPrefix) + setBoolean('unassign', params.unassign) + setBoolean('showInUi', params.showInUi) + setBoolean('includeHidden', params.includeHidden) + setJson('actionParameters', params.actionParameters, 'action parameters') + break + case 'crowdstrike_perform_host_action': + setString('actionName', params.hostActionName) + setJson('deviceIds', params.deviceIds, 'host agent IDs') + break + case 'crowdstrike_get_host_group_details': + setJson('hostGroupIds', params.hostGroupIds, 'host group IDs') + break + case 'crowdstrike_perform_host_group_action': + setString('actionName', params.hostGroupActionName) + setString('hostGroupId', params.hostGroupId) + setJson('deviceIds', params.deviceIds, 'host agent IDs') + break + case 'crowdstrike_get_indicator_details': + setJson('indicatorIds', params.indicatorIds, 'indicator IDs') + break + case 'crowdstrike_create_indicators': + case 'crowdstrike_update_indicators': + setJson('indicators', params.indicators, 'indicators') + setString('comment', params.comment) + setBoolean('retrodetects', params.retrodetects) + setBoolean('ignoreWarnings', params.ignoreWarnings) + break + case 'crowdstrike_delete_indicators': + setJson('indicatorIds', params.indicatorIds, 'indicator IDs') + setString('filter', params.deleteFilter) + setString('comment', params.comment) + break + case 'crowdstrike_get_vulnerability_details': + setJson('vulnerabilityIds', params.vulnerabilityIds, 'vulnerability IDs') + break + case 'crowdstrike_init_rtr_session': + setString('deviceId', params.deviceId) + setString('origin', params.origin) + setBoolean('queueOffline', params.queueOffline) + break + case 'crowdstrike_execute_rtr_command': + setString('sessionId', params.sessionId) + setString('baseCommand', params.baseCommand) + setString('commandString', params.commandString) + break + case 'crowdstrike_get_rtr_command_status': + setString('cloudRequestId', params.cloudRequestId) + setNumber('sequenceId', params.sequenceId, 'sequence ID', { min: 0 }) + break + case 'crowdstrike_delete_rtr_session': + setString('sessionId', params.sessionId) + break + case 'crowdstrike_get_case_details': + setJson('caseIds', params.caseIds, 'case IDs') + break + default: + break } return mapped @@ -201,6 +966,11 @@ export const CrowdStrikeBlock: BlockConfig = { clientSecret: { type: 'string', description: 'CrowdStrike Falcon API client secret' }, cloud: { type: 'string', description: 'CrowdStrike Falcon cloud region' }, filter: { type: 'string', description: 'Falcon Query Language filter' }, + deleteFilter: { + type: 'string', + description: 'Falcon Query Language filter selecting the indicators to delete', + }, + q: { type: 'string', description: 'Free-text metadata search' }, ids: { type: 'json', description: 'JSON array of CrowdStrike sensor device IDs' }, aggregateQuery: { type: 'json', @@ -208,7 +978,47 @@ export const CrowdStrikeBlock: BlockConfig = { }, limit: { type: 'number', description: 'Maximum number of records to return' }, offset: { type: 'number', description: 'Pagination offset' }, + after: { type: 'string', description: 'Cursor for the next page of results' }, sort: { type: 'string', description: 'Sort expression' }, + includeHidden: { type: 'boolean', description: 'Include previously hidden alerts' }, + compositeIds: { type: 'json', description: 'JSON array of composite alert IDs' }, + updateStatus: { type: 'string', description: 'New alert status' }, + assignToUuid: { type: 'string', description: 'Falcon user UUID to assign alerts to' }, + assignToUserId: { type: 'string', description: 'Falcon user ID to assign alerts to' }, + assignToName: { type: 'string', description: 'Falcon username to assign alerts to' }, + unassign: { type: 'boolean', description: 'Clear the alert assignment' }, + appendComment: { type: 'string', description: 'Comment to append to the alert' }, + addTag: { type: 'string', description: 'Tag to add to the alert' }, + removeTag: { type: 'string', description: 'Tag to remove from the alert' }, + removeTagsByPrefix: { + type: 'string', + description: 'Remove every alert tag starting with this prefix', + }, + showInUi: { type: 'boolean', description: 'Whether the alert shows in the Falcon console' }, + actionParameters: { + type: 'json', + description: 'Additional alert action parameters as { name, value } objects', + }, + hostActionName: { type: 'string', description: 'Host action to perform' }, + deviceIds: { type: 'json', description: 'JSON array of host agent IDs' }, + hostGroupIds: { type: 'json', description: 'JSON array of host group IDs' }, + hostGroupActionName: { type: 'string', description: 'Host group action to perform' }, + hostGroupId: { type: 'string', description: 'Host group ID to modify' }, + indicatorIds: { type: 'json', description: 'JSON array of indicator IDs' }, + indicators: { type: 'json', description: 'JSON array of indicator objects' }, + comment: { type: 'string', description: 'Audit comment for indicator changes' }, + retrodetects: { type: 'boolean', description: 'Generate retroactive detections' }, + ignoreWarnings: { type: 'boolean', description: 'Apply indicator changes despite warnings' }, + vulnerabilityIds: { type: 'json', description: 'JSON array of Spotlight vulnerability IDs' }, + deviceId: { type: 'string', description: 'Host agent ID for the RTR session' }, + queueOffline: { type: 'boolean', description: 'Queue the RTR session for an offline host' }, + origin: { type: 'string', description: 'RTR session origin label' }, + sessionId: { type: 'string', description: 'RTR session ID' }, + baseCommand: { type: 'string', description: 'Read-only RTR base command' }, + commandString: { type: 'string', description: 'Full RTR command line to run' }, + cloudRequestId: { type: 'string', description: 'RTR cloud request ID' }, + sequenceId: { type: 'number', description: 'RTR output chunk sequence' }, + caseIds: { type: 'json', description: 'JSON array of Case Management case IDs' }, }, outputs: { @@ -222,18 +1032,135 @@ export const CrowdStrikeBlock: BlockConfig = { description: 'CrowdStrike aggregate result groups (name, buckets, docCountErrorUpperBound, sumOtherDocCount)', }, + alertIds: { type: 'json', description: 'Composite alert IDs matching an alert query' }, + alerts: { + type: 'json', + description: + 'CrowdStrike alert records (compositeId, id, cid, name, description, type, product, platform, severity, severityName, status, assignedToName, tactic, technique, deviceId, hostname, tags, timestamps)', + }, + updatedIds: { type: 'json', description: 'Composite alert IDs an update was submitted for' }, + affected: { + type: 'json', + description: 'Entities affected by a host action (id, path)', + }, + hostGroupIds: { type: 'json', description: 'Host group IDs matching a host group query' }, + hostGroups: { + type: 'json', + description: + 'CrowdStrike host group records (id, name, description, groupType, assignmentRule, createdBy, createdTimestamp, modifiedBy, modifiedTimestamp)', + }, + indicatorIds: { type: 'json', description: 'Indicator IDs matching an indicator query' }, + indicators: { + type: 'json', + description: + 'CrowdStrike indicator records (id, type, value, action, severity, platforms, hostGroups, appliedGlobally, tags, expiration, metadata, timestamps)', + }, + deletedIds: { type: 'json', description: 'Indicator IDs CrowdStrike deleted' }, + vulnerabilityIds: { + type: 'json', + description: 'Spotlight vulnerability IDs matching a vulnerability query', + }, + vulnerabilities: { + type: 'json', + description: + 'Spotlight vulnerability records (id, aid, status, cve, app, hostInfo, remediations, suppressionInfo, timestamps)', + }, + sessionId: { type: 'string', description: 'RTR session ID' }, + deviceId: { type: 'string', description: 'Host ID (AID) the RTR session was opened against' }, + platform: { type: 'string', description: 'Platform of the host in the RTR session' }, + pwd: { type: 'string', description: 'Working directory the RTR session started in' }, + offlineQueued: { + type: 'boolean', + description: 'Whether the RTR session was queued for an offline host', + }, + existingAidSessions: { + type: 'number', + description: 'Number of RTR sessions already open against the host', + }, + createdAt: { type: 'string', description: 'When the RTR session was created' }, + cloudRequestId: { type: 'string', description: 'RTR cloud request ID to poll for output' }, + queuedCommandOffline: { + type: 'boolean', + description: 'Whether the RTR command was queued because the host is offline', + }, + baseCommand: { type: 'string', description: 'Base RTR command the status refers to' }, + taskId: { type: 'string', description: 'RTR task ID for the executed command' }, + sequenceId: { type: 'number', description: 'Sequence number of the RTR command output chunk' }, + complete: { type: 'boolean', description: 'Whether an RTR command has finished' }, + stdout: { type: 'string', description: 'Standard output from an RTR command' }, + stderr: { type: 'string', description: 'Standard error from an RTR command' }, + deleted: { type: 'boolean', description: 'Whether the RTR session was closed' }, + caseIds: { type: 'json', description: 'Case IDs matching a case query' }, + cases: { + type: 'json', + description: + 'CrowdStrike Case Management records (id, name, description, status, severity, severityLevel, referenceId, assignedTo, tags, timestamps)', + }, pagination: { type: 'json', - description: 'Pagination metadata (limit, offset, total) for query responses', + description: 'Pagination metadata (limit, offset, total, after) for query responses', + }, + errors: { + type: 'json', + description: + 'Per-item errors CrowdStrike returned alongside a partially successful response (code, id, message)', }, count: { type: 'number', description: 'Number of records returned by the selected operation' }, }, } export const CrowdStrikeBlockMeta = { - tags: ['identity', 'monitoring'], + tags: ['identity', 'monitoring', 'incident-management', 'automation'], url: 'https://www.crowdstrike.com', templates: [ + { + icon: CrowdStrikeIcon, + title: 'CrowdStrike alert triage', + prompt: + 'Create a workflow that queries new high-severity CrowdStrike alerts, pulls their details, summarizes the tactic, technique, and affected host for each, posts the triage summary to Slack, and marks the reviewed alerts in progress.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['security', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: CrowdStrikeIcon, + title: 'CrowdStrike host containment', + prompt: + 'Create a workflow that takes a host ID, contains the host in CrowdStrike, opens a Real Time Response session to capture running processes and network connections, and posts the collected evidence to Slack for the on-call responder.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['security', 'incident-response'], + alsoIntegrations: ['slack'], + }, + { + icon: CrowdStrikeIcon, + title: 'CrowdStrike IOC sync', + prompt: + 'Create a workflow that reads indicators of compromise from a table, creates them as custom CrowdStrike indicators with a blocking action and an expiration, and records the returned indicator IDs back to the table.', + modules: ['agent', 'tables', 'workflows'], + category: 'operations', + tags: ['security', 'automation'], + }, + { + icon: CrowdStrikeIcon, + title: 'CrowdStrike vulnerability report', + prompt: + 'Create a scheduled workflow that queries CrowdStrike Spotlight for open critical vulnerabilities, pulls the CVE and remediation details, groups them by host, and writes a prioritized remediation report for the platform team.', + modules: ['scheduled', 'agent', 'files', 'workflows'], + category: 'operations', + tags: ['security', 'reporting'], + }, + { + icon: CrowdStrikeIcon, + title: 'CrowdStrike case digest', + prompt: + 'Create a scheduled workflow that queries open CrowdStrike Case Management cases, pulls each case status, severity, and assignee, and posts a daily standup digest to Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['security', 'reporting'], + alsoIntegrations: ['slack'], + }, { icon: CrowdStrikeIcon, title: 'CrowdStrike sensor coverage gaps', @@ -302,6 +1229,34 @@ export const CrowdStrikeBlockMeta = { }, ], skills: [ + { + name: 'triage-falcon-alerts', + description: + 'Query CrowdStrike alerts, pull their details, and summarize severity, tactic, and affected host for SOC triage.', + content: + '# Triage CrowdStrike Alerts\n\nWork a queue of Falcon alerts down to a reviewed state.\n\n## Steps\n1. Query alerts with an FQL filter for the status and severity you care about.\n2. Pull alert details for the returned composite IDs.\n3. Summarize each alert by severity, tactic, technique, and affected host.\n4. Update the reviewed alerts with a new status and an audit comment.\n\n## Output\nA triage summary per alert plus the list of alert IDs whose status was updated.', + }, + { + name: 'contain-compromised-host', + description: + 'Contain a CrowdStrike host and collect live evidence through a Real Time Response session.', + content: + '# Contain a Compromised Host\n\nIsolate a host and gather evidence before responders arrive.\n\n## Steps\n1. Run the contain action against the target host ID.\n2. Open a Real Time Response session on that host.\n3. Run read-tier commands (ps, netstat, ls, filehash) and poll each cloud request ID for output.\n4. Close the session when collection is done.\n\n## Output\nConfirmation the host was contained, plus the captured command output for the incident record.', + }, + { + name: 'manage-custom-indicators', + description: + 'Create, update, search, and delete custom CrowdStrike indicators of compromise.', + content: + '# Manage Custom CrowdStrike Indicators\n\nKeep the custom IOC list current.\n\n## Steps\n1. Query existing indicators with an FQL filter to see what is already covered.\n2. Create new indicators with the intended action, platforms, and expiration.\n3. Update severity, action, or expiration on indicators that need changing.\n4. Delete indicators that are stale, scoping deletes by explicit IDs rather than a broad filter.\n\n## Output\nThe indicator IDs created, updated, or deleted, plus any per-item errors CrowdStrike returned.', + }, + { + name: 'prioritize-spotlight-vulnerabilities', + description: + 'Query CrowdStrike Spotlight vulnerabilities and rank them by severity, exploit status, and affected host.', + content: + '# Prioritize Spotlight Vulnerabilities\n\nTurn the Spotlight backlog into a ranked remediation list.\n\n## Steps\n1. Query vulnerabilities with an FQL filter (Spotlight requires one) for open findings.\n2. Pull details for the returned IDs to get CVE data, host info, and remediations.\n3. Rank by CVE severity and exploit status, then group by host or application.\n\n## Output\nA prioritized remediation list naming each CVE, its affected hosts, and the recommended fix.', + }, { name: 'audit-identity-sensors', description: diff --git a/apps/sim/blocks/blocks/datadog.test.ts b/apps/sim/blocks/blocks/datadog.test.ts new file mode 100644 index 00000000000..0eef4a11f3a --- /dev/null +++ b/apps/sim/blocks/blocks/datadog.test.ts @@ -0,0 +1,153 @@ +/** + * Guards the Datadog block's params mapper and declared outputs against the tools they front. + * + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { DatadogBlock } from '@/blocks/blocks/datadog' +import * as datadogTools from '@/tools/datadog' +import type { ToolConfig } from '@/tools/types' + +const toolsById = new Map( + Object.values(datadogTools) + .filter( + (value): value is ToolConfig => typeof value === 'object' && value !== null && 'id' in value + ) + .map((tool) => [tool.id, tool]) +) + +const mapParams = DatadogBlock.tools.config?.params + +/** + * The block hands the executor `{ ...inputs, ...transformedParams }`, so a mapper that simply + * omits a key leaves the raw serialized subblock value in place. Every assertion about leakage + * has to run against this merged shape rather than the mapper's return value alone. + */ +function mergedParams(inputs: Record): Record { + return { ...inputs, ...(mapParams?.(inputs as never) as Record) } +} + +const baseInputs = { apiKey: 'key', applicationKey: 'app-key', site: 'datadoghq.com' } + +describe('datadog list_monitors params', () => { + /** + * `monitorTags` is Create Monitor's advanced tag field, but it serializes for every operation. + * Leaving it in the merge silently filters the monitor list while presenting it as complete. + */ + it('clears a leftover Create Monitor tag filter after the merge', () => { + const params = mergedParams({ + ...baseInputs, + operation: 'datadog_list_monitors', + monitorTags: 'team:backend', + }) + + expect(params.monitorTags).toBeUndefined() + }) + + it('still forwards the List Monitors filters', () => { + const params = mergedParams({ + ...baseInputs, + operation: 'datadog_list_monitors', + listMonitorName: 'CPU', + listMonitorTags: 'env:prod', + }) + + expect(params.name).toBe('CPU') + expect(params.tags).toBe('env:prod') + }) + + it('forwards pagination as numbers', () => { + const params = mergedParams({ + ...baseInputs, + operation: 'datadog_list_monitors', + listMonitorPageSize: '50', + listMonitorPage: '2', + }) + + expect(params.pageSize).toBe(50) + expect(params.page).toBe(2) + }) + + /** + * These are advanced free-text fields, so they can carry a typo or an unresolved + * reference. A bare `Number()` would put the literal `NaN` in the query string + * rather than omitting the parameter. + */ + it('drops a non-numeric pagination value instead of sending NaN', () => { + const params = mergedParams({ + ...baseInputs, + operation: 'datadog_list_monitors', + listMonitorPageSize: 'fifty', + listMonitorPage: '{{unresolved}}', + }) + + expect(params.pageSize).toBeUndefined() + expect(params.page).toBeUndefined() + }) + + it('keeps an explicit page 0, which is Datadog’s first page', () => { + const params = mergedParams({ + ...baseInputs, + operation: 'datadog_list_monitors', + listMonitorPage: '0', + }) + + expect(params.page).toBe(0) + }) + + it('exposes pagination subBlocks gated on List Monitors', () => { + const paginationIds = ['listMonitorPageSize', 'listMonitorPage'] + for (const id of paginationIds) { + const subBlock = DatadogBlock.subBlocks.find((candidate) => candidate.id === id) + expect(subBlock, `missing subBlock ${id}`).toBeDefined() + expect(subBlock?.condition).toEqual({ field: 'operation', value: 'datadog_list_monitors' }) + } + }) +}) + +describe('datadog create_monitor params', () => { + /** Clearing the leak must not disarm the operation the field actually belongs to. */ + it('still sends monitor tags as the create payload tags', () => { + const params = mergedParams({ + ...baseInputs, + operation: 'datadog_create_monitor', + name: 'High CPU', + type: 'metric alert', + monitorQuery: 'avg(last_5m):avg:system.cpu.user{*} > 90', + monitorTags: 'team:backend', + }) + + expect(params.tags).toBe('team:backend') + }) +}) + +describe('datadog block outputs', () => { + const outputs = DatadogBlock.outputs + + it('declares the fields mute and unmute return', () => { + for (const toolId of ['datadog_mute_monitor', 'datadog_unmute_monitor']) { + const toolOutputs = Object.keys(toolsById.get(toolId)?.outputs ?? {}) + expect(toolOutputs).toContain('monitorId') + for (const field of toolOutputs) { + expect(outputs, `${toolId} emits ${field}`).toHaveProperty(field) + } + } + }) + + /** `errors` is the only signal that Datadog rejected part of a submitted metric batch. */ + it('declares the submit_metrics errors field', () => { + expect(Object.keys(toolsById.get('datadog_submit_metrics')?.outputs ?? {})).toContain('errors') + expect(outputs).toHaveProperty('errors') + }) + + it('declares no output no tool can produce', () => { + const emitted = new Set() + for (const toolId of DatadogBlock.tools.access ?? []) { + for (const field of Object.keys(toolsById.get(toolId)?.outputs ?? {})) { + emitted.add(field) + } + } + + expect(Object.keys(outputs).filter((field) => !emitted.has(field))).toEqual([]) + }) +}) diff --git a/apps/sim/blocks/blocks/datadog.ts b/apps/sim/blocks/blocks/datadog.ts index 96e090aa262..0dcfe862503 100644 --- a/apps/sim/blocks/blocks/datadog.ts +++ b/apps/sim/blocks/blocks/datadog.ts @@ -3,6 +3,32 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { DatadogResponse } from '@/tools/datadog/types' +/** + * Normalizes a `switch` sub-block value. Switches serialize their state as the + * strings `'true'`/`'false'`, so `Boolean(value)` would read `'false'` as true. + * Returns `undefined` when the switch was never set so the tool falls back to + * Datadog's own default. + */ +function toSwitchBoolean(value: unknown): boolean | undefined { + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + return undefined +} + +/** + * Coerce a List Monitors pagination input, dropping anything that is not a finite + * number. These are advanced free-text fields, so they can carry a typo or an + * unresolved reference, and a bare `Number()` would put the literal `NaN` in the + * query string instead of omitting the parameter. An untouched subBlock resolves + * to `null` and an empty one to `''`; both are omissions rather than zeros, while + * an explicit `0` is Datadog's own first page and is kept. + */ +function datadogPageNumber(value: unknown): number | undefined { + if (value == null || value === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + export const DatadogBlock: BlockConfig = { type: 'datadog', name: 'Datadog', @@ -44,6 +70,10 @@ export const DatadogBlock: BlockConfig = { { text: ', for scope', field: 'scope' }, { text: ', until', field: 'end' }, ], + datadog_unmute_monitor: [ + { text: 'Unmute monitor', field: 'muteMonitorId', core: true }, + { text: ', for scope', field: 'scope' }, + ], datadog_query_logs: [ { text: 'Search logs matching', field: 'logQuery', core: true }, { text: ', since', field: 'logFrom' }, @@ -55,6 +85,87 @@ export const DatadogBlock: BlockConfig = { ], datadog_list_downtimes: ['List scheduled downtimes'], datadog_cancel_downtime: [{ text: 'Cancel downtime', field: 'downtimeId', core: true }], + datadog_list_incidents: ['List incidents'], + datadog_get_incident: [{ text: 'Read incident', field: 'incidentId', core: true }], + datadog_create_incident: [ + { text: 'Declare incident', field: 'incidentTitle', core: true }, + { text: ', at severity', field: 'incidentSeverity' }, + ], + datadog_update_incident: [ + { text: 'Update incident', field: 'incidentId', core: true }, + { text: ', to severity', field: 'incidentSeverity' }, + ], + datadog_add_incident_todo: [ + { text: 'Add follow-up task to incident', field: 'incidentId', core: true }, + { text: ', assigned to', field: 'todoAssignees' }, + ], + datadog_list_slos: [ + 'List SLOs', + { text: ', named like', field: 'sloQuery' }, + { text: ', tagged', field: 'sloTagsQuery' }, + ], + datadog_get_slo: [{ text: 'Read SLO', field: 'sloId', core: true }], + datadog_create_slo: [ + { text: 'Create SLO', field: 'sloName', core: true }, + { text: ', of type', field: 'sloType' }, + ], + datadog_update_slo: [{ text: 'Update SLO', field: 'sloId', core: true }], + datadog_delete_slo: [{ text: 'Delete SLO', field: 'sloId', core: true }], + datadog_get_slo_history: [ + { text: 'Read SLO history for', field: 'sloId', core: true }, + { text: ', since', field: 'sloFromTs' }, + ], + datadog_list_dashboards: ['List dashboards'], + datadog_get_dashboard: [{ text: 'Read dashboard', field: 'dashboardId', core: true }], + datadog_create_dashboard: [ + { text: 'Create dashboard', field: 'dashboardTitle', core: true }, + ], + datadog_delete_dashboard: [{ text: 'Delete dashboard', field: 'dashboardId', core: true }], + datadog_list_synthetics_tests: ['List Synthetic tests'], + datadog_get_synthetics_test: [ + { text: 'Read Synthetic test', field: 'syntheticsPublicId', core: true }, + ], + datadog_get_synthetics_results: [ + { text: 'Read results for Synthetic test', field: 'syntheticsPublicId', core: true }, + ], + datadog_get_browser_synthetics_results: [ + { + text: 'Read results for browser Synthetic test', + field: 'syntheticsPublicId', + core: true, + }, + ], + datadog_trigger_synthetics_tests: [ + { text: 'Trigger Synthetic tests', field: 'syntheticsPublicIds', core: true }, + ], + datadog_update_synthetics_status: [ + { text: 'Set Synthetic test', field: 'syntheticsPublicId', core: true }, + { text: 'to', field: 'syntheticsNewStatus' }, + ], + datadog_list_security_signals: [ + { text: 'Search security signals matching', field: 'signalQuery', core: true }, + { text: ', since', field: 'signalFrom' }, + ], + datadog_get_security_signal: [ + { text: 'Read security signal', field: 'signalId', core: true }, + ], + datadog_update_security_signal_state: [ + { text: 'Set security signal', field: 'signalId', core: true }, + { text: 'to state', field: 'signalState' }, + ], + datadog_update_security_signal_assignee: [ + { text: 'Assign security signal', field: 'signalId', core: true }, + { text: ', to', field: 'signalAssigneeUuid' }, + ], + datadog_list_security_rules: [ + 'List detection rules', + { text: ', matching', field: 'ruleQuery' }, + ], + datadog_search_spans: [ + { text: 'Search APM spans matching', field: 'spanQuery', core: true }, + { text: ', since', field: 'spanFrom' }, + ], + datadog_list_services: ['List services in the catalog'], }, }, }, @@ -72,11 +183,43 @@ export const DatadogBlock: BlockConfig = { { label: 'Get Monitor', id: 'datadog_get_monitor' }, { label: 'List Monitors', id: 'datadog_list_monitors' }, { label: 'Mute Monitor', id: 'datadog_mute_monitor' }, + { label: 'Unmute Monitor', id: 'datadog_unmute_monitor' }, { label: 'Query Logs', id: 'datadog_query_logs' }, { label: 'Send Logs', id: 'datadog_send_logs' }, { label: 'Create Downtime', id: 'datadog_create_downtime' }, { label: 'List Downtimes', id: 'datadog_list_downtimes' }, { label: 'Cancel Downtime', id: 'datadog_cancel_downtime' }, + { label: 'List Incidents', id: 'datadog_list_incidents' }, + { label: 'Get Incident', id: 'datadog_get_incident' }, + { label: 'Create Incident', id: 'datadog_create_incident' }, + { label: 'Update Incident', id: 'datadog_update_incident' }, + { label: 'Add Incident Todo', id: 'datadog_add_incident_todo' }, + { label: 'List SLOs', id: 'datadog_list_slos' }, + { label: 'Get SLO', id: 'datadog_get_slo' }, + { label: 'Create SLO', id: 'datadog_create_slo' }, + { label: 'Update SLO', id: 'datadog_update_slo' }, + { label: 'Delete SLO', id: 'datadog_delete_slo' }, + { label: 'Get SLO History', id: 'datadog_get_slo_history' }, + { label: 'List Dashboards', id: 'datadog_list_dashboards' }, + { label: 'Get Dashboard', id: 'datadog_get_dashboard' }, + { label: 'Create Dashboard', id: 'datadog_create_dashboard' }, + { label: 'Delete Dashboard', id: 'datadog_delete_dashboard' }, + { label: 'List Synthetic Tests', id: 'datadog_list_synthetics_tests' }, + { label: 'Get Synthetic Test', id: 'datadog_get_synthetics_test' }, + { label: 'Get Synthetic Test Results', id: 'datadog_get_synthetics_results' }, + { + label: 'Get Browser Synthetic Test Results', + id: 'datadog_get_browser_synthetics_results', + }, + { label: 'Trigger Synthetic Tests', id: 'datadog_trigger_synthetics_tests' }, + { label: 'Pause Or Start Synthetic Test', id: 'datadog_update_synthetics_status' }, + { label: 'List Security Signals', id: 'datadog_list_security_signals' }, + { label: 'Get Security Signal', id: 'datadog_get_security_signal' }, + { label: 'Update Security Signal State', id: 'datadog_update_security_signal_state' }, + { label: 'Assign Security Signal', id: 'datadog_update_security_signal_assignee' }, + { label: 'List Security Rules', id: 'datadog_list_security_rules' }, + { label: 'Search Spans', id: 'datadog_search_spans' }, + { label: 'List Services', id: 'datadog_list_services' }, ], value: () => 'datadog_submit_metrics', }, @@ -385,44 +528,60 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, condition: { field: 'operation', value: 'datadog_list_monitors' }, mode: 'advanced', }, + { + id: 'listMonitorPageSize', + title: 'Page Size', + type: 'short-input', + placeholder: '50', + condition: { field: 'operation', value: 'datadog_list_monitors' }, + mode: 'advanced', + }, + { + id: 'listMonitorPage', + title: 'Page Number', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_monitors' }, + mode: 'advanced', + }, - // Mute Monitor inputs + // Mute / Unmute Monitor inputs { id: 'muteMonitorId', title: 'Monitor ID', type: 'short-input', placeholder: '12345678', - condition: { field: 'operation', value: 'datadog_mute_monitor' }, - required: true, + condition: { + field: 'operation', + value: ['datadog_mute_monitor', 'datadog_unmute_monitor'], + }, + required: { field: 'operation', value: ['datadog_mute_monitor', 'datadog_unmute_monitor'] }, }, { id: 'scope', title: 'Scope', type: 'short-input', - placeholder: 'host:myhost (optional)', - condition: { field: 'operation', value: 'datadog_mute_monitor' }, + placeholder: 'host:myhost (leave blank for all scopes)', + condition: { + field: 'operation', + value: ['datadog_mute_monitor', 'datadog_unmute_monitor'], + }, mode: 'advanced', }, { id: 'end', - title: 'End Time (Unix Timestamp)', + title: 'Mute Until (Unix Timestamp)', type: 'short-input', - placeholder: 'Leave empty for indefinite', + placeholder: 'Leave empty to mute until unmuted', condition: { field: 'operation', value: 'datadog_mute_monitor' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a Unix timestamp (seconds since epoch) based on the user's description. -The timestamp should be a number representing seconds since January 1, 1970 UTC. -Examples: -- "in 1 hour" -> Calculate current time plus 3600 seconds -- "tomorrow morning" -> Calculate tomorrow at 09:00:00 UTC as Unix timestamp -- "end of day" -> Calculate today at 23:59:59 UTC as Unix timestamp - -Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, - placeholder: 'Describe when mute should end (e.g., "in 1 hour", "tomorrow")...', - generationType: 'timestamp', - }, + }, + { + id: 'unmuteAllScopes', + title: 'Clear All Scopes', + type: 'switch', + condition: { field: 'operation', value: 'datadog_unmute_monitor' }, + mode: 'advanced', }, // Query Logs inputs @@ -497,6 +656,14 @@ Return ONLY the relative time string - no explanations, no quotes, no extra text condition: { field: 'operation', value: 'datadog_query_logs' }, mode: 'advanced', }, + { + id: 'logCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Cursor returned by a previous call', + condition: { field: 'operation', value: 'datadog_query_logs' }, + mode: 'advanced', + }, // Send Logs inputs { @@ -605,6 +772,38 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, condition: { field: 'operation', value: 'datadog_create_downtime' }, mode: 'advanced', }, + { + id: 'downtimeMonitorTags', + title: 'Monitor Tags', + type: 'short-input', + placeholder: 'team:backend,priority:high', + condition: { field: 'operation', value: 'datadog_create_downtime' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Datadog monitor tags based on the user's description. +Each tag uses the "key:value" form. +Examples: "team:backend,priority:high", "env:production", "service:checkout" + +Return ONLY the comma-separated tag list - no explanations, no extra text.`, + placeholder: 'Describe which monitors to target...', + }, + }, + { + id: 'downtimeTimezone', + title: 'Timezone', + type: 'short-input', + placeholder: 'UTC or America/New_York', + condition: { field: 'operation', value: 'datadog_create_downtime' }, + mode: 'advanced', + }, + { + id: 'downtimeMuteFirstRecovery', + title: 'Mute First Recovery Notification', + type: 'switch', + condition: { field: 'operation', value: 'datadog_create_downtime' }, + mode: 'advanced', + }, // List Downtimes inputs { @@ -614,6 +813,22 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, condition: { field: 'operation', value: 'datadog_list_downtimes' }, mode: 'advanced', }, + { + id: 'downtimeLimit', + title: 'Limit', + type: 'short-input', + placeholder: '30', + condition: { field: 'operation', value: 'datadog_list_downtimes' }, + mode: 'advanced', + }, + { + id: 'downtimeOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_downtimes' }, + mode: 'advanced', + }, // Cancel Downtime inputs { @@ -625,85 +840,1148 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, required: true, }, - // Authentication (common) + // Incidents inputs { - id: 'apiKey', - title: 'API Key', + id: 'incidentId', + title: 'Incident ID', type: 'short-input', - placeholder: 'Enter your Datadog API key', - password: true, - required: true, + placeholder: '00000000-0000-0000-1234-000000000000', + condition: { + field: 'operation', + value: ['datadog_get_incident', 'datadog_update_incident', 'datadog_add_incident_todo'], + }, + required: { + field: 'operation', + value: ['datadog_get_incident', 'datadog_update_incident', 'datadog_add_incident_todo'], + }, }, - // Application Key - REQUIRED only for read/manage operations (not needed for submit_metrics, create_event, send_logs) { - id: 'applicationKey', - title: 'Application Key', + id: 'incidentTitle', + title: 'Incident Title', type: 'short-input', - placeholder: 'Enter your Datadog application key', - password: true, + placeholder: 'Checkout API returning 500s', condition: { field: 'operation', - value: [ - 'datadog_query_timeseries', - 'datadog_create_monitor', - 'datadog_get_monitor', - 'datadog_list_monitors', - 'datadog_mute_monitor', - 'datadog_query_logs', - 'datadog_create_downtime', - 'datadog_list_downtimes', - 'datadog_cancel_downtime', - ], + value: ['datadog_create_incident', 'datadog_update_incident'], }, - required: true, + required: { field: 'operation', value: 'datadog_create_incident' }, }, { - id: 'site', - title: 'Datadog Site', + id: 'incidentCustomerImpacted', + title: 'Customer Impacted', + type: 'switch', + condition: { + field: 'operation', + value: ['datadog_create_incident', 'datadog_update_incident'], + }, + }, + { + id: 'incidentSeverity', + title: 'Severity', type: 'dropdown', options: [ - { label: 'US1 (datadoghq.com)', id: 'datadoghq.com' }, - { label: 'US3 (us3.datadoghq.com)', id: 'us3.datadoghq.com' }, - { label: 'US5 (us5.datadoghq.com)', id: 'us5.datadoghq.com' }, - { label: 'EU (datadoghq.eu)', id: 'datadoghq.eu' }, - { label: 'AP1 (ap1.datadoghq.com)', id: 'ap1.datadoghq.com' }, - { label: 'US1-FED (ddog-gov.com)', id: 'ddog-gov.com' }, + { label: 'SEV-0', id: 'SEV-0' }, + { label: 'SEV-1', id: 'SEV-1' }, + { label: 'SEV-2', id: 'SEV-2' }, + { label: 'SEV-3', id: 'SEV-3' }, + { label: 'SEV-4', id: 'SEV-4' }, + { label: 'SEV-5', id: 'SEV-5' }, + { label: 'Unknown', id: 'UNKNOWN' }, ], - value: () => 'datadoghq.com', + condition: { + field: 'operation', + value: ['datadog_create_incident', 'datadog_update_incident'], + }, + }, + { + id: 'incidentCustomerImpactScope', + title: 'Customer Impact Scope', + type: 'long-input', + placeholder: 'Checkout unavailable for EU customers', + condition: { + field: 'operation', + value: ['datadog_create_incident', 'datadog_update_incident'], + }, mode: 'advanced', }, - ], - tools: { - access: [ - 'datadog_submit_metrics', - 'datadog_query_timeseries', - 'datadog_create_event', - 'datadog_create_monitor', - 'datadog_get_monitor', - 'datadog_list_monitors', - 'datadog_mute_monitor', - 'datadog_query_logs', - 'datadog_send_logs', - 'datadog_create_downtime', - 'datadog_list_downtimes', - 'datadog_cancel_downtime', - ], - config: { - tool: (params) => params.operation, - params: (params) => { - // Base params that are always needed - const baseParams: Record = { - apiKey: params.apiKey, - applicationKey: params.applicationKey, - site: params.site, - } + { + id: 'incidentTypeUuid', + title: 'Incident Type UUID', + type: 'short-input', + placeholder: 'Leave empty to use the default incident type', + condition: { field: 'operation', value: 'datadog_create_incident' }, + mode: 'advanced', + }, + { + id: 'incidentIsTest', + title: 'Test Incident', + type: 'switch', + condition: { field: 'operation', value: 'datadog_create_incident' }, + mode: 'advanced', + }, + { + id: 'incidentCustomerImpactStart', + title: 'Customer Impact Start', + type: 'short-input', + placeholder: '2026-01-02T09:42:36Z', + condition: { field: 'operation', value: 'datadog_update_incident' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp for when customer impact began. Return ONLY the timestamp string.', + placeholder: 'Describe when customer impact started...', + generationType: 'timestamp', + }, + }, + { + id: 'incidentCustomerImpactEnd', + title: 'Customer Impact End', + type: 'short-input', + placeholder: '2026-01-02T10:15:00Z', + condition: { field: 'operation', value: 'datadog_update_incident' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp for when customer impact ended. Return ONLY the timestamp string.', + placeholder: 'Describe when customer impact ended...', + generationType: 'timestamp', + }, + }, + { + id: 'incidentDetected', + title: 'Detected At', + type: 'short-input', + placeholder: '2026-01-02T09:40:00Z', + condition: { field: 'operation', value: 'datadog_update_incident' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp for when the incident was detected. Return ONLY the timestamp string.', + placeholder: 'Describe when the incident was detected...', + generationType: 'timestamp', + }, + }, + { + id: 'incidentFields', + title: 'Fields (JSON)', + type: 'code', + placeholder: '{"state": {"type": "dropdown", "value": "resolved"}}', + condition: { + field: 'operation', + value: ['datadog_create_incident', 'datadog_update_incident'], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a Datadog incident fields object based on the user's description. +Each key is a field name and each value is an object with "type" and "value". +Examples: +- {"severity": {"type": "dropdown", "value": "SEV-2"}} +- {"state": {"type": "dropdown", "value": "resolved"}} +- {"services": {"type": "multiselect", "value": ["checkout", "payments"]}} - // Only include params relevant to each operation - switch (params.operation) { - case 'datadog_submit_metrics': - return { ...baseParams, series: params.series } +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the incident fields to set...', + generationType: 'json-object', + }, + }, + { + id: 'incidentNotificationHandles', + title: 'Notification Handles', + type: 'short-input', + placeholder: '@slack-incidents, @oncall@example.com', + condition: { + field: 'operation', + value: ['datadog_create_incident', 'datadog_update_incident'], + }, + mode: 'advanced', + }, + { + id: 'incidentInclude', + title: 'Include Related Resources', + type: 'short-input', + placeholder: 'users, attachments', + condition: { + field: 'operation', + value: ['datadog_list_incidents', 'datadog_get_incident'], + }, + mode: 'advanced', + }, + { + id: 'incidentPageSize', + title: 'Page Size', + type: 'short-input', + placeholder: '10', + condition: { field: 'operation', value: 'datadog_list_incidents' }, + mode: 'advanced', + }, + { + id: 'incidentPageOffset', + title: 'Page Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_incidents' }, + mode: 'advanced', + }, + { + id: 'todoContent', + title: 'Task Content', + type: 'long-input', + placeholder: 'Restore lost data', + condition: { field: 'operation', value: 'datadog_add_incident_todo' }, + required: true, + }, + { + id: 'todoAssignees', + title: 'Assignees', + type: 'short-input', + placeholder: '@jane@example.com, @oncall', + condition: { field: 'operation', value: 'datadog_add_incident_todo' }, + required: true, + }, + { + id: 'todoDueDate', + title: 'Due Date', + type: 'short-input', + placeholder: '2026-01-10T05:00:00Z', + condition: { field: 'operation', value: 'datadog_add_incident_todo' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp for when the task is due. Return ONLY the timestamp string.', + placeholder: 'Describe when the task is due...', + generationType: 'timestamp', + }, + }, - case 'datadog_query_timeseries': + // SLO inputs + { + id: 'sloId', + title: 'SLO ID', + type: 'short-input', + placeholder: 'e6ce9c47b6c04d3d9dbfbb1cb4b7a8a3', + condition: { + field: 'operation', + value: [ + 'datadog_get_slo', + 'datadog_update_slo', + 'datadog_delete_slo', + 'datadog_get_slo_history', + ], + }, + required: { + field: 'operation', + value: [ + 'datadog_get_slo', + 'datadog_update_slo', + 'datadog_delete_slo', + 'datadog_get_slo_history', + ], + }, + }, + { + id: 'sloName', + title: 'SLO Name', + type: 'short-input', + placeholder: 'Checkout API availability', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + required: { field: 'operation', value: ['datadog_create_slo'] }, + }, + { + id: 'sloType', + title: 'SLO Type', + type: 'dropdown', + options: [ + { label: 'Metric', id: 'metric' }, + { label: 'Monitor', id: 'monitor' }, + ], + value: () => 'metric', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + required: { field: 'operation', value: ['datadog_create_slo'] }, + }, + { + id: 'sloThresholds', + title: 'Thresholds (JSON)', + type: 'code', + placeholder: '[{"timeframe": "30d", "target": 99.9, "warning": 99.95}]', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + required: { field: 'operation', value: ['datadog_create_slo'] }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of Datadog SLO thresholds based on the user's description. +Each threshold object has: +- "timeframe": one of "7d", "30d", or "90d" +- "target": the target percentage (e.g., 99.9) +- "warning": optional warning percentage, must be greater than the target + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the SLO targets...', + generationType: 'json-object', + }, + }, + { + id: 'sloMetricQuery', + title: 'Metric Query (JSON)', + type: 'code', + placeholder: + '{"numerator": "sum:requests{status:ok}.as_count()", "denominator": "sum:requests{*}.as_count()"}', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + wandConfig: { + enabled: true, + prompt: `Generate a Datadog metric SLO query object based on the user's description. +The object has: +- "numerator": the query counting good events +- "denominator": the query counting all events + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the good events and total events...', + generationType: 'json-object', + }, + }, + { + id: 'sloMonitorIds', + title: 'Monitor IDs', + type: 'short-input', + placeholder: '12345678, 23456789', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + mode: 'advanced', + }, + { + id: 'sloGroups', + title: 'Monitor Groups', + type: 'short-input', + placeholder: 'env:prod, role:mysql', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + mode: 'advanced', + }, + { + id: 'sloDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Availability of the checkout API', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + mode: 'advanced', + }, + { + id: 'sloTags', + title: 'Tags', + type: 'short-input', + placeholder: 'env:prod, team:core', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + mode: 'advanced', + }, + { + id: 'sloTargetThreshold', + title: 'Target Threshold', + type: 'short-input', + placeholder: '99.9', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + mode: 'advanced', + }, + { + id: 'sloWarningThreshold', + title: 'Warning Threshold', + type: 'short-input', + placeholder: '99.95', + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + mode: 'advanced', + }, + { + id: 'sloTimeframe', + title: 'Timeframe', + type: 'dropdown', + options: [ + { label: '7 days', id: '7d' }, + { label: '30 days', id: '30d' }, + { label: '90 days', id: '90d' }, + ], + condition: { field: 'operation', value: ['datadog_create_slo', 'datadog_update_slo'] }, + mode: 'advanced', + }, + { + id: 'sloIds', + title: 'Filter by IDs', + type: 'short-input', + placeholder: 'id1, id2', + condition: { field: 'operation', value: 'datadog_list_slos' }, + mode: 'advanced', + }, + { + id: 'sloQuery', + title: 'Filter by Name', + type: 'short-input', + placeholder: 'checkout', + condition: { field: 'operation', value: 'datadog_list_slos' }, + mode: 'advanced', + }, + { + id: 'sloTagsQuery', + title: 'Filter by Tag', + type: 'short-input', + placeholder: 'env:prod', + condition: { field: 'operation', value: 'datadog_list_slos' }, + mode: 'advanced', + }, + { + id: 'sloMetricsQuery', + title: 'Filter by Metrics Query', + type: 'short-input', + placeholder: 'aws.elb.request_count', + condition: { field: 'operation', value: 'datadog_list_slos' }, + mode: 'advanced', + }, + { + id: 'sloLimit', + title: 'Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'datadog_list_slos' }, + mode: 'advanced', + }, + { + id: 'sloOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_slos' }, + mode: 'advanced', + }, + { + id: 'sloWithConfiguredAlertIds', + title: 'Include SLO Monitor IDs', + type: 'switch', + condition: { field: 'operation', value: 'datadog_get_slo' }, + mode: 'advanced', + }, + { + id: 'sloForce', + title: 'Force Delete', + type: 'switch', + condition: { field: 'operation', value: 'datadog_delete_slo' }, + mode: 'advanced', + }, + { + id: 'sloFromTs', + title: 'From (Unix Timestamp)', + type: 'short-input', + placeholder: 'e.g., 1701360000', + condition: { field: 'operation', value: 'datadog_get_slo_history' }, + required: true, + wandConfig: { + enabled: true, + prompt: + 'Generate a Unix timestamp in seconds for the start of the window. Return ONLY the numeric timestamp.', + placeholder: 'Describe the start time (e.g., "7 days ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'sloToTs', + title: 'To (Unix Timestamp)', + type: 'short-input', + placeholder: 'e.g., 1701446400', + condition: { field: 'operation', value: 'datadog_get_slo_history' }, + required: true, + wandConfig: { + enabled: true, + prompt: + 'Generate a Unix timestamp in seconds for the end of the window. Return ONLY the numeric timestamp.', + placeholder: 'Describe the end time (e.g., "now")...', + generationType: 'timestamp', + }, + }, + { + id: 'sloTarget', + title: 'Target', + type: 'short-input', + placeholder: '99.9', + condition: { field: 'operation', value: 'datadog_get_slo_history' }, + mode: 'advanced', + }, + { + id: 'sloApplyCorrection', + title: 'Apply Corrections', + type: 'switch', + value: () => 'true', + condition: { field: 'operation', value: 'datadog_get_slo_history' }, + mode: 'advanced', + }, + + // Dashboard inputs + { + id: 'dashboardId', + title: 'Dashboard ID', + type: 'short-input', + placeholder: 'abc-def-ghi', + condition: { + field: 'operation', + value: ['datadog_get_dashboard', 'datadog_delete_dashboard'], + }, + required: { + field: 'operation', + value: ['datadog_get_dashboard', 'datadog_delete_dashboard'], + }, + }, + { + id: 'dashboardTitle', + title: 'Dashboard Title', + type: 'short-input', + placeholder: 'Checkout service overview', + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + required: true, + }, + { + id: 'dashboardLayoutType', + title: 'Layout Type', + type: 'dropdown', + options: [ + { label: 'Ordered', id: 'ordered' }, + { label: 'Free', id: 'free' }, + ], + value: () => 'ordered', + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + required: true, + }, + { + id: 'dashboardWidgets', + title: 'Widgets (JSON)', + type: 'code', + placeholder: + '[{"definition": {"type": "timeseries", "title": "CPU", "requests": [{"q": "avg:system.cpu.user{*}"}]}}]', + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of Datadog dashboard widgets based on the user's description. +Each widget has a "definition" object with at least a "type" (e.g., "timeseries", "query_value", "toplist"), +a "title", and the widget's "requests" array. + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the widgets you want on the dashboard...', + generationType: 'json-object', + }, + }, + { + id: 'dashboardDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Latency, errors, and saturation for checkout', + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + mode: 'advanced', + }, + { + id: 'dashboardTags', + title: 'Tags', + type: 'short-input', + placeholder: 'team:core', + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + mode: 'advanced', + }, + { + id: 'dashboardNotifyList', + title: 'Notify List', + type: 'short-input', + placeholder: 'jane@example.com, john@example.com', + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + mode: 'advanced', + }, + { + id: 'dashboardTemplateVariables', + title: 'Template Variables (JSON)', + type: 'code', + placeholder: '[{"name": "env", "prefix": "env", "available_values": ["prod", "staging"]}]', + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of Datadog dashboard template variables based on the user's description. +Each variable has a "name", an optional "prefix" (the tag key), and optional "available_values". + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the template variables...', + generationType: 'json-object', + }, + }, + { + id: 'dashboardReflowType', + title: 'Reflow Type', + type: 'dropdown', + options: [ + { label: 'Auto', id: 'auto' }, + { label: 'Fixed', id: 'fixed' }, + ], + condition: { field: 'operation', value: 'datadog_create_dashboard' }, + mode: 'advanced', + }, + { + id: 'dashboardFilterShared', + title: 'Shared Only', + type: 'switch', + condition: { field: 'operation', value: 'datadog_list_dashboards' }, + mode: 'advanced', + }, + { + id: 'dashboardFilterDeleted', + title: 'Deleted Only', + type: 'switch', + condition: { field: 'operation', value: 'datadog_list_dashboards' }, + mode: 'advanced', + }, + { + id: 'dashboardCount', + title: 'Count', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'datadog_list_dashboards' }, + mode: 'advanced', + }, + { + id: 'dashboardStart', + title: 'Start Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_dashboards' }, + mode: 'advanced', + }, + + // Synthetics inputs + { + id: 'syntheticsPublicId', + title: 'Test Public ID', + type: 'short-input', + placeholder: 'abc-def-ghi', + condition: { + field: 'operation', + value: [ + 'datadog_get_synthetics_test', + 'datadog_get_synthetics_results', + 'datadog_get_browser_synthetics_results', + 'datadog_update_synthetics_status', + ], + }, + required: { + field: 'operation', + value: [ + 'datadog_get_synthetics_test', + 'datadog_get_synthetics_results', + 'datadog_get_browser_synthetics_results', + 'datadog_update_synthetics_status', + ], + }, + }, + { + id: 'syntheticsNewStatus', + title: 'New Status', + type: 'dropdown', + options: [ + { label: 'Live', id: 'live' }, + { label: 'Paused', id: 'paused' }, + ], + value: () => 'live', + condition: { field: 'operation', value: 'datadog_update_synthetics_status' }, + required: true, + }, + { + id: 'syntheticsPublicIds', + title: 'Test Public IDs', + type: 'short-input', + placeholder: 'abc-def-ghi, jkl-mno-pqr', + condition: { field: 'operation', value: 'datadog_trigger_synthetics_tests' }, + required: true, + }, + { + id: 'syntheticsFromTs', + title: 'From (Unix Milliseconds)', + type: 'short-input', + placeholder: 'e.g., 1701360000000', + condition: { + field: 'operation', + value: ['datadog_get_synthetics_results', 'datadog_get_browser_synthetics_results'], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a Unix timestamp in MILLISECONDS for the start of the window. Return ONLY the numeric timestamp.', + placeholder: 'Describe the start time (e.g., "1 hour ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'syntheticsToTs', + title: 'To (Unix Milliseconds)', + type: 'short-input', + placeholder: 'e.g., 1701446400000', + condition: { + field: 'operation', + value: ['datadog_get_synthetics_results', 'datadog_get_browser_synthetics_results'], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a Unix timestamp in MILLISECONDS for the end of the window. Return ONLY the numeric timestamp.', + placeholder: 'Describe the end time (e.g., "now")...', + generationType: 'timestamp', + }, + }, + { + id: 'syntheticsProbeDc', + title: 'Locations', + type: 'short-input', + placeholder: 'aws:eu-west-3, aws:us-east-1', + condition: { + field: 'operation', + value: ['datadog_get_synthetics_results', 'datadog_get_browser_synthetics_results'], + }, + mode: 'advanced', + }, + { + id: 'syntheticsPageSize', + title: 'Page Size', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'datadog_list_synthetics_tests' }, + mode: 'advanced', + }, + { + id: 'syntheticsPageNumber', + title: 'Page Number', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_synthetics_tests' }, + mode: 'advanced', + }, + + // Security monitoring inputs + { + id: 'signalId', + title: 'Signal ID', + type: 'short-input', + placeholder: 'AAAAAWgN8Xwgr1vKDQAAAABBV2dOOFh3ZzZobm1mWXJFYTR0OA', + condition: { + field: 'operation', + value: [ + 'datadog_get_security_signal', + 'datadog_update_security_signal_state', + 'datadog_update_security_signal_assignee', + ], + }, + required: { + field: 'operation', + value: [ + 'datadog_get_security_signal', + 'datadog_update_security_signal_state', + 'datadog_update_security_signal_assignee', + ], + }, + }, + { + id: 'signalState', + title: 'Triage State', + type: 'dropdown', + options: [ + { label: 'Open', id: 'open' }, + { label: 'Under Review', id: 'under_review' }, + { label: 'Archived', id: 'archived' }, + ], + value: () => 'open', + condition: { field: 'operation', value: 'datadog_update_security_signal_state' }, + required: true, + }, + { + id: 'signalArchiveReason', + title: 'Archive Reason', + type: 'dropdown', + options: [ + { label: 'None', id: 'none' }, + { label: 'False Positive', id: 'false_positive' }, + { label: 'Testing Or Maintenance', id: 'testing_or_maintenance' }, + { label: 'Remediated', id: 'remediated' }, + { label: 'Investigated, Case Opened', id: 'investigated_case_opened' }, + { label: 'True Positive - Benign', id: 'true_positive_benign' }, + { label: 'True Positive - Malicious', id: 'true_positive_malicious' }, + { label: 'Other', id: 'other' }, + ], + condition: { field: 'operation', value: 'datadog_update_security_signal_state' }, + mode: 'advanced', + }, + { + id: 'signalArchiveComment', + title: 'Archive Comment', + type: 'long-input', + placeholder: 'Known scanner traffic from the security team', + condition: { field: 'operation', value: 'datadog_update_security_signal_state' }, + mode: 'advanced', + }, + { + id: 'signalAssigneeUuid', + title: 'Assignee UUID', + type: 'short-input', + placeholder: '773b045d-ccf8-4808-bd3b-955ef6a8c940', + condition: { field: 'operation', value: 'datadog_update_security_signal_assignee' }, + required: true, + }, + { + id: 'signalQuery', + title: 'Search Query', + type: 'long-input', + placeholder: 'security:attack status:high', + condition: { field: 'operation', value: 'datadog_list_security_signals' }, + wandConfig: { + enabled: true, + prompt: `Generate a Datadog security signal search query based on the user's description. +The query uses facet syntax: facet:value +Examples: +- "security:attack status:high" - High severity attack signals +- "@workflow.rule.name:\"Brute Force\"" - Signals from a specific rule +- "source:cloudtrail status:critical" - Critical CloudTrail signals + +Return ONLY the search query string - no explanations.`, + placeholder: 'Describe the signals you want to find...', + }, + }, + { + id: 'signalFrom', + title: 'From', + type: 'short-input', + placeholder: '2026-01-02T09:42:36.320Z', + condition: { field: 'operation', value: 'datadog_list_security_signals' }, + wandConfig: { + enabled: true, + prompt: + 'Convert the described start time into an absolute ISO-8601 UTC date-time, e.g. 2026-01-02T09:42:36.320Z. Datadog signal search rejects relative expressions such as "now-1h", so always resolve them against the current date. Return ONLY the timestamp.', + placeholder: 'Describe the start time (e.g., "1 hour ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'signalTo', + title: 'To', + type: 'short-input', + placeholder: '2026-01-03T09:42:36.320Z', + condition: { field: 'operation', value: 'datadog_list_security_signals' }, + wandConfig: { + enabled: true, + prompt: + 'Convert the described end time into an absolute ISO-8601 UTC date-time, e.g. 2026-01-03T09:42:36.320Z. Datadog signal search rejects relative expressions such as "now", so always resolve them against the current date. Return ONLY the timestamp.', + placeholder: 'Describe the end time (e.g., "now")...', + generationType: 'timestamp', + }, + }, + { + id: 'signalSort', + title: 'Sort', + type: 'dropdown', + options: [ + { label: 'Newest First', id: '-timestamp' }, + { label: 'Oldest First', id: 'timestamp' }, + ], + condition: { field: 'operation', value: 'datadog_list_security_signals' }, + mode: 'advanced', + }, + { + id: 'signalLimit', + title: 'Limit', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'datadog_list_security_signals' }, + mode: 'advanced', + }, + { + id: 'signalCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Cursor returned by a previous call', + condition: { field: 'operation', value: 'datadog_list_security_signals' }, + mode: 'advanced', + }, + { + id: 'ruleQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'type:log_detection source:cloudtrail', + condition: { field: 'operation', value: 'datadog_list_security_rules' }, + mode: 'advanced', + }, + { + id: 'ruleSort', + title: 'Sort', + type: 'dropdown', + options: [ + { label: 'Name', id: 'name' }, + { label: 'Name (descending)', id: '-name' }, + { label: 'Creation Date', id: 'creation_date' }, + { label: 'Creation Date (descending)', id: '-creation_date' }, + { label: 'Update Date', id: 'update_date' }, + { label: 'Update Date (descending)', id: '-update_date' }, + ], + condition: { field: 'operation', value: 'datadog_list_security_rules' }, + mode: 'advanced', + }, + { + id: 'rulePageSize', + title: 'Page Size', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'datadog_list_security_rules' }, + mode: 'advanced', + }, + { + id: 'rulePageNumber', + title: 'Page Number', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_security_rules' }, + mode: 'advanced', + }, + + // APM inputs + { + id: 'spanQuery', + title: 'Span Query', + type: 'long-input', + placeholder: 'service:checkout AND @http.status_code:[500 TO 599]', + condition: { field: 'operation', value: 'datadog_search_spans' }, + wandConfig: { + enabled: true, + prompt: `Generate a Datadog span search query based on the user's description. +The query uses facet syntax: facet:value +Examples: +- "service:checkout AND @http.status_code:[500 TO 599]" - Server errors in checkout +- "env:prod resource_name:\"GET /cart\"" - Spans for a specific endpoint +- "@duration:>1000000000" - Spans slower than one second (duration is in nanoseconds) + +Return ONLY the search query string - no explanations.`, + placeholder: 'Describe the spans you want to find...', + }, + }, + { + id: 'spanFrom', + title: 'From', + type: 'short-input', + placeholder: 'now-15m', + condition: { field: 'operation', value: 'datadog_search_spans' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a Datadog relative time string such as now-15m, now-1h, or now-1d. Return ONLY the string.', + placeholder: 'Describe the start time (e.g., "15 minutes ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'spanTo', + title: 'To', + type: 'short-input', + placeholder: 'now', + condition: { field: 'operation', value: 'datadog_search_spans' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a Datadog relative time string such as now or now-5m. Return ONLY the string.', + placeholder: 'Describe the end time (e.g., "now")...', + generationType: 'timestamp', + }, + }, + { + id: 'spanSort', + title: 'Sort', + type: 'dropdown', + options: [ + { label: 'Newest First', id: '-timestamp' }, + { label: 'Oldest First', id: 'timestamp' }, + ], + condition: { field: 'operation', value: 'datadog_search_spans' }, + mode: 'advanced', + }, + { + id: 'spanLimit', + title: 'Limit', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'datadog_search_spans' }, + mode: 'advanced', + }, + { + id: 'spanCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Cursor returned by a previous call', + condition: { field: 'operation', value: 'datadog_search_spans' }, + mode: 'advanced', + }, + { + id: 'servicePageSize', + title: 'Page Size', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'datadog_list_services' }, + mode: 'advanced', + }, + { + id: 'servicePageNumber', + title: 'Page Number', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'datadog_list_services' }, + mode: 'advanced', + }, + { + id: 'serviceSchemaVersion', + title: 'Schema Version', + type: 'dropdown', + options: [ + { label: 'v2', id: 'v2' }, + { label: 'v2.1', id: 'v2.1' }, + { label: 'v2.2', id: 'v2.2' }, + ], + condition: { field: 'operation', value: 'datadog_list_services' }, + mode: 'advanced', + }, + + // Authentication (common) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Datadog API key', + password: true, + required: true, + }, + // Application Key - REQUIRED only for read/manage operations (not needed for submit_metrics, create_event, send_logs) + { + id: 'applicationKey', + title: 'Application Key', + type: 'short-input', + placeholder: 'Enter your Datadog application key', + password: true, + condition: { + field: 'operation', + value: [ + 'datadog_query_timeseries', + 'datadog_create_monitor', + 'datadog_get_monitor', + 'datadog_list_monitors', + 'datadog_mute_monitor', + 'datadog_unmute_monitor', + 'datadog_query_logs', + 'datadog_create_downtime', + 'datadog_list_downtimes', + 'datadog_cancel_downtime', + 'datadog_list_incidents', + 'datadog_get_incident', + 'datadog_create_incident', + 'datadog_update_incident', + 'datadog_add_incident_todo', + 'datadog_list_slos', + 'datadog_get_slo', + 'datadog_create_slo', + 'datadog_update_slo', + 'datadog_delete_slo', + 'datadog_get_slo_history', + 'datadog_list_dashboards', + 'datadog_get_dashboard', + 'datadog_create_dashboard', + 'datadog_delete_dashboard', + 'datadog_list_synthetics_tests', + 'datadog_get_synthetics_test', + 'datadog_get_synthetics_results', + 'datadog_get_browser_synthetics_results', + 'datadog_trigger_synthetics_tests', + 'datadog_update_synthetics_status', + 'datadog_list_security_signals', + 'datadog_get_security_signal', + 'datadog_update_security_signal_state', + 'datadog_update_security_signal_assignee', + 'datadog_list_security_rules', + 'datadog_search_spans', + 'datadog_list_services', + ], + }, + required: true, + }, + { + id: 'site', + title: 'Datadog Site', + type: 'dropdown', + options: [ + { label: 'US1 (datadoghq.com)', id: 'datadoghq.com' }, + { label: 'US3 (us3.datadoghq.com)', id: 'us3.datadoghq.com' }, + { label: 'US5 (us5.datadoghq.com)', id: 'us5.datadoghq.com' }, + { label: 'EU (datadoghq.eu)', id: 'datadoghq.eu' }, + { label: 'AP1 (ap1.datadoghq.com)', id: 'ap1.datadoghq.com' }, + { label: 'AP2 (ap2.datadoghq.com)', id: 'ap2.datadoghq.com' }, + { label: 'UK1 (uk1.datadoghq.com)', id: 'uk1.datadoghq.com' }, + { label: 'US1-FED (ddog-gov.com)', id: 'ddog-gov.com' }, + { label: 'US2-FED (us2.ddog-gov.com)', id: 'us2.ddog-gov.com' }, + ], + value: () => 'datadoghq.com', + mode: 'advanced', + }, + ], + tools: { + access: [ + 'datadog_submit_metrics', + 'datadog_query_timeseries', + 'datadog_create_event', + 'datadog_create_monitor', + 'datadog_get_monitor', + 'datadog_list_monitors', + 'datadog_mute_monitor', + 'datadog_unmute_monitor', + 'datadog_query_logs', + 'datadog_send_logs', + 'datadog_create_downtime', + 'datadog_list_downtimes', + 'datadog_cancel_downtime', + 'datadog_list_incidents', + 'datadog_get_incident', + 'datadog_create_incident', + 'datadog_update_incident', + 'datadog_add_incident_todo', + 'datadog_list_slos', + 'datadog_get_slo', + 'datadog_create_slo', + 'datadog_update_slo', + 'datadog_delete_slo', + 'datadog_get_slo_history', + 'datadog_list_dashboards', + 'datadog_get_dashboard', + 'datadog_create_dashboard', + 'datadog_delete_dashboard', + 'datadog_list_synthetics_tests', + 'datadog_get_synthetics_test', + 'datadog_get_synthetics_results', + 'datadog_get_browser_synthetics_results', + 'datadog_trigger_synthetics_tests', + 'datadog_update_synthetics_status', + 'datadog_list_security_signals', + 'datadog_get_security_signal', + 'datadog_update_security_signal_state', + 'datadog_update_security_signal_assignee', + 'datadog_list_security_rules', + 'datadog_search_spans', + 'datadog_list_services', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const baseParams: { apiKey: string; applicationKey: string; site: string } = { + apiKey: params.apiKey, + applicationKey: params.applicationKey, + site: params.site, + } + + switch (params.operation) { + case 'datadog_submit_metrics': + return { ...baseParams, series: params.series } + + case 'datadog_query_timeseries': return { ...baseParams, query: params.query, @@ -741,16 +2019,32 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, ...baseParams, name: params.listMonitorName || undefined, tags: params.listMonitorTags || undefined, + /** + * `monitorTags` belongs to Create Monitor but serializes for every operation, and + * the block merges these params over the raw inputs. Without an explicit clear, a + * leftover value would filter this list while presenting it as complete. + */ + monitorTags: undefined, + pageSize: datadogPageNumber(params.listMonitorPageSize), + page: datadogPageNumber(params.listMonitorPage), } case 'datadog_mute_monitor': return { ...baseParams, monitorId: params.muteMonitorId, - scope: params.scope, + scope: params.scope || undefined, end: params.end ? Number(params.end) : undefined, } + case 'datadog_unmute_monitor': + return { + ...baseParams, + monitorId: params.muteMonitorId, + scope: params.scope || undefined, + allScopes: toSwitchBoolean(params.unmuteAllScopes), + } + case 'datadog_query_logs': return { ...baseParams, @@ -758,6 +2052,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, from: params.logFrom, to: params.logTo, limit: params.logLimit ? Number(params.logLimit) : undefined, + cursor: params.logCursor || undefined, } case 'datadog_send_logs': @@ -771,14 +2066,268 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, start: params.downtimeStart ? Number(params.downtimeStart) : undefined, end: params.downtimeEnd ? Number(params.downtimeEnd) : undefined, monitorId: params.downtimeMonitorId, + monitorTags: params.downtimeMonitorTags || undefined, + timezone: params.downtimeTimezone || undefined, + muteFirstRecoveryNotification: toSwitchBoolean(params.downtimeMuteFirstRecovery), } case 'datadog_list_downtimes': - return { ...baseParams, currentOnly: params.currentOnly } + return { + ...baseParams, + currentOnly: toSwitchBoolean(params.currentOnly), + limit: params.downtimeLimit ? Number(params.downtimeLimit) : undefined, + offset: params.downtimeOffset ? Number(params.downtimeOffset) : undefined, + } case 'datadog_cancel_downtime': return { ...baseParams, downtimeId: params.downtimeId } + case 'datadog_list_incidents': + return { + ...baseParams, + include: params.incidentInclude || undefined, + pageSize: params.incidentPageSize ? Number(params.incidentPageSize) : undefined, + pageOffset: params.incidentPageOffset ? Number(params.incidentPageOffset) : undefined, + } + + case 'datadog_get_incident': + return { + ...baseParams, + incidentId: params.incidentId, + include: params.incidentInclude || undefined, + } + + case 'datadog_create_incident': + return { + ...baseParams, + title: params.incidentTitle, + customerImpacted: toSwitchBoolean(params.incidentCustomerImpacted) ?? false, + severity: params.incidentSeverity || undefined, + customerImpactScope: params.incidentCustomerImpactScope || undefined, + incidentTypeUuid: params.incidentTypeUuid || undefined, + isTest: toSwitchBoolean(params.incidentIsTest), + fields: params.incidentFields || undefined, + notificationHandles: params.incidentNotificationHandles || undefined, + } + + case 'datadog_update_incident': + return { + ...baseParams, + incidentId: params.incidentId, + title: params.incidentTitle || undefined, + severity: params.incidentSeverity || undefined, + customerImpacted: toSwitchBoolean(params.incidentCustomerImpacted), + customerImpactScope: params.incidentCustomerImpactScope || undefined, + customerImpactStart: params.incidentCustomerImpactStart || undefined, + customerImpactEnd: params.incidentCustomerImpactEnd || undefined, + detected: params.incidentDetected || undefined, + fields: params.incidentFields || undefined, + notificationHandles: params.incidentNotificationHandles || undefined, + } + + case 'datadog_add_incident_todo': + return { + ...baseParams, + incidentId: params.incidentId, + content: params.todoContent, + assignees: params.todoAssignees, + dueDate: params.todoDueDate || undefined, + } + + case 'datadog_list_slos': + return { + ...baseParams, + ids: params.sloIds || undefined, + query: params.sloQuery || undefined, + tagsQuery: params.sloTagsQuery || undefined, + metricsQuery: params.sloMetricsQuery || undefined, + limit: params.sloLimit ? Number(params.sloLimit) : undefined, + offset: params.sloOffset ? Number(params.sloOffset) : undefined, + } + + case 'datadog_get_slo': + return { + ...baseParams, + sloId: params.sloId, + withConfiguredAlertIds: toSwitchBoolean(params.sloWithConfiguredAlertIds), + } + + case 'datadog_create_slo': + return { + ...baseParams, + name: params.sloName, + type: params.sloType, + thresholds: params.sloThresholds, + description: params.sloDescription || undefined, + tags: params.sloTags || undefined, + query: params.sloMetricQuery || undefined, + monitorIds: params.sloMonitorIds || undefined, + groups: params.sloGroups || undefined, + targetThreshold: params.sloTargetThreshold + ? Number(params.sloTargetThreshold) + : undefined, + warningThreshold: params.sloWarningThreshold + ? Number(params.sloWarningThreshold) + : undefined, + timeframe: params.sloTimeframe || undefined, + } + + case 'datadog_update_slo': + return { + ...baseParams, + sloId: params.sloId, + name: params.sloName || undefined, + type: params.sloType || undefined, + thresholds: params.sloThresholds || undefined, + description: params.sloDescription || undefined, + tags: params.sloTags || undefined, + query: params.sloMetricQuery || undefined, + monitorIds: params.sloMonitorIds || undefined, + groups: params.sloGroups || undefined, + targetThreshold: params.sloTargetThreshold + ? Number(params.sloTargetThreshold) + : undefined, + warningThreshold: params.sloWarningThreshold + ? Number(params.sloWarningThreshold) + : undefined, + timeframe: params.sloTimeframe || undefined, + } + + case 'datadog_delete_slo': + return { + ...baseParams, + sloId: params.sloId, + force: toSwitchBoolean(params.sloForce), + } + + case 'datadog_get_slo_history': + return { + ...baseParams, + sloId: params.sloId, + fromTs: params.sloFromTs ? Number(params.sloFromTs) : undefined, + toTs: params.sloToTs ? Number(params.sloToTs) : undefined, + target: params.sloTarget ? Number(params.sloTarget) : undefined, + applyCorrection: toSwitchBoolean(params.sloApplyCorrection), + } + + case 'datadog_list_dashboards': + return { + ...baseParams, + filterShared: toSwitchBoolean(params.dashboardFilterShared), + filterDeleted: toSwitchBoolean(params.dashboardFilterDeleted), + count: params.dashboardCount ? Number(params.dashboardCount) : undefined, + start: params.dashboardStart ? Number(params.dashboardStart) : undefined, + } + + case 'datadog_get_dashboard': + return { ...baseParams, dashboardId: params.dashboardId } + + case 'datadog_create_dashboard': + return { + ...baseParams, + title: params.dashboardTitle, + layoutType: params.dashboardLayoutType, + widgets: params.dashboardWidgets, + description: params.dashboardDescription || undefined, + notifyList: params.dashboardNotifyList || undefined, + templateVariables: params.dashboardTemplateVariables || undefined, + tags: params.dashboardTags || undefined, + reflowType: params.dashboardReflowType || undefined, + } + + case 'datadog_delete_dashboard': + return { ...baseParams, dashboardId: params.dashboardId } + + case 'datadog_list_synthetics_tests': + return { + ...baseParams, + pageSize: params.syntheticsPageSize ? Number(params.syntheticsPageSize) : undefined, + pageNumber: params.syntheticsPageNumber + ? Number(params.syntheticsPageNumber) + : undefined, + } + + case 'datadog_get_synthetics_test': + return { ...baseParams, publicId: params.syntheticsPublicId } + + case 'datadog_get_synthetics_results': + case 'datadog_get_browser_synthetics_results': + return { + ...baseParams, + publicId: params.syntheticsPublicId, + fromTs: params.syntheticsFromTs ? Number(params.syntheticsFromTs) : undefined, + toTs: params.syntheticsToTs ? Number(params.syntheticsToTs) : undefined, + probeDc: params.syntheticsProbeDc || undefined, + } + + case 'datadog_trigger_synthetics_tests': + return { ...baseParams, publicIds: params.syntheticsPublicIds } + + case 'datadog_update_synthetics_status': + return { + ...baseParams, + publicId: params.syntheticsPublicId, + newStatus: params.syntheticsNewStatus, + } + + case 'datadog_list_security_signals': + return { + ...baseParams, + query: params.signalQuery || undefined, + from: params.signalFrom || undefined, + to: params.signalTo || undefined, + sort: params.signalSort || undefined, + cursor: params.signalCursor || undefined, + limit: params.signalLimit ? Number(params.signalLimit) : undefined, + } + + case 'datadog_get_security_signal': + return { ...baseParams, signalId: params.signalId } + + case 'datadog_update_security_signal_state': + return { + ...baseParams, + signalId: params.signalId, + state: params.signalState, + archiveReason: params.signalArchiveReason || undefined, + archiveComment: params.signalArchiveComment || undefined, + } + + case 'datadog_update_security_signal_assignee': + return { + ...baseParams, + signalId: params.signalId, + assigneeUuid: params.signalAssigneeUuid, + } + + case 'datadog_list_security_rules': + return { + ...baseParams, + query: params.ruleQuery || undefined, + sort: params.ruleSort || undefined, + pageSize: params.rulePageSize ? Number(params.rulePageSize) : undefined, + pageNumber: params.rulePageNumber ? Number(params.rulePageNumber) : undefined, + } + + case 'datadog_search_spans': + return { + ...baseParams, + query: params.spanQuery || undefined, + from: params.spanFrom || undefined, + to: params.spanTo || undefined, + sort: params.spanSort || undefined, + cursor: params.spanCursor || undefined, + limit: params.spanLimit ? Number(params.spanLimit) : undefined, + } + + case 'datadog_list_services': + return { + ...baseParams, + pageSize: params.servicePageSize ? Number(params.servicePageSize) : undefined, + pageNumber: params.servicePageNumber ? Number(params.servicePageNumber) : undefined, + schemaVersion: params.serviceSchemaVersion || undefined, + } + default: return baseParams } @@ -810,10 +2359,11 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, monitorPriority: { type: 'number', description: 'Monitor priority (1-5)' }, options: { type: 'json', description: 'Monitor options' }, monitorId: { type: 'string', description: 'Monitor ID' }, - muteMonitorId: { type: 'string', description: 'Monitor ID to mute' }, - scope: { type: 'string', description: 'Scope for muting' }, - end: { type: 'number', description: 'End time for mute' }, // Logs + muteMonitorId: { type: 'string', description: 'Monitor ID to mute or unmute' }, + scope: { type: 'string', description: 'Scope to mute or unmute' }, + end: { type: 'number', description: 'Unix timestamp when the mute ends' }, + unmuteAllScopes: { type: 'boolean', description: 'Clear mute settings for every scope' }, logQuery: { type: 'string', description: 'Log search query' }, logFrom: { type: 'string', description: 'Log start time' }, logTo: { type: 'string', description: 'Log end time' }, @@ -825,28 +2375,171 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, downtimeStart: { type: 'number', description: 'Downtime start time' }, downtimeEnd: { type: 'number', description: 'Downtime end time' }, downtimeMonitorId: { type: 'string', description: 'Monitor ID for downtime' }, + downtimeMonitorTags: { + type: 'string', + description: 'Comma-separated monitor tags to target', + }, + downtimeTimezone: { type: 'string', description: 'Display timezone for the downtime' }, + downtimeLimit: { type: 'number', description: 'Downtimes to return per page' }, + downtimeOffset: { type: 'number', description: 'Index of the first downtime to return' }, + downtimeMuteFirstRecovery: { + type: 'boolean', + description: 'Mute the first recovery notification', + }, currentOnly: { type: 'boolean', description: 'Filter to current downtimes' }, downtimeId: { type: 'string', description: 'Downtime ID to cancel' }, listMonitorName: { type: 'string', description: 'Filter monitors by name' }, listMonitorTags: { type: 'string', description: 'Filter monitors by tags' }, + listMonitorPageSize: { type: 'number', description: 'Monitors to return per page' }, + listMonitorPage: { type: 'number', description: 'Monitor page number (0-indexed)' }, + // Incidents + incidentId: { type: 'string', description: 'Incident UUID' }, + incidentTitle: { type: 'string', description: 'Incident title' }, + incidentCustomerImpacted: { type: 'boolean', description: 'Whether customers were impacted' }, + incidentSeverity: { type: 'string', description: 'Incident severity' }, + incidentCustomerImpactScope: { type: 'string', description: 'Summary of the customer impact' }, + incidentCustomerImpactStart: { type: 'string', description: 'Customer impact start timestamp' }, + incidentCustomerImpactEnd: { type: 'string', description: 'Customer impact end timestamp' }, + incidentDetected: { type: 'string', description: 'Detection timestamp' }, + incidentTypeUuid: { type: 'string', description: 'Incident type UUID' }, + incidentIsTest: { type: 'boolean', description: 'Whether the incident is a test' }, + incidentFields: { type: 'json', description: 'User-defined incident fields' }, + incidentNotificationHandles: { type: 'string', description: 'Handles to notify' }, + incidentInclude: { type: 'string', description: 'Related resources to include' }, + incidentPageSize: { type: 'number', description: 'Incidents per page' }, + incidentPageOffset: { type: 'number', description: 'Incident page offset' }, + todoContent: { type: 'string', description: 'Follow-up task content' }, + todoAssignees: { type: 'string', description: 'Follow-up task assignees' }, + todoDueDate: { type: 'string', description: 'Follow-up task due date' }, + // SLOs + sloId: { type: 'string', description: 'SLO ID' }, + sloName: { type: 'string', description: 'SLO name' }, + sloType: { type: 'string', description: 'SLO type' }, + sloThresholds: { type: 'json', description: 'SLO thresholds' }, + sloDescription: { type: 'string', description: 'SLO description' }, + sloTags: { type: 'string', description: 'SLO tags' }, + sloMetricQuery: { type: 'json', description: 'Metric SLO numerator and denominator' }, + sloMonitorIds: { type: 'string', description: 'Monitor IDs for monitor SLOs' }, + sloGroups: { type: 'string', description: 'Monitor groups for monitor SLOs' }, + sloTargetThreshold: { type: 'number', description: 'Primary target threshold' }, + sloWarningThreshold: { type: 'number', description: 'Primary warning threshold' }, + sloTimeframe: { type: 'string', description: 'Primary timeframe' }, + sloIds: { type: 'string', description: 'Filter SLOs by IDs' }, + sloQuery: { type: 'string', description: 'Filter SLOs by name' }, + sloTagsQuery: { type: 'string', description: 'Filter SLOs by tag' }, + sloMetricsQuery: { type: 'string', description: 'Filter SLOs by metrics query' }, + sloLimit: { type: 'number', description: 'Max SLOs to return' }, + sloOffset: { type: 'number', description: 'SLO list offset' }, + sloWithConfiguredAlertIds: { type: 'boolean', description: 'Include SLO monitor IDs' }, + sloForce: { type: 'boolean', description: 'Force SLO deletion' }, + sloFromTs: { type: 'number', description: 'SLO history window start (Unix seconds)' }, + sloToTs: { type: 'number', description: 'SLO history window end (Unix seconds)' }, + sloTarget: { type: 'number', description: 'SLO target for history queries' }, + sloApplyCorrection: { type: 'boolean', description: 'Apply SLO corrections' }, + // Dashboards + dashboardId: { type: 'string', description: 'Dashboard ID' }, + dashboardTitle: { type: 'string', description: 'Dashboard title' }, + dashboardLayoutType: { type: 'string', description: 'Dashboard layout type' }, + dashboardWidgets: { type: 'json', description: 'Dashboard widget definitions' }, + dashboardDescription: { type: 'string', description: 'Dashboard description' }, + dashboardTags: { type: 'string', description: 'Dashboard tags' }, + dashboardNotifyList: { type: 'string', description: 'Handles notified on changes' }, + dashboardTemplateVariables: { type: 'json', description: 'Template variable definitions' }, + dashboardReflowType: { type: 'string', description: 'Dashboard reflow type' }, + dashboardFilterShared: { type: 'boolean', description: 'Return only shared dashboards' }, + dashboardFilterDeleted: { type: 'boolean', description: 'Return only deleted dashboards' }, + dashboardCount: { type: 'number', description: 'Max dashboards to return' }, + dashboardStart: { type: 'number', description: 'Dashboard list offset' }, + // Synthetics + syntheticsPublicId: { type: 'string', description: 'Synthetic test public ID' }, + syntheticsPublicIds: { type: 'string', description: 'Synthetic test public IDs to trigger' }, + syntheticsNewStatus: { type: 'string', description: 'New Synthetic test status' }, + syntheticsFromTs: { type: 'number', description: 'Results window start (Unix milliseconds)' }, + syntheticsToTs: { type: 'number', description: 'Results window end (Unix milliseconds)' }, + syntheticsProbeDc: { type: 'string', description: 'Locations to query results for' }, + syntheticsPageSize: { type: 'number', description: 'Synthetic tests per page' }, + syntheticsPageNumber: { type: 'number', description: 'Synthetic tests page number' }, + // Security monitoring + signalId: { type: 'string', description: 'Security signal ID' }, + signalState: { type: 'string', description: 'Security signal triage state' }, + signalArchiveReason: { type: 'string', description: 'Archive reason' }, + signalArchiveComment: { type: 'string', description: 'Archive comment' }, + signalAssigneeUuid: { type: 'string', description: 'UUID of the assignee' }, + signalQuery: { type: 'string', description: 'Security signal search query' }, + signalFrom: { type: 'string', description: 'Signal search window start' }, + signalTo: { type: 'string', description: 'Signal search window end' }, + signalSort: { type: 'string', description: 'Signal sort order' }, + signalLimit: { type: 'number', description: 'Max signals to return' }, + signalCursor: { type: 'string', description: 'Signal pagination cursor' }, + ruleQuery: { type: 'string', description: 'Detection rule search query' }, + ruleSort: { type: 'string', description: 'Detection rule sort order' }, + rulePageSize: { type: 'number', description: 'Detection rules per page' }, + rulePageNumber: { type: 'number', description: 'Detection rules page number' }, + // APM + spanQuery: { type: 'string', description: 'Span search query' }, + spanFrom: { type: 'string', description: 'Span search window start' }, + spanTo: { type: 'string', description: 'Span search window end' }, + spanSort: { type: 'string', description: 'Span sort order' }, + spanLimit: { type: 'number', description: 'Max spans to return' }, + spanCursor: { type: 'string', description: 'Span pagination cursor' }, + logCursor: { type: 'string', description: 'Log search pagination cursor' }, + servicePageSize: { type: 'number', description: 'Service definitions per page' }, + servicePageNumber: { type: 'number', description: 'Service definitions page number' }, + serviceSchemaVersion: { type: 'string', description: 'Service definition schema version' }, }, outputs: { success: { type: 'boolean', description: 'Whether the operation succeeded' }, // Metrics series: { type: 'json', description: 'Timeseries data' }, status: { type: 'string', description: 'Query status' }, + errors: { type: 'json', description: 'Metric series rejected during submission' }, // Events event: { type: 'json', description: 'Event data' }, - events: { type: 'json', description: 'List of events' }, // Monitors monitor: { type: 'json', description: 'Monitor data' }, monitors: { type: 'json', description: 'List of monitors' }, + monitorId: { type: 'number', description: 'ID of the muted or unmuted monitor' }, + name: { type: 'string', description: 'Name of the muted or unmuted monitor' }, + overallState: { type: 'string', description: 'Monitor state after muting or unmuting' }, // Logs logs: { type: 'json', description: 'Log entries' }, nextLogId: { type: 'string', description: 'Pagination cursor for logs' }, // Downtimes downtime: { type: 'json', description: 'Downtime data' }, downtimes: { type: 'json', description: 'List of downtimes' }, + totalCount: { type: 'number', description: 'Total downtimes matching the filter' }, + // Incidents + incident: { type: 'json', description: 'Incident data' }, + incidents: { type: 'json', description: 'List of incidents' }, + nextOffset: { type: 'number', description: 'Offset for the next page of incidents' }, + todo: { type: 'json', description: 'Incident follow-up task' }, + // SLOs + slo: { type: 'json', description: 'Service level objective' }, + slos: { type: 'json', description: 'List of service level objectives' }, + history: { type: 'json', description: 'SLO history for the requested window' }, + sliValue: { type: 'number', description: 'Overall SLI value over the window' }, + deletedIds: { type: 'json', description: 'IDs of deleted service level objectives' }, + // Dashboards + dashboard: { type: 'json', description: 'Dashboard definition' }, + dashboards: { type: 'json', description: 'List of dashboard summaries' }, + deletedDashboardId: { type: 'string', description: 'ID of the deleted dashboard' }, + // Synthetics + test: { type: 'json', description: 'Synthetic test configuration' }, + tests: { type: 'json', description: 'List of Synthetic tests' }, + results: { type: 'json', description: 'Synthetic test run results' }, + lastTimestampFetched: { type: 'number', description: 'Timestamp of the latest test run' }, + batchId: { type: 'string', description: 'Public ID of the triggered batch' }, + triggeredCheckIds: { type: 'json', description: 'Public IDs of triggered tests' }, + locations: { type: 'json', description: 'Locations the tests ran from' }, + // Security monitoring + signal: { type: 'json', description: 'Security signal' }, + signals: { type: 'json', description: 'List of security signals' }, + rules: { type: 'json', description: 'List of detection rules' }, + nextCursor: { type: 'string', description: 'Cursor for the next page of results' }, + // APM + spans: { type: 'json', description: 'List of APM spans' }, + elapsed: { type: 'number', description: 'Query time in milliseconds' }, + services: { type: 'json', description: 'List of service definitions' }, }, } diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 5fb3d01153b..a9d7d9b93fa 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -446,6 +446,7 @@ export const DiscordBlock: BlockConfig = { id: 'webhookToken', title: 'Webhook Token', type: 'short-input', + password: true, placeholder: 'Enter webhook token', required: true, condition: { diff --git a/apps/sim/blocks/blocks/microsoft_ad.test.ts b/apps/sim/blocks/blocks/microsoft_ad.test.ts new file mode 100644 index 00000000000..efe7ef81e01 --- /dev/null +++ b/apps/sim/blocks/blocks/microsoft_ad.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { MicrosoftAdBlock } from '@/blocks/blocks/microsoft_ad' + +/** + * The tri-state assertions run against `{ ...inputs, ...buildParams(inputs) }`, the shape the + * generic tool handler actually forwards. A key the mapper merely omits is *not* dropped by that + * merge — the raw subBlock string survives — so asserting on the mapper's return alone would + * pass against the broken code. + */ +describe('MicrosoftAdBlock', () => { + const buildParams = MicrosoftAdBlock.tools.config.params! + + const subBlock = (id: string) => + MicrosoftAdBlock.subBlocks.find((candidate) => candidate.id === id)! + + describe('accountEnabled tri-state', () => { + it('clears the "No Change" default instead of forwarding an empty string', () => { + const inputs = { operation: 'update_user', userId: 'user-1', accountEnabled: '' } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.accountEnabled).toBeUndefined() + }) + + it('is the serialized default, so the empty case is the common case', () => { + const accountEnabled = subBlock('accountEnabled') + + expect(accountEnabled.value?.()).toBe('') + expect(accountEnabled.mode).toBeUndefined() + }) + + it('coerces an explicit update_user choice to a boolean', () => { + const enabled = { operation: 'update_user', userId: 'user-1', accountEnabled: 'true' } + const disabled = { operation: 'update_user', userId: 'user-1', accountEnabled: 'false' } + + expect({ ...enabled, ...buildParams(enabled) }.accountEnabled).toBe(true) + expect({ ...disabled, ...buildParams(disabled) }.accountEnabled).toBe(false) + }) + + it('coerces the create_user choice to a boolean and clears the update-side value', () => { + const inputs = { + operation: 'create_user', + displayName: 'Ada', + accountEnabled: '', + accountEnabledCreate: 'false', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.accountEnabled).toBe(false) + }) + }) + + describe('visibility tri-state', () => { + it('clears the "No Change" default instead of forwarding an empty string', () => { + const inputs = { operation: 'update_group', groupId: 'group-1', visibility: '' } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.visibility).toBeUndefined() + }) + + it('passes an explicit visibility through on both operations', () => { + const update = { operation: 'update_group', groupId: 'group-1', visibility: 'Public' } + const create = { + operation: 'create_group', + visibility: '', + visibilityCreate: 'HiddenMembership', + } + + expect({ ...update, ...buildParams(update) }.visibility).toBe('Public') + expect({ ...create, ...buildParams(create) }.visibility).toBe('HiddenMembership') + }) + }) + + describe('top coercion', () => { + it('never forwards NaN for a non-numeric page size', () => { + const inputs = { operation: 'list_users', top: 'all' } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.top).toBeUndefined() + }) + + it('coerces a numeric page size', () => { + const inputs = { operation: 'list_users', top: '100' } + + expect({ ...inputs, ...buildParams(inputs) }.top).toBe(100) + }) + }) + + describe('groupId requirement', () => { + it('requires a Group ID for list_group_members on the first page', () => { + const required = subBlock('groupId').required as (values?: Record) => { + field: string + value: string[] + } + + expect(required({}).value).toContain('list_group_members') + }) + + it('drops the requirement only while continuing from a nextLink', () => { + const required = subBlock('groupId').required as (values?: Record) => { + field: string + value: string[] + } + const { value } = required({ nextLink: 'https://graph.microsoft.com/v1.0/groups/g/members' }) + + expect(value).not.toContain('list_group_members') + expect(value).toEqual( + expect.arrayContaining([ + 'get_group', + 'update_group', + 'delete_group', + 'add_group_member', + 'remove_group_member', + ]) + ) + }) + }) + + /** + * `User.ReadWrite.All` is listed on every `/users` read this block performs — list, get, + * licenseDetails, registeredDevices, and ownedDevices — so `User.Read.All` was pure consent + * noise. `Directory.Read.All` and `GroupMember.ReadWrite.All` are deliberately retained: + * `GET /subscribedSkus` names neither `LicenseAssignment.ReadWrite.All` nor any scope left in + * this list, and `POST /groups/{id}/members/$ref` accepts `GroupMember.ReadWrite.All` only. + */ + describe('requested OAuth scopes', () => { + const requiredScopes = + MicrosoftAdBlock.subBlocks.find((candidate) => candidate.id === 'credential') + ?.requiredScopes ?? [] + + it('does not request User.Read.All, which User.ReadWrite.All subsumes', () => { + expect(requiredScopes).toContain('User.ReadWrite.All') + expect(requiredScopes).not.toContain('User.Read.All') + }) + + it('keeps the scopes no retained scope covers', () => { + expect(requiredScopes).toEqual( + expect.arrayContaining(['Directory.Read.All', 'GroupMember.ReadWrite.All']) + ) + }) + }) +}) diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts index 432b62fe9ae..7769ca84f3b 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.ts @@ -4,12 +4,132 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { MicrosoftAdResponse } from '@/tools/microsoft_ad/types' +/** Operations that act on a single user and therefore require the User ID field. */ +const USER_ID_OPERATIONS = [ + 'get_user', + 'update_user', + 'delete_user', + 'assign_license', + 'list_user_licenses', + 'revoke_sign_in_sessions', + 'set_password', + 'reset_password', + 'list_authentication_methods', + 'list_user_app_role_assignments', + 'add_user_app_role_assignment', + 'remove_user_app_role_assignment', + 'list_user_devices', +] + +/** + * Per-user operations that page through an `@odata.nextLink`. The continuation URL already + * addresses the user, so these are the only per-user operations that can run without a User ID, + * and only when continuing from a previous page. + */ +const PAGED_USER_ID_OPERATIONS = ['list_user_app_role_assignments', 'list_user_devices'] + +/** Per-user operations that always require a User ID, whichever page is being fetched. */ +const ALWAYS_USER_ID_OPERATIONS = USER_ID_OPERATIONS.filter( + (operation) => !PAGED_USER_ID_OPERATIONS.includes(operation) +) + +/** Operations that act on a single group and therefore require the Group ID field. */ +const GROUP_ID_OPERATIONS = [ + 'get_group', + 'update_group', + 'delete_group', + 'list_group_members', + 'add_group_member', + 'remove_group_member', +] + +/** + * Group operations that page through an `@odata.nextLink`. The continuation URL already + * addresses the group, so these are the only group operations that can run without a Group ID, + * and only when continuing from a previous page. + */ +const PAGED_GROUP_ID_OPERATIONS = ['list_group_members'] + +/** Group operations that always require a Group ID, whichever page is being fetched. */ +const ALWAYS_GROUP_ID_OPERATIONS = GROUP_ID_OPERATIONS.filter( + (operation) => !PAGED_GROUP_ID_OPERATIONS.includes(operation) +) + +/** Collection operations that accept a page size and an @odata.nextLink continuation URL. */ +const PAGED_OPERATIONS = [ + 'list_users', + 'list_groups', + 'list_group_members', + 'list_sign_ins', + 'list_directory_audits', + 'list_user_app_role_assignments', + 'list_service_principals', + 'list_devices', + 'list_user_devices', + 'list_conditional_access_policies', +] + +/** + * Operations that return an `@odata.nextLink` continuation URL. This is a superset of + * `PAGED_OPERATIONS`: `appRoleAssignedTo` pages through `@odata.nextLink` but the tool does not + * expose a `$top` page size. + */ +const NEXT_LINK_OPERATIONS = [...PAGED_OPERATIONS, 'list_service_principal_app_role_assignments'] + +/** Operations that own the shared `filter` and `search` fields. */ +const SHARED_FILTER_OPERATIONS = ['list_users', 'list_groups'] + +/** + * The subBlock each operation reads its OData `$filter` from. + * + * A subBlock keeps its value after the operation changes, so the filter is resolved by + * ownership rather than by letting later assignments overwrite earlier ones. Otherwise a clause + * written for `/users` would still be sent when the block is switched to `/auditLogs/signIns` or + * `/devices`, where it is invalid. + * + * The mapper must write `filter` and `search` on every operation, including as `undefined`. The + * executor merges `{ ...inputs, ...transformedParams }`, so a key that is merely omitted here + * leaves the stale serialized value in place rather than clearing it. The same applies to every + * optional field the mapper coerces out of a subBlock string, such as + * `forceChangePasswordNextSignInWithMfa`: omitting the key on "No Change" would forward the raw + * empty string to Graph in place of a boolean. + */ +const FILTER_FIELD_BY_OPERATION: Record = { + list_users: 'filter', + list_groups: 'filter', + list_sign_ins: 'signInFilter', + list_directory_audits: 'auditFilter', + list_user_app_role_assignments: 'appRoleFilter', + list_service_principal_app_role_assignments: 'appRoleFilter', + list_service_principals: 'servicePrincipalFilter', + list_devices: 'deviceFilter', + list_conditional_access_policies: 'policyFilter', +} + +/** The subBlock each operation reads its `$search` term from. */ +const SEARCH_FIELD_BY_OPERATION: Record = { + list_users: 'search', + list_groups: 'search', + list_service_principals: 'servicePrincipalSearch', + list_devices: 'deviceSearch', +} + +/** Operations that act on a single device object. */ +const DEVICE_ID_OPERATIONS = ['get_device'] + +/** Operations that act on a single directory role. */ +const DIRECTORY_ROLE_OPERATIONS = [ + 'list_directory_role_members', + 'add_directory_role_member', + 'remove_directory_role_member', +] + export const MicrosoftAdBlock: BlockConfig = { type: 'microsoft_ad', name: 'Azure AD', - description: 'Manage users and groups in Azure AD (Microsoft Entra ID)', + description: 'Manage identities, licenses, roles, and access in Azure AD (Microsoft Entra ID)', longDescription: - 'Integrate Azure Active Directory into your workflows. List, create, update, and delete users and groups. Manage group memberships programmatically.', + 'Integrate Azure Active Directory into your workflows. Create, update, and delete users and groups, manage group memberships, assign and remove licenses, reset passwords, revoke sign-in sessions, read sign-in and directory audit logs, grant and revoke app and directory roles, and read registered devices and conditional access policies. Device writes are not supported.', docsLink: 'https://docs.sim.ai/integrations/microsoft_ad', category: 'tools', integrationType: IntegrationType.Security, @@ -58,6 +178,63 @@ export const MicrosoftAdBlock: BlockConfig = { { text: 'Remove member', field: 'memberId', core: true }, { text: 'from group', field: 'groupId', core: true }, ], + assign_license: [ + { text: 'Change licenses for', field: 'userId', core: true }, + { text: ', adding', field: 'addSkuIds' }, + { text: ', removing', field: 'removeSkuIds' }, + ], + list_user_licenses: [{ text: 'List licenses for user', field: 'userId', core: true }], + list_subscribed_skus: ['List tenant subscription SKUs'], + revoke_sign_in_sessions: [ + { text: 'Revoke sign-in sessions for', field: 'userId', core: true }, + ], + set_password: [{ text: 'Set the password for user', field: 'userId', core: true }], + reset_password: [{ text: 'Reset the password for user', field: 'userId', core: true }], + list_authentication_methods: [ + { text: 'List authentication methods for user', field: 'userId', core: true }, + ], + list_sign_ins: ['List sign-ins', { text: ', where', field: 'signInFilter' }], + list_directory_audits: ['List directory audits', { text: ', where', field: 'auditFilter' }], + list_user_app_role_assignments: [ + { text: 'List app role assignments for user', field: 'userId', core: true }, + ], + add_user_app_role_assignment: [ + { text: 'Grant app role', field: 'appRoleId', core: true }, + { text: 'to user', field: 'userId', core: true }, + ], + remove_user_app_role_assignment: [ + { text: 'Revoke app role assignment', field: 'appRoleAssignmentId', core: true }, + { text: 'from user', field: 'userId', core: true }, + ], + list_service_principals: [ + 'List service principals', + { text: ', matching', field: 'servicePrincipalSearch' }, + ], + list_service_principal_app_role_assignments: [ + { text: 'List assignments for application', field: 'servicePrincipalId', core: true }, + ], + list_directory_roles: ['List directory roles'], + list_directory_role_members: [ + { text: 'List members of directory role', field: 'directoryRoleId', core: true }, + ], + add_directory_role_member: [ + { text: 'Grant directory role', field: 'directoryRoleId', core: true }, + { text: 'to', field: 'memberId', core: true }, + ], + remove_directory_role_member: [ + { text: 'Revoke directory role', field: 'directoryRoleId', core: true }, + { text: 'from', field: 'memberId', core: true }, + ], + list_devices: ['List devices', { text: ', matching', field: 'deviceSearch' }], + get_device: [{ text: 'Fetch device', field: 'deviceObjectId', core: true }], + list_user_devices: [ + { text: 'List devices for user', field: 'userId', core: true }, + { text: ', linked as', field: 'deviceRelationship' }, + ], + list_conditional_access_policies: ['List conditional access policies'], + get_conditional_access_policy: [ + { text: 'Fetch conditional access policy', field: 'policyId', core: true }, + ], }, }, }, @@ -81,6 +258,35 @@ export const MicrosoftAdBlock: BlockConfig = { { label: 'List Group Members', id: 'list_group_members' }, { label: 'Add Group Member', id: 'add_group_member' }, { label: 'Remove Group Member', id: 'remove_group_member' }, + { label: 'Assign License', id: 'assign_license' }, + { label: 'List User Licenses', id: 'list_user_licenses' }, + { label: 'List Subscribed SKUs', id: 'list_subscribed_skus' }, + { label: 'Revoke Sign-In Sessions', id: 'revoke_sign_in_sessions' }, + { label: 'Set Password', id: 'set_password' }, + { label: 'Reset Password', id: 'reset_password' }, + { label: 'List Authentication Methods', id: 'list_authentication_methods' }, + { label: 'List Sign-Ins', id: 'list_sign_ins' }, + { label: 'List Directory Audits', id: 'list_directory_audits' }, + { label: 'List User App Role Assignments', id: 'list_user_app_role_assignments' }, + { label: 'Grant App Role To User', id: 'add_user_app_role_assignment' }, + { label: 'Revoke App Role From User', id: 'remove_user_app_role_assignment' }, + { label: 'List Service Principals', id: 'list_service_principals' }, + { + label: 'List Application Assignments', + id: 'list_service_principal_app_role_assignments', + }, + { label: 'List Directory Roles', id: 'list_directory_roles' }, + { label: 'List Directory Role Members', id: 'list_directory_role_members' }, + { label: 'Add Directory Role Member', id: 'add_directory_role_member' }, + { label: 'Remove Directory Role Member', id: 'remove_directory_role_member' }, + { label: 'List Devices', id: 'list_devices' }, + { label: 'Get Device', id: 'get_device' }, + { label: 'List User Devices', id: 'list_user_devices' }, + { + label: 'List Conditional Access Policies', + id: 'list_conditional_access_policies', + }, + { label: 'Get Conditional Access Policy', id: 'get_conditional_access_policy' }, ], value: () => 'list_users', }, @@ -98,8 +304,11 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'User ID', type: 'short-input', placeholder: 'User ID or user principal name (e.g., user@example.com)', - condition: { field: 'operation', value: ['get_user', 'update_user', 'delete_user'] }, - required: { field: 'operation', value: ['get_user', 'update_user', 'delete_user'] }, + condition: { field: 'operation', value: USER_ID_OPERATIONS }, + required: (values) => + values?.nextLink + ? { field: 'operation', value: ALWAYS_USER_ID_OPERATIONS } + : { field: 'operation', value: USER_ID_OPERATIONS }, }, // Create user fields { @@ -131,8 +340,8 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'Password', type: 'short-input', placeholder: 'Initial password', - condition: { field: 'operation', value: 'create_user' }, - required: { field: 'operation', value: 'create_user' }, + condition: { field: 'operation', value: ['create_user', 'set_password'] }, + required: { field: 'operation', value: ['create_user', 'set_password'] }, password: true, }, { @@ -213,10 +422,7 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'Max Results', type: 'short-input', placeholder: 'e.g., 100 (max 999)', - condition: { - field: 'operation', - value: ['list_users', 'list_groups', 'list_group_members'], - }, + condition: { field: 'operation', value: PAGED_OPERATIONS }, mode: 'advanced', }, { @@ -224,7 +430,7 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'Filter', type: 'short-input', placeholder: "e.g., department eq 'Sales'", - condition: { field: 'operation', value: ['list_users', 'list_groups'] }, + condition: { field: 'operation', value: SHARED_FILTER_OPERATIONS }, mode: 'advanced', }, { @@ -232,7 +438,7 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'Search', type: 'short-input', placeholder: 'Search by name or email', - condition: { field: 'operation', value: ['list_users', 'list_groups'] }, + condition: { field: 'operation', value: SHARED_FILTER_OPERATIONS }, mode: 'advanced', }, { @@ -240,10 +446,7 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'Next Page', type: 'short-input', placeholder: "Paste the previous response's nextLink to fetch the next page", - condition: { - field: 'operation', - value: ['list_users', 'list_groups', 'list_group_members'], - }, + condition: { field: 'operation', value: NEXT_LINK_OPERATIONS }, mode: 'advanced', }, // Group ID field @@ -252,27 +455,16 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'Group ID', type: 'short-input', placeholder: 'Group ID (GUID)', - condition: { - field: 'operation', - value: [ - 'get_group', - 'update_group', - 'delete_group', - 'list_group_members', - 'add_group_member', - 'remove_group_member', - ], - }, - required: { - field: 'operation', - value: [ - 'get_group', - 'update_group', - 'delete_group', - 'add_group_member', - 'remove_group_member', - ], - }, + condition: { field: 'operation', value: GROUP_ID_OPERATIONS }, + /** + * `list_group_members` pages through an `@odata.nextLink` that already addresses the + * group, so it is the only member of this set that can run without a Group ID, and only + * when continuing from a previous page. + */ + required: (values) => + values?.nextLink + ? { field: 'operation', value: ALWAYS_GROUP_ID_OPERATIONS } + : { field: 'operation', value: GROUP_ID_OPERATIONS }, }, // Create group fields { @@ -364,8 +556,233 @@ export const MicrosoftAdBlock: BlockConfig = { title: 'Member ID', type: 'short-input', placeholder: 'User ID to add or remove', - condition: { field: 'operation', value: ['add_group_member', 'remove_group_member'] }, - required: { field: 'operation', value: ['add_group_member', 'remove_group_member'] }, + condition: { + field: 'operation', + value: [ + 'add_group_member', + 'remove_group_member', + 'add_directory_role_member', + 'remove_directory_role_member', + ], + }, + required: { + field: 'operation', + value: [ + 'add_group_member', + 'remove_group_member', + 'add_directory_role_member', + 'remove_directory_role_member', + ], + }, + }, + { + id: 'addSkuIds', + title: 'Licenses To Add', + type: 'short-input', + placeholder: 'Comma-separated SKU IDs (GUIDs)', + condition: { field: 'operation', value: 'assign_license' }, + }, + { + id: 'removeSkuIds', + title: 'Licenses To Remove', + type: 'short-input', + placeholder: 'Comma-separated SKU IDs (GUIDs)', + condition: { field: 'operation', value: 'assign_license' }, + }, + { + id: 'disabledPlanIds', + title: 'Disabled Service Plans', + type: 'short-input', + placeholder: 'Comma-separated service plan IDs to disable on the added licenses', + condition: { field: 'operation', value: 'assign_license' }, + mode: 'advanced', + }, + { + id: 'forceChangePasswordNextSignIn', + title: 'Force Password Change At Next Sign-In', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'set_password' }, + }, + { + id: 'forceChangePasswordNextSignInWithMfa', + title: 'Require MFA Before Password Change', + type: 'dropdown', + options: [ + { label: 'No Change', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'set_password' }, + mode: 'advanced', + }, + { + id: 'newPassword', + title: 'New Password', + type: 'short-input', + placeholder: 'Leave empty to have Microsoft generate and return one', + condition: { field: 'operation', value: 'reset_password' }, + password: true, + }, + { + id: 'signInFilter', + title: 'Filter', + type: 'short-input', + placeholder: 'e.g., createdDateTime ge 2024-01-01T00:00:00Z and status/errorCode ne 0', + condition: { field: 'operation', value: 'list_sign_ins' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a Microsoft Graph OData $filter expression for the auditLogs/signIns endpoint. Filterable fields include userPrincipalName, userId, appId, appDisplayName, ipAddress, createdDateTime, conditionalAccessStatus, riskState and status/errorCode. Return ONLY the filter expression.', + generationType: 'timestamp', + }, + }, + { + id: 'auditFilter', + title: 'Filter', + type: 'short-input', + placeholder: 'e.g., activityDateTime ge 2024-01-01T00:00:00Z', + condition: { field: 'operation', value: 'list_directory_audits' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a Microsoft Graph OData $filter expression for the auditLogs/directoryAudits endpoint. Filterable fields include activityDateTime, activityDisplayName, correlationId, loggedByService, initiatedBy and targetResources. Return ONLY the filter expression.', + generationType: 'timestamp', + }, + }, + { + id: 'appRoleFilter', + title: 'Filter', + type: 'short-input', + placeholder: "e.g., resourceId eq '8e881353-1735-45af-af21-ee1344582a4d'", + condition: { + field: 'operation', + value: ['list_user_app_role_assignments', 'list_service_principal_app_role_assignments'], + }, + mode: 'advanced', + }, + { + id: 'resourceId', + title: 'Service Principal ID', + type: 'short-input', + placeholder: 'Object ID of the service principal that defines the app role', + condition: { field: 'operation', value: 'add_user_app_role_assignment' }, + required: { field: 'operation', value: 'add_user_app_role_assignment' }, + }, + { + id: 'appRoleId', + title: 'App Role ID', + type: 'short-input', + placeholder: 'App role GUID, or 00000000-0000-0000-0000-000000000000 for no specific role', + condition: { field: 'operation', value: 'add_user_app_role_assignment' }, + required: { field: 'operation', value: 'add_user_app_role_assignment' }, + }, + { + id: 'appRoleAssignmentId', + title: 'App Role Assignment ID', + type: 'short-input', + placeholder: 'ID of the assignment to revoke', + condition: { field: 'operation', value: 'remove_user_app_role_assignment' }, + required: { field: 'operation', value: 'remove_user_app_role_assignment' }, + }, + { + id: 'servicePrincipalId', + title: 'Service Principal ID', + type: 'short-input', + placeholder: 'Object ID of the service principal', + condition: { + field: 'operation', + value: 'list_service_principal_app_role_assignments', + }, + /** + * The continuation URL already addresses the service principal, so the ID is only + * required for the first page. An empty operation list matches nothing, which is how + * the function form of `required` expresses "not required". + */ + required: (values) => + values?.nextLink + ? { field: 'operation', value: [] } + : { field: 'operation', value: 'list_service_principal_app_role_assignments' }, + }, + { + id: 'servicePrincipalSearch', + title: 'Search', + type: 'short-input', + placeholder: 'Search by application name', + condition: { field: 'operation', value: 'list_service_principals' }, + mode: 'advanced', + }, + { + id: 'servicePrincipalFilter', + title: 'Filter', + type: 'short-input', + placeholder: "e.g., servicePrincipalType eq 'Application'", + condition: { field: 'operation', value: 'list_service_principals' }, + mode: 'advanced', + }, + { + id: 'directoryRoleId', + title: 'Directory Role ID', + type: 'short-input', + placeholder: 'Object ID of the directory role', + condition: { field: 'operation', value: DIRECTORY_ROLE_OPERATIONS }, + required: { field: 'operation', value: DIRECTORY_ROLE_OPERATIONS }, + }, + { + id: 'deviceObjectId', + title: 'Device Object ID', + type: 'short-input', + placeholder: 'Device object ID (not the deviceId registration identifier)', + condition: { field: 'operation', value: DEVICE_ID_OPERATIONS }, + required: { field: 'operation', value: DEVICE_ID_OPERATIONS }, + }, + { + id: 'deviceSearch', + title: 'Search', + type: 'short-input', + placeholder: 'Search by device name', + condition: { field: 'operation', value: 'list_devices' }, + mode: 'advanced', + }, + { + id: 'deviceFilter', + title: 'Filter', + type: 'short-input', + placeholder: 'e.g., accountEnabled eq false', + condition: { field: 'operation', value: 'list_devices' }, + mode: 'advanced', + }, + { + id: 'deviceRelationship', + title: 'Device Link', + type: 'dropdown', + options: [ + { label: 'Registered By User', id: 'registered' }, + { label: 'Owned By User', id: 'owned' }, + ], + value: () => 'registered', + condition: { field: 'operation', value: 'list_user_devices' }, + }, + { + id: 'policyId', + title: 'Policy ID', + type: 'short-input', + placeholder: 'Conditional access policy ID', + condition: { field: 'operation', value: 'get_conditional_access_policy' }, + required: { field: 'operation', value: 'get_conditional_access_policy' }, + }, + { + id: 'policyFilter', + title: 'Filter', + type: 'short-input', + placeholder: "e.g., state eq 'enabled'", + condition: { field: 'operation', value: 'list_conditional_access_policies' }, + mode: 'advanced', }, ], tools: { @@ -383,20 +800,54 @@ export const MicrosoftAdBlock: BlockConfig = { 'microsoft_ad_list_group_members', 'microsoft_ad_add_group_member', 'microsoft_ad_remove_group_member', + 'microsoft_ad_assign_license', + 'microsoft_ad_list_user_licenses', + 'microsoft_ad_list_subscribed_skus', + 'microsoft_ad_revoke_sign_in_sessions', + 'microsoft_ad_set_password', + 'microsoft_ad_reset_password', + 'microsoft_ad_list_authentication_methods', + 'microsoft_ad_list_sign_ins', + 'microsoft_ad_list_directory_audits', + 'microsoft_ad_list_user_app_role_assignments', + 'microsoft_ad_add_user_app_role_assignment', + 'microsoft_ad_remove_user_app_role_assignment', + 'microsoft_ad_list_service_principals', + 'microsoft_ad_list_service_principal_app_role_assignments', + 'microsoft_ad_list_directory_roles', + 'microsoft_ad_list_directory_role_members', + 'microsoft_ad_add_directory_role_member', + 'microsoft_ad_remove_directory_role_member', + 'microsoft_ad_list_devices', + 'microsoft_ad_get_device', + 'microsoft_ad_list_user_devices', + 'microsoft_ad_list_conditional_access_policies', + 'microsoft_ad_get_conditional_access_policy', ], config: { tool: (params) => `microsoft_ad_${params.operation}`, params: (params) => { const result: Record = {} - if (params.top) result.top = Number(params.top) - if (params.filter) result.filter = params.filter - if (params.search) result.search = params.search + const top = Number(params.top) + result.top = params.top && Number.isFinite(top) ? top : undefined if (params.nextLink) result.nextLink = params.nextLink + const values = params as Record + result.filter = values[FILTER_FIELD_BY_OPERATION[params.operation]] || undefined + result.search = values[SEARCH_FIELD_BY_OPERATION[params.operation]] || undefined + if (params.operation === 'set_password') { + result.forceChangePasswordNextSignIn = params.forceChangePasswordNextSignIn !== 'false' + result.forceChangePasswordNextSignInWithMfa = params.forceChangePasswordNextSignInWithMfa + ? params.forceChangePasswordNextSignInWithMfa === 'true' + : undefined + } if (params.operation === 'update_user') { - if (params.accountEnabled) result.accountEnabled = params.accountEnabled === 'true' + result.accountEnabled = params.accountEnabled + ? params.accountEnabled === 'true' + : undefined } else if (params.operation === 'create_user') { - if (params.accountEnabledCreate) - result.accountEnabled = params.accountEnabledCreate === 'true' + result.accountEnabled = params.accountEnabledCreate + ? params.accountEnabledCreate === 'true' + : undefined } if (params.mailEnabled !== undefined) result.mailEnabled = params.mailEnabled === 'true' if (params.securityEnabled !== undefined) @@ -407,9 +858,9 @@ export const MicrosoftAdBlock: BlockConfig = { if (params.groupDescription) result.description = params.groupDescription if (params.groupTypes !== undefined) result.groupTypes = params.groupTypes if (params.operation === 'update_group') { - if (params.visibility) result.visibility = params.visibility + result.visibility = params.visibility || undefined } else if (params.operation === 'create_group') { - if (params.visibilityCreate) result.visibility = params.visibilityCreate + result.visibility = params.visibilityCreate || undefined } return result }, @@ -444,12 +895,34 @@ export const MicrosoftAdBlock: BlockConfig = { visibility: { type: 'string' }, visibilityCreate: { type: 'string' }, memberId: { type: 'string' }, + addSkuIds: { type: 'string' }, + removeSkuIds: { type: 'string' }, + disabledPlanIds: { type: 'string' }, + forceChangePasswordNextSignIn: { type: 'string' }, + forceChangePasswordNextSignInWithMfa: { type: 'string' }, + newPassword: { type: 'string' }, + signInFilter: { type: 'string' }, + auditFilter: { type: 'string' }, + appRoleFilter: { type: 'string' }, + resourceId: { type: 'string' }, + appRoleId: { type: 'string' }, + appRoleAssignmentId: { type: 'string' }, + servicePrincipalId: { type: 'string' }, + servicePrincipalSearch: { type: 'string' }, + servicePrincipalFilter: { type: 'string' }, + directoryRoleId: { type: 'string' }, + deviceObjectId: { type: 'string' }, + deviceSearch: { type: 'string' }, + deviceFilter: { type: 'string' }, + deviceRelationship: { type: 'string' }, + policyId: { type: 'string' }, + policyFilter: { type: 'string' }, }, outputs: { response: { type: 'json', description: - 'Azure AD operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType. List operations also return nextLink for fetching additional pages.', + 'Microsoft Entra ID operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType. Licensing operations return skuId, skuPartNumber, consumedUnits, prepaidUnits, and servicePlans. Sign-in and audit operations return the event id, timestamp, actor, target, and result. App role and directory role operations return assignment and role ids with their principals. Device operations return id, deviceId, displayName, operatingSystem, accountEnabled, isCompliant, isManaged, and trustType. Conditional access operations return id, displayName, state, conditions, grantControls, and sessionControls. List operations also return nextLink for fetching additional pages.', }, }, } diff --git a/apps/sim/blocks/blocks/mssql.test.ts b/apps/sim/blocks/blocks/mssql.test.ts new file mode 100644 index 00000000000..6a31a1ba4f9 --- /dev/null +++ b/apps/sim/blocks/blocks/mssql.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { MSSQLBlock } from '@/blocks/blocks/mssql' + +/** + * Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the + * shape the generic tool handler actually forwards. A key the mapper omits is + * *not* dropped by that merge — the raw subBlock value survives — so asserting + * on the mapper's return alone would prove nothing about what the tool receives. + */ +describe('MSSQLBlock', () => { + const buildParams = MSSQLBlock.tools.config.params! + const selectTool = MSSQLBlock.tools.config.tool! + + const connection = { + host: 'db.example.com', + port: '1433', + database: 'app', + username: 'app', + password: 'secret', + } + + it('maps every dropdown operation onto a registered tool', () => { + const operation = MSSQLBlock.subBlocks.find((subBlock) => subBlock.id === 'operation') + const optionIds = operation?.options?.map((option) => option.id) ?? [] + + expect(optionIds).toHaveLength(6) + expect(new Set(optionIds.map((id) => selectTool({ operation: id })))).toEqual( + new Set(MSSQLBlock.tools.access) + ) + }) + + it('rejects an operation outside the registered tool set', () => { + expect(() => selectTool({ operation: 'mssql_truncate' })).toThrow( + /Invalid Microsoft SQL Server operation/ + ) + }) + + /** + * The TLS toggles are string enums rather than switches on purpose: a switch + * subBlock serializes the *string* `'false'`, which is truthy, and the route + * contract would then coerce the user's "off" into `true`. These assertions + * pin the string all the way through the merge. + */ + it('carries the TLS toggles through as strings, never as booleans', () => { + const inputs = { + ...connection, + operation: 'query', + query: 'SELECT 1', + encrypt: 'disabled', + trustServerCertificate: 'disabled', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.encrypt).toBe('disabled') + expect(finalInputs.trustServerCertificate).toBe('disabled') + }) + + it('defaults the TLS toggles to the secure pair when the subBlocks are untouched', () => { + const inputs = { ...connection, operation: 'query', query: 'SELECT 1' } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.encrypt).toBe('enabled') + expect(finalInputs.trustServerCertificate).toBe('disabled') + }) + + it('parses the port and a JSON data payload into their runtime types', () => { + const inputs = { + ...connection, + port: '14330', + operation: 'insert', + table: 'users', + data: '{"name":"Jane","age":30}', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.port).toBe(14330) + expect(finalInputs.data).toEqual({ name: 'Jane', age: 30 }) + }) + + it('surfaces a malformed data payload as a named error rather than forwarding the string', () => { + expect(() => + buildParams({ ...connection, operation: 'insert', table: 'users', data: '{not json' }) + ).toThrow(/Invalid JSON data format/) + }) + + /** + * The mapper leaves `connectionTimeout` unassigned when it is blank, but the + * merge means the empty subBlock string reaches the tool regardless — so the + * omission is not what makes this safe. The tool's own `params.connectionTimeout ? …` + * guard is, and the route contract then applies its 15000 ms default. + */ + it('lets a blank connectionTimeout through the merge as the raw empty string', () => { + const inputs = { ...connection, operation: 'query', query: 'SELECT 1', connectionTimeout: '' } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.connectionTimeout).toBe('') + }) + + it('parses connectionTimeout when it is set', () => { + const inputs = { + ...connection, + operation: 'query', + query: 'SELECT 1', + connectionTimeout: '30000', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.connectionTimeout).toBe(30000) + }) + + it('does not offer a named-instance field, which cannot stay pinned to the validated IP', () => { + const ids = MSSQLBlock.subBlocks.map((subBlock) => subBlock.id) + + expect(ids).not.toContain('instanceName') + }) + + /** + * Duplicate subBlock ids across disjoint operations are the house pattern, but + * the store seeds values by id in file order — so two entries sharing an id + * must agree on their default, or the last one silently wins. + */ + it('keeps duplicate subBlock ids in agreement on their default value', () => { + const defaultsById = new Map() + for (const subBlock of MSSQLBlock.subBlocks) { + const seeded = typeof subBlock.value === 'function' ? subBlock.value({}) : undefined + defaultsById.set(subBlock.id, [...(defaultsById.get(subBlock.id) ?? []), seeded]) + } + + for (const [id, defaults] of defaultsById) { + expect( + new Set(defaults.map((value) => JSON.stringify(value))), + `subBlock "${id}"` + ).toHaveLength(1) + } + }) +}) diff --git a/apps/sim/blocks/blocks/mssql.ts b/apps/sim/blocks/blocks/mssql.ts new file mode 100644 index 00000000000..2b59254ccef --- /dev/null +++ b/apps/sim/blocks/blocks/mssql.ts @@ -0,0 +1,508 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { MicrosoftSqlIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { IntegrationType } from '@/blocks/types' +import type { MSSQLResponse } from '@/tools/mssql/types' + +const MSSQL_WAND_PROMPT = `You are an expert Microsoft SQL Server developer. Write T-SQL queries based on the user's request. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the SQL query. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw SQL query. + +### QUERY GUIDELINES +1. **Syntax**: Use Microsoft SQL Server (T-SQL) syntax and functions +2. **Performance**: Write efficient queries with proper indexing considerations +3. **Security**: Use parameterized queries when applicable +4. **Readability**: Format queries with proper indentation and spacing +5. **Best Practices**: Follow SQL Server naming conventions and bracket-quote identifiers when needed + +### T-SQL FEATURES +- Use TOP (n) instead of LIMIT, and OFFSET ... FETCH NEXT for paging +- Use SQL Server functions (ISNULL, COALESCE, DATEADD, DATEDIFF, GETUTCDATE, FORMAT) +- Leverage CTEs, window functions, and MERGE where they fit +- Use proper SQL Server data types (NVARCHAR, DATETIME2, BIT, UNIQUEIDENTIFIER) + +### EXAMPLES + +**Simple Select**: "Get all active users" +→ SELECT TOP (100) id, name, email, created_at + FROM dbo.users + WHERE is_active = 1 + ORDER BY created_at DESC; + +**Complex Join**: "Get users with their order counts and total spent" +→ SELECT + u.id, + u.name, + u.email, + COUNT(o.id) AS order_count, + ISNULL(SUM(o.total), 0) AS total_spent + FROM dbo.users u + LEFT JOIN dbo.orders o ON u.id = o.user_id + WHERE u.is_active = 1 + GROUP BY u.id, u.name, u.email + HAVING COUNT(o.id) > 0 + ORDER BY total_spent DESC; + +**With CTE**: "Get top 10 products by sales in the last 30 days" +→ WITH product_sales AS ( + SELECT + p.id, + p.name, + SUM(oi.quantity * oi.price) AS total_sales + FROM dbo.products p + JOIN dbo.order_items oi ON p.id = oi.product_id + JOIN dbo.orders o ON oi.order_id = o.id + WHERE o.created_at >= DATEADD(day, -30, GETUTCDATE()) + GROUP BY p.id, p.name + ) + SELECT TOP (10) * + FROM product_sales + ORDER BY total_sales DESC; + +### REMEMBER +Return ONLY the SQL query - no explanations, no markdown, no extra text.` + +export const MSSQLBlock: BlockConfig = { + type: 'mssql', + name: 'Microsoft SQL Server', + description: 'Connect to Microsoft SQL Server database', + longDescription: + 'Integrate Microsoft SQL Server into the workflow. Can query, insert, update, delete, execute raw T-SQL, and introspect schemas.', + docsLink: 'https://docs.sim.ai/integrations/mssql', + category: 'tools', + integrationType: IntegrationType.Databases, + bgColor: '#FFFFFF', + icon: MicrosoftSqlIcon, + canvasPresentation: { + defaultTitle: 'Microsoft SQL Server', + sentences: { + byOperation: { + query: [ + { text: 'Run SELECT query', field: 'query', core: true }, + { text: 'on', field: 'database' }, + ], + insert: [ + { text: 'Insert', field: 'data', core: true }, + { text: 'into', field: 'table', core: true }, + ], + update: [ + { text: 'Update rows in', field: 'table', core: true }, + { text: ', where', field: 'where' }, + { text: ', setting', field: 'data' }, + ], + delete: [ + { text: 'Delete rows from', field: 'table', core: true }, + { text: ', where', field: 'where' }, + ], + execute: [ + { text: 'Execute raw T-SQL', field: 'query', core: true }, + { text: 'on', field: 'database' }, + ], + introspect: [ + { text: 'Read the schema of', field: 'database', core: true }, + { text: ', under', field: 'schema' }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Query (SELECT)', id: 'query' }, + { label: 'Insert Data', id: 'insert' }, + { label: 'Update Data', id: 'update' }, + { label: 'Delete Data', id: 'delete' }, + { label: 'Execute Raw SQL', id: 'execute' }, + { label: 'Introspect Schema', id: 'introspect' }, + ], + value: () => 'query', + }, + { + id: 'host', + title: 'Host', + type: 'short-input', + placeholder: 'your.database.host', + required: true, + }, + { + id: 'port', + title: 'Port', + type: 'short-input', + placeholder: '1433', + value: () => '1433', + required: true, + }, + { + id: 'database', + title: 'Database Name', + type: 'short-input', + placeholder: 'your_database', + required: true, + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'sa', + required: true, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + password: true, + placeholder: 'Your database password', + required: true, + }, + { + id: 'encrypt', + title: 'Encrypt Connection', + type: 'dropdown', + options: [ + { label: 'Enabled', id: 'enabled' }, + { label: 'Disabled', id: 'disabled' }, + ], + value: () => 'enabled', + }, + { + id: 'trustServerCertificate', + title: 'Trust Server Certificate', + type: 'dropdown', + mode: 'advanced', + options: [ + { label: 'Disabled', id: 'disabled' }, + { label: 'Enabled', id: 'enabled' }, + ], + value: () => 'disabled', + }, + { + id: 'connectionTimeout', + title: 'Timeout (ms)', + type: 'short-input', + mode: 'advanced', + placeholder: '15000', + }, + { + id: 'table', + title: 'Table Name', + type: 'short-input', + placeholder: 'users', + condition: { field: 'operation', value: 'insert' }, + required: true, + }, + { + id: 'table', + title: 'Table Name', + type: 'short-input', + placeholder: 'users', + condition: { field: 'operation', value: 'update' }, + required: true, + }, + { + id: 'table', + title: 'Table Name', + type: 'short-input', + placeholder: 'users', + condition: { field: 'operation', value: 'delete' }, + required: true, + }, + { + id: 'query', + title: 'SQL Query', + type: 'code', + placeholder: 'SELECT TOP (100) * FROM dbo.users WHERE is_active = 1', + condition: { field: 'operation', value: 'query' }, + required: true, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: MSSQL_WAND_PROMPT, + placeholder: 'Describe the SQL query you need...', + generationType: 'sql-query', + }, + }, + { + id: 'query', + title: 'SQL Query', + type: 'code', + placeholder: 'SELECT * FROM dbo.table_name', + condition: { field: 'operation', value: 'execute' }, + required: true, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: MSSQL_WAND_PROMPT, + placeholder: 'Describe the SQL query you need...', + generationType: 'sql-query', + }, + }, + { + id: 'data', + title: 'Data (JSON)', + canvasNoun: 'a row', + type: 'code', + placeholder: '{\n "name": "John Doe",\n "email": "john@example.com",\n "is_active": 1\n}', + condition: { field: 'operation', value: 'insert' }, + required: true, + }, + { + id: 'data', + title: 'Update Data (JSON)', + type: 'code', + placeholder: '{\n "name": "Jane Doe",\n "email": "jane@example.com"\n}', + condition: { field: 'operation', value: 'update' }, + required: true, + }, + { + id: 'where', + title: 'WHERE Condition', + type: 'short-input', + placeholder: 'id = 1', + condition: { field: 'operation', value: 'update' }, + required: true, + }, + { + id: 'where', + title: 'WHERE Condition', + type: 'short-input', + placeholder: 'id = 1', + condition: { field: 'operation', value: 'delete' }, + required: true, + }, + { + id: 'schema', + title: 'Schema Name', + type: 'short-input', + placeholder: 'dbo', + value: () => 'dbo', + condition: { field: 'operation', value: 'introspect' }, + }, + ], + tools: { + access: [ + 'mssql_query', + 'mssql_insert', + 'mssql_update', + 'mssql_delete', + 'mssql_execute', + 'mssql_introspect', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'query': + return 'mssql_query' + case 'insert': + return 'mssql_insert' + case 'update': + return 'mssql_update' + case 'delete': + return 'mssql_delete' + case 'execute': + return 'mssql_execute' + case 'introspect': + return 'mssql_introspect' + default: + throw new Error(`Invalid Microsoft SQL Server operation: ${params.operation}`) + } + }, + params: (params) => { + const { operation, data, ...rest } = params + + let parsedData: Record | undefined + if (data && typeof data === 'string' && data.trim()) { + try { + parsedData = JSON.parse(data) + } catch (parseError) { + const errorMsg = getErrorMessage(parseError, 'Unknown JSON error') + throw new Error(`Invalid JSON data format: ${errorMsg}. Please check your JSON syntax.`) + } + } else if (data && typeof data === 'object') { + parsedData = data + } + + const result: Record = { + host: rest.host, + port: typeof rest.port === 'string' ? Number.parseInt(rest.port, 10) : rest.port || 1433, + database: rest.database, + username: rest.username, + password: rest.password, + encrypt: rest.encrypt || 'enabled', + trustServerCertificate: rest.trustServerCertificate || 'disabled', + } + + if (rest.connectionTimeout) { + result.connectionTimeout = + typeof rest.connectionTimeout === 'string' + ? Number.parseInt(rest.connectionTimeout, 10) + : rest.connectionTimeout + } + if (rest.table) result.table = rest.table + if (rest.query) result.query = rest.query + if (rest.where) result.where = rest.where + if (rest.schema) result.schema = rest.schema + if (parsedData !== undefined) result.data = parsedData + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Database operation to perform' }, + host: { type: 'string', description: 'Database host' }, + port: { type: 'string', description: 'Database port' }, + database: { type: 'string', description: 'Database name' }, + username: { type: 'string', description: 'Database username' }, + password: { type: 'string', description: 'Database password' }, + encrypt: { type: 'string', description: 'Encrypt the connection with TLS' }, + trustServerCertificate: { + type: 'string', + description: 'Trust a self-signed server certificate', + }, + connectionTimeout: { + type: 'string', + description: 'Connection and request timeout in milliseconds', + }, + table: { type: 'string', description: 'Table name' }, + query: { type: 'string', description: 'T-SQL query to execute' }, + data: { type: 'json', description: 'Data for insert/update operations' }, + where: { type: 'string', description: 'WHERE clause for update/delete' }, + schema: { type: 'string', description: 'Schema name for introspection' }, + }, + outputs: { + message: { + type: 'string', + description: 'Success or error message describing the operation outcome', + }, + rows: { + type: 'array', + description: 'Array of rows returned from the query', + }, + rowCount: { + type: 'number', + description: 'Number of rows returned or affected by the operation', + }, + tables: { + type: 'array', + description: 'Array of table schemas with columns, keys, and indexes (introspect operation)', + }, + schemas: { + type: 'array', + description: 'List of available schemas in the database (introspect operation)', + }, + }, +} + +export const MSSQLBlockMeta = { + tags: ['data-analytics'], + url: 'https://www.microsoft.com/en-us/sql-server', + templates: [ + { + icon: MicrosoftSqlIcon, + title: 'Ask SQL Server in plain English', + prompt: + 'Build a workflow that takes a natural-language question, has an agent turn it into a T-SQL SELECT, runs it against Microsoft SQL Server, and returns the resulting rows as a readable answer.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['database', 'reporting'], + }, + { + icon: MicrosoftSqlIcon, + title: 'SQL Server metrics digest to Slack', + prompt: + 'Create a scheduled workflow that queries key business metrics from Microsoft SQL Server each morning, has an agent summarize the numbers and notable changes, and posts the digest to a Slack channel.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['reporting', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: MicrosoftSqlIcon, + title: 'Document your SQL Server schema', + prompt: + 'Build a workflow that introspects a Microsoft SQL Server schema to list its tables, columns, keys, and indexes, then has an agent write plain-English documentation describing what each table holds.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['database', 'documentation'], + }, + { + icon: MicrosoftSqlIcon, + title: 'Merge records into SQL Server', + prompt: + 'Create a workflow that takes incoming records and runs a T-SQL MERGE against Microsoft SQL Server so each row is inserted when new and updated when it already exists, keeping the table free of duplicates.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['database', 'sync'], + }, + { + icon: MicrosoftSqlIcon, + title: 'Nightly SQL Server data cleanup', + prompt: + 'Build a scheduled workflow that deletes rows in Microsoft SQL Server older than a retention cutoff and reports how many rows were removed, so tables stay lean on an interval.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['database', 'automation'], + }, + { + icon: MicrosoftSqlIcon, + title: 'SQL Server results to Sim table', + prompt: + 'Create a scheduled workflow that runs a Microsoft SQL Server query for the latest records and writes each row into a Sim table, so the data is available for downstream blocks without a live database call.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['database', 'sync'], + }, + { + icon: MicrosoftSqlIcon, + title: 'SQL Server threshold breach alert', + prompt: + 'Build a scheduled workflow that queries a Microsoft SQL Server count or aggregate, compares it to a threshold, and posts a Slack alert only when the value crosses the limit so the team hears about problems early.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['monitoring', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: MicrosoftSqlIcon, + title: 'Log form submissions to SQL Server', + prompt: + 'Create a workflow that takes an incoming payload, validates the fields, and inserts a new row into a Microsoft SQL Server table so every submission is durably recorded.', + modules: ['agent', 'workflows'], + category: 'productivity', + tags: ['database', 'automation'], + }, + ], + skills: [ + { + name: 'query-to-answer', + description: 'Turn a natural-language question into a T-SQL SELECT and return the rows.', + content: + '# Query To Answer\n\nAnswer a question by generating and running a T-SQL SELECT.\n\n## Steps\n1. Introspect the relevant schema first so the agent knows the real table and column names.\n2. Have the agent write a single SELECT, using TOP (n) to bound large result sets.\n3. Run the query and inspect rows and rowCount.\n4. Summarize the rows back to the caller in plain language.\n\n## Output\nReturn the answer text, the underlying rows, and the rowCount.', + }, + { + name: 'merge-records', + description: 'Insert new rows or update existing ones by key so a table stays deduplicated.', + content: + "# Merge Records\n\nKeep a table in sync by inserting new rows or updating existing ones on a key.\n\n## Preferred: atomic MERGE with execute\nUse the execute operation to run a single T-SQL `MERGE`, which resolves the match and the write in one statement.\n\n```sql\nMERGE dbo.users AS target\nUSING (SELECT 'u1' AS id, 'Jane' AS name, 'jane@example.com' AS email) AS source\nON target.id = source.id\nWHEN MATCHED THEN UPDATE SET name = source.name, email = source.email\nWHEN NOT MATCHED THEN INSERT (id, name, email) VALUES (source.id, source.name, source.email);\n```\n\nAlways terminate a MERGE statement with a semicolon — SQL Server requires it.\n\n## Fallback: read then write\nIf MERGE is unavailable, query the table for an existing row by key, then run insert when none exists or update with a WHERE condition on the key when one does.\n\n## Steps\n1. Confirm the match key is unique.\n2. Run execute with the MERGE statement for each record.\n3. Inspect rowCount to confirm the write landed.\n\n## Output\nReport how many records were merged and the total rowCount changed.", + }, + { + name: 'document-schema', + description: 'Introspect a schema and produce plain-English documentation of its tables.', + content: + '# Document Schema\n\nDescribe a Microsoft SQL Server schema in readable terms.\n\n## Steps\n1. Run introspect on the target schema (default dbo) to get tables, columns, keys, and indexes.\n2. Have an agent describe what each table stores and how they relate via foreign keys.\n3. Note primary keys and indexes that hint at common access patterns.\n4. Assemble the descriptions into a single document.\n\n## Output\nReturn the list of tables with a short description of each and the raw introspection result.', + }, + { + name: 'retention-cleanup', + description: 'Delete rows older than a retention cutoff and report the count removed.', + content: + '# Retention Cleanup\n\nRemove stale rows on a schedule.\n\n## Steps\n1. Compute the retention cutoff timestamp for the run.\n2. Optionally query a count of rows older than the cutoff to preview the impact.\n3. Run delete with a WHERE condition comparing the timestamp column to the cutoff.\n4. Capture the rowCount removed for the run.\n\n## Output\nReport how many rows were deleted and the cutoff that was applied.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/okta.test.ts b/apps/sim/blocks/blocks/okta.test.ts new file mode 100644 index 00000000000..53e69305396 --- /dev/null +++ b/apps/sim/blocks/blocks/okta.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { OktaBlock } from '@/blocks/blocks/okta' + +/** + * The generic block handler runs `{ ...inputs, ...transformedParams }`, so the + * transform can only drop a value by assigning `undefined` to its key. Omitting + * the key leaves the raw subBlock string in place. These cases pin that + * behavior by asserting on the merge, not on the transform alone. + */ +function merge(inputs: Record): Record { + const transform = OktaBlock.tools.config?.params + if (!transform) throw new Error('Okta block has no params transform') + return { ...inputs, ...transform(inputs) } +} + +const BASE = { operation: 'okta_list_users', apiKey: 'token', domain: 'dev-1.okta.com' } + +describe('Okta block params transform', () => { + it('coerces a numeric limit', () => { + expect(merge({ ...BASE, limit: '25' }).limit).toBe(25) + }) + + it('drops a non-numeric limit rather than forwarding the raw entry', () => { + expect(merge({ ...BASE, limit: 'twenty' }).limit).toBeUndefined() + }) + + it('drops a non-numeric priority rather than forwarding the raw entry', () => { + const merged = merge({ + ...BASE, + operation: 'okta_assign_group_to_app', + priority: 'high', + }) + expect(merged.priority).toBeUndefined() + }) + + it('drops a blank profile field so a partial update leaves Okta untouched', () => { + const merged = merge({ + ...BASE, + operation: 'okta_update_user', + userId: '00u1', + firstName: 'Ada', + lastName: '', + }) + expect(merged.firstName).toBe('Ada') + expect(merged.lastName).toBeUndefined() + }) + + it('maps the group name and description onto the tool param names', () => { + const merged = merge({ + ...BASE, + operation: 'okta_create_group', + groupName: 'Engineering', + groupDescription: 'Eng team', + }) + expect(merged.name).toBe('Engineering') + expect(merged.description).toBe('Eng team') + }) + + /** + * The update tool declares `name` optional and its merge helper carries the + * stored name through when the field is blank, so requiring it on the block + * would block a description-only update the tool and the API both accept. + * Create has no stored name to fall back on and still requires it. + */ + it('requires the group name only when creating a group', () => { + const groupName = OktaBlock.subBlocks.find((subBlock) => subBlock.id === 'groupName') + + expect(groupName?.condition).toEqual({ + field: 'operation', + value: ['okta_create_group', 'okta_update_group'], + }) + expect(groupName?.required).toEqual({ field: 'operation', value: ['okta_create_group'] }) + }) + + it('keeps a false toggle, which is a real choice rather than a blank field', () => { + const merged = merge({ + ...BASE, + operation: 'okta_activate_user', + userId: '00u1', + sendEmail: false, + }) + expect(merged.sendEmail).toBe(false) + }) +}) + +describe('Okta block outputs', () => { + it('declares only fields a tool emits at the top level', () => { + const declared = Object.keys(OktaBlock.outputs) + expect(declared).not.toContain('targets') + expect(declared).not.toContain('debugData') + expect(declared).toContain('events') + }) +}) diff --git a/apps/sim/blocks/blocks/okta.ts b/apps/sim/blocks/blocks/okta.ts index a29a3b7db01..7a0db904fec 100644 --- a/apps/sim/blocks/blocks/okta.ts +++ b/apps/sim/blocks/blocks/okta.ts @@ -1,16 +1,58 @@ import { OktaIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { IntegrationType } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' import type { OktaResponse } from '@/tools/okta/types' +/** + * Coerces a numeric subBlock value, dropping anything that is not a real number. + * + * These fields are free-text inputs, so a stray non-numeric entry would otherwise + * become `NaN` and serialize to `null`, which Okta rejects with a validation + * error that points at the wrong thing. Dropping the field instead lets Okta + * apply its own default. + */ +function toFiniteNumber(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +/** Operations where Okta sends the notification email unless told otherwise. */ +const SEND_EMAIL_DEFAULT_ON_OPERATIONS = ['okta_activate_user', 'okta_reset_password'] + +const SEND_EMAIL_DEFAULT_ON = new Set(SEND_EMAIL_DEFAULT_ON_OPERATIONS) + +/** + * The cursor subBlock each paginated operation reads. + * + * Okta's `after` cursor is opaque and scoped to the endpoint that minted it, so + * every operation carries its own field rather than sharing one. + */ +const CURSOR_FIELD_BY_OPERATION: Record = { + okta_list_users: 'after', + okta_list_groups: 'groupsAfter', + okta_list_group_members: 'groupMembersAfter', + okta_get_logs: 'logsAfter', + okta_list_apps: 'appsAfter', + okta_list_app_users: 'appUsersAfter', + okta_list_app_groups: 'appGroupsAfter', + okta_list_group_rules: 'groupRulesAfter', +} + +/** Treats a blank subBlock value as absent. */ +function blankToUndefined(value: unknown): unknown { + return value === null || value === '' ? undefined : value +} + export const OktaBlock: BlockConfig = { type: 'okta', name: 'Okta', - description: 'Manage users and groups in Okta', + description: 'Manage users, groups, apps, and MFA in Okta', longDescription: - 'Integrate Okta identity management into your workflow. List, create, update, activate, suspend, and delete users. Reset passwords. Manage groups and group membership.', + 'Integrate Okta identity management into your workflow. Manage users, groups, and group rules. Run service desk actions like resetting MFA factors and clearing sessions. Review and change application assignments and admin roles. Query the System Log to audit sign-ins and admin changes.', docsLink: 'https://docs.sim.ai/integrations/okta', category: 'tools', + authMode: AuthMode.ApiKey, integrationType: IntegrationType.Security, bgColor: '#191919', iconColor: '#007DC1', @@ -66,6 +108,92 @@ export const OktaBlock: BlockConfig = { { text: 'List the members of group', field: 'groupId', core: true }, { text: ', up to', field: 'limit' }, ], + okta_get_logs: [ + 'Read System Log events', + { text: ', matching', field: 'q' }, + { text: ', filtered by', field: 'filter' }, + { text: ', since', field: 'since' }, + { text: ', until', field: 'until' }, + ], + okta_clear_user_sessions: [ + { text: 'Clear every session of user', field: 'userId', core: true }, + ], + okta_get_session: [{ text: 'Read session', field: 'sessionId', core: true }], + okta_revoke_session: [{ text: 'Revoke session', field: 'sessionId', core: true }], + okta_list_factors: [{ text: 'List the MFA factors of user', field: 'userId', core: true }], + okta_get_factor: [ + { text: 'Read factor', field: 'factorId', core: true }, + { text: 'of user', field: 'userId' }, + ], + okta_enroll_factor: [ + { text: 'Enroll factor', field: 'factorType', core: true }, + { text: 'for user', field: 'userId' }, + ], + okta_reset_factor: [ + { text: 'Reset factor', field: 'factorId', core: true }, + { text: 'for user', field: 'userId' }, + ], + okta_reset_all_factors: [ + { text: 'Reset every MFA factor of user', field: 'userId', core: true }, + ], + okta_list_apps: [ + 'List applications', + { text: ', matching', field: 'q' }, + { text: ', filtered by', field: 'filter' }, + { text: ', up to', field: 'limit' }, + ], + okta_get_app: [{ text: 'Read application', field: 'appId', core: true }], + okta_list_app_users: [ + { text: 'List the users assigned to application', field: 'appId', core: true }, + { text: ', matching', field: 'q' }, + ], + okta_assign_user_to_app: [ + { text: 'Assign user', field: 'userId', core: true }, + { text: 'to application', field: 'appId' }, + ], + okta_remove_user_from_app: [ + { text: 'Remove user', field: 'userId', core: true }, + { text: 'from application', field: 'appId', core: true }, + ], + okta_list_app_groups: [ + { text: 'List the groups assigned to application', field: 'appId', core: true }, + ], + okta_assign_group_to_app: [ + { text: 'Assign group', field: 'groupId', core: true }, + { text: 'to application', field: 'appId' }, + ], + okta_remove_group_from_app: [ + { text: 'Remove group', field: 'groupId', core: true }, + { text: 'from application', field: 'appId' }, + ], + okta_list_user_roles: [ + { text: 'List the admin roles of user', field: 'userId', core: true }, + ], + okta_assign_user_role: [ + { text: 'Assign admin role', field: 'roleType', core: true }, + { text: 'to user', field: 'userId' }, + ], + okta_remove_user_role: [ + { text: 'Remove admin role assignment', field: 'roleAssignmentId', core: true }, + { text: 'from user', field: 'userId' }, + ], + okta_list_group_rules: [ + 'List group rules', + { text: ', matching', field: 'ruleSearch' }, + { text: ', up to', field: 'limit' }, + ], + okta_get_group_rule: [{ text: 'Read group rule', field: 'groupRuleId', core: true }], + okta_create_group_rule: [ + { text: 'Create group rule', field: 'ruleName', core: true }, + { text: ', matching users where', field: 'expression' }, + ], + okta_activate_group_rule: [ + { text: 'Activate group rule', field: 'groupRuleId', core: true }, + ], + okta_deactivate_group_rule: [ + { text: 'Deactivate group rule', field: 'groupRuleId', core: true }, + ], + okta_delete_group_rule: [{ text: 'Delete group rule', field: 'groupRuleId', core: true }], }, }, }, @@ -94,6 +222,32 @@ export const OktaBlock: BlockConfig = { { label: 'Add User to Group', id: 'okta_add_user_to_group' }, { label: 'Remove User from Group', id: 'okta_remove_user_from_group' }, { label: 'List Group Members', id: 'okta_list_group_members' }, + { label: 'List Group Rules', id: 'okta_list_group_rules' }, + { label: 'Get Group Rule', id: 'okta_get_group_rule' }, + { label: 'Create Group Rule', id: 'okta_create_group_rule' }, + { label: 'Activate Group Rule', id: 'okta_activate_group_rule' }, + { label: 'Deactivate Group Rule', id: 'okta_deactivate_group_rule' }, + { label: 'Delete Group Rule', id: 'okta_delete_group_rule' }, + { label: 'List Factors', id: 'okta_list_factors' }, + { label: 'Get Factor', id: 'okta_get_factor' }, + { label: 'Enroll Factor', id: 'okta_enroll_factor' }, + { label: 'Reset Factor', id: 'okta_reset_factor' }, + { label: 'Reset All Factors', id: 'okta_reset_all_factors' }, + { label: 'Clear User Sessions', id: 'okta_clear_user_sessions' }, + { label: 'Get Session', id: 'okta_get_session' }, + { label: 'Revoke Session', id: 'okta_revoke_session' }, + { label: 'List Applications', id: 'okta_list_apps' }, + { label: 'Get Application', id: 'okta_get_app' }, + { label: 'List Application Users', id: 'okta_list_app_users' }, + { label: 'Assign User to Application', id: 'okta_assign_user_to_app' }, + { label: 'Remove User from Application', id: 'okta_remove_user_from_app' }, + { label: 'List Application Groups', id: 'okta_list_app_groups' }, + { label: 'Assign Group to Application', id: 'okta_assign_group_to_app' }, + { label: 'Remove Group from Application', id: 'okta_remove_group_from_app' }, + { label: 'List User Roles', id: 'okta_list_user_roles' }, + { label: 'Assign User Role', id: 'okta_assign_user_role' }, + { label: 'Remove User Role', id: 'okta_remove_user_role' }, + { label: 'Get System Log Events', id: 'okta_get_logs' }, ], value: () => 'okta_list_users', }, @@ -118,15 +272,56 @@ export const OktaBlock: BlockConfig = { title: 'Search', type: 'short-input', placeholder: 'profile.firstName eq "John"', - condition: { field: 'operation', value: ['okta_list_users', 'okta_list_groups'] }, + condition: { + field: 'operation', + value: ['okta_list_users', 'okta_list_groups'], + }, + wandConfig: { + enabled: true, + placeholder: 'Describe who or what to search for', + prompt: + 'Generate an Okta search expression. The grammar is SCIM-style: a property, an operator (eq, sw, co, gt, ge, lt, le), and a quoted value, combined with and/or and parentheses. User properties are prefixed with profile (profile.firstName, profile.email, profile.department) plus the top-level id, status, created, activated, statusChanged and lastUpdated. Group properties are type plus profile.name and profile.description. Example: profile.department eq "Engineering" and status eq "ACTIVE". Return ONLY the expression - no explanations, no extra text.', + }, }, { id: 'filter', title: 'Filter', type: 'short-input', placeholder: 'status eq "ACTIVE"', - condition: { field: 'operation', value: ['okta_list_users', 'okta_list_groups'] }, + condition: { + field: 'operation', + value: ['okta_list_users', 'okta_list_groups', 'okta_list_apps', 'okta_get_logs'], + }, mode: 'advanced', + wandConfig: { + enabled: true, + placeholder: 'Describe how to narrow the results', + prompt: + 'Generate an Okta filter expression. The grammar is SCIM-style: a property, an operator (eq, and for lastUpdated also gt, ge, lt, le), and a quoted value, combined with and/or. Each listing supports a limited property set. Users: status, lastUpdated, id, profile.login, profile.email, profile.firstName, profile.lastName. Groups: id, type, lastUpdated, lastMembershipUpdated. Applications: id, status, name. System Log: any event property, such as eventType, outcome.result, actor.alternateId or client.ipAddress. Example: eventType eq "user.session.start" and outcome.result eq "FAILURE". Return ONLY the expression - no explanations, no extra text.', + }, + }, + { + id: 'q', + title: 'Query', + type: 'short-input', + placeholder: 'Keyword to search for', + condition: { + field: 'operation', + value: ['okta_get_logs', 'okta_list_apps', 'okta_list_app_users', 'okta_list_app_groups'], + }, + }, + { + /** + * Group rules take a plain keyword on `search`, not the SCIM-style + * expression the Search field's wand generates, so they get their own + * field rather than sharing one that would produce a silently + * non-matching query. + */ + id: 'ruleSearch', + title: 'Search', + type: 'short-input', + placeholder: 'Keyword to search rules for', + condition: { field: 'operation', value: 'okta_list_group_rules' }, }, // User ID (shared across user operations that need it) { @@ -147,6 +342,17 @@ export const OktaBlock: BlockConfig = { 'okta_delete_user', 'okta_add_user_to_group', 'okta_remove_user_from_group', + 'okta_clear_user_sessions', + 'okta_list_factors', + 'okta_get_factor', + 'okta_enroll_factor', + 'okta_reset_factor', + 'okta_reset_all_factors', + 'okta_assign_user_to_app', + 'okta_remove_user_from_app', + 'okta_list_user_roles', + 'okta_assign_user_role', + 'okta_remove_user_role', ], }, required: { @@ -162,6 +368,17 @@ export const OktaBlock: BlockConfig = { 'okta_delete_user', 'okta_add_user_to_group', 'okta_remove_user_from_group', + 'okta_clear_user_sessions', + 'okta_list_factors', + 'okta_get_factor', + 'okta_enroll_factor', + 'okta_reset_factor', + 'okta_reset_all_factors', + 'okta_assign_user_to_app', + 'okta_remove_user_from_app', + 'okta_list_user_roles', + 'okta_assign_user_role', + 'okta_remove_user_role', ], }, }, @@ -180,6 +397,8 @@ export const OktaBlock: BlockConfig = { 'okta_add_user_to_group', 'okta_remove_user_from_group', 'okta_list_group_members', + 'okta_assign_group_to_app', + 'okta_remove_group_from_app', ], }, required: { @@ -191,6 +410,8 @@ export const OktaBlock: BlockConfig = { 'okta_add_user_to_group', 'okta_remove_user_from_group', 'okta_list_group_members', + 'okta_assign_group_to_app', + 'okta_remove_group_from_app', ], }, }, @@ -260,13 +481,29 @@ export const OktaBlock: BlockConfig = { condition: { field: 'operation', value: ['okta_create_user', 'okta_update_user'] }, mode: 'advanced', }, + /** + * Okta's `activate` default is inverted between the two operations that take + * it: creating a user activates unless told otherwise, enrolling a factor + * does not. One shared switch could only be seeded for one of them, and + * because it is advanced `shouldSerializeSubBlock` skips its condition, so + * the other operation inherited the wrong answer. Each gets its own field + * and the params mapper picks by operation. + */ { id: 'activate', title: 'Activate Immediately', type: 'switch', + value: () => 'true', condition: { field: 'operation', value: 'okta_create_user' }, mode: 'advanced', }, + { + id: 'activateFactor', + title: 'Activate Immediately', + type: 'switch', + condition: { field: 'operation', value: 'okta_enroll_factor' }, + mode: 'advanced', + }, // Group name (for create/update group) { id: 'groupName', @@ -274,7 +511,13 @@ export const OktaBlock: BlockConfig = { type: 'short-input', placeholder: 'Engineering Team', condition: { field: 'operation', value: ['okta_create_group', 'okta_update_group'] }, - required: { field: 'operation', value: ['okta_create_group', 'okta_update_group'] }, + /** + * Required only on create, where Okta has no stored name to fall back on. + * An update is a read-modify-write that carries the stored name through, so + * a blank name means "leave it alone" — requiring it here would block a + * description-only update the tool and API both accept. + */ + required: { field: 'operation', value: ['okta_create_group'] }, }, { id: 'groupDescription', @@ -283,20 +526,385 @@ export const OktaBlock: BlockConfig = { placeholder: 'Description for the group', condition: { field: 'operation', value: ['okta_create_group', 'okta_update_group'] }, }, - // Send email option (activate, reset password, delete) + /** + * Okta's `sendEmail` default is not uniform: activation and password reset + * default to sending, deactivation and removal default to not sending. One + * shared switch could only be seeded for one of those, so the two groups get + * their own field and the params mapper picks by operation. + */ { id: 'sendEmail', title: 'Send Email', type: 'switch', + value: () => 'true', + condition: { + field: 'operation', + value: SEND_EMAIL_DEFAULT_ON_OPERATIONS, + }, + mode: 'advanced', + }, + { + id: 'sendDeactivationEmail', + title: 'Send Email', + type: 'switch', + condition: { + field: 'operation', + value: ['okta_deactivate_user', 'okta_delete_user', 'okta_remove_user_from_app'], + }, + mode: 'advanced', + }, + // Group rule params + { + id: 'groupRuleId', + title: 'Group Rule ID', + type: 'short-input', + placeholder: 'Okta group rule ID', condition: { field: 'operation', value: [ - 'okta_activate_user', - 'okta_deactivate_user', - 'okta_reset_password', - 'okta_delete_user', + 'okta_get_group_rule', + 'okta_activate_group_rule', + 'okta_deactivate_group_rule', + 'okta_delete_group_rule', + ], + }, + required: { + field: 'operation', + value: [ + 'okta_get_group_rule', + 'okta_activate_group_rule', + 'okta_deactivate_group_rule', + 'okta_delete_group_rule', + ], + }, + }, + { + id: 'ruleName', + title: 'Rule Name', + type: 'short-input', + placeholder: 'Engineers', + condition: { field: 'operation', value: 'okta_create_group_rule' }, + required: { field: 'operation', value: 'okta_create_group_rule' }, + }, + { + id: 'expression', + title: 'Expression', + type: 'short-input', + placeholder: 'user.department=="Engineering"', + condition: { field: 'operation', value: 'okta_create_group_rule' }, + required: { field: 'operation', value: 'okta_create_group_rule' }, + wandConfig: { + enabled: true, + placeholder: 'Describe which users the rule should match', + prompt: + 'Generate an Okta Expression Language predicate that evaluates to a boolean over a user profile. Reference profile attributes as user., combine them with && and ||, and compare with == or !=. Example: user.department=="Engineering" && user.countryCode=="US". Return ONLY the expression - no explanations, no extra text.', + }, + }, + { + id: 'assignUserToGroupIds', + title: 'Assign to Group IDs', + type: 'short-input', + placeholder: 'Comma-separated group IDs', + condition: { field: 'operation', value: 'okta_create_group_rule' }, + required: { field: 'operation', value: 'okta_create_group_rule' }, + }, + { + id: 'excludedUserIds', + title: 'Excluded User IDs', + type: 'short-input', + placeholder: 'Comma-separated user IDs to exclude', + condition: { field: 'operation', value: 'okta_create_group_rule' }, + mode: 'advanced', + }, + { + id: 'removeUsers', + title: 'Remove Assigned Users', + type: 'switch', + condition: { field: 'operation', value: 'okta_delete_group_rule' }, + mode: 'advanced', + }, + // Factor params + { + id: 'factorId', + title: 'Factor ID', + type: 'short-input', + placeholder: 'Okta factor ID', + condition: { field: 'operation', value: ['okta_get_factor', 'okta_reset_factor'] }, + required: { field: 'operation', value: ['okta_get_factor', 'okta_reset_factor'] }, + }, + { + id: 'factorType', + title: 'Factor Type', + type: 'dropdown', + options: [ + { label: 'SMS', id: 'sms' }, + { label: 'Voice Call', id: 'call' }, + { label: 'Email', id: 'email' }, + { label: 'Security Question', id: 'question' }, + { label: 'Okta Verify Push', id: 'push' }, + { label: 'Software TOTP', id: 'token:software:totp' }, + { label: 'U2F', id: 'u2f' }, + { label: 'WebAuthn', id: 'webauthn' }, + ], + condition: { field: 'operation', value: 'okta_enroll_factor' }, + required: { field: 'operation', value: 'okta_enroll_factor' }, + }, + { + id: 'provider', + title: 'Factor Provider', + type: 'dropdown', + options: [ + { label: 'Okta', id: 'OKTA' }, + { label: 'Google', id: 'GOOGLE' }, + { label: 'FIDO', id: 'FIDO' }, + { label: 'Duo', id: 'DUO' }, + { label: 'RSA', id: 'RSA' }, + { label: 'Symantec', id: 'SYMANTEC' }, + { label: 'Yubico', id: 'YUBICO' }, + { label: 'Custom', id: 'CUSTOM' }, + ], + condition: { field: 'operation', value: 'okta_enroll_factor' }, + required: { field: 'operation', value: 'okta_enroll_factor' }, + }, + { + id: 'phoneNumber', + title: 'Phone Number', + type: 'short-input', + placeholder: '+14155550123 (required for SMS and voice call)', + condition: { field: 'operation', value: 'okta_enroll_factor' }, + }, + { + id: 'factorEmail', + title: 'Factor Email', + type: 'short-input', + placeholder: 'user@example.com (required for the email factor)', + condition: { field: 'operation', value: 'okta_enroll_factor' }, + mode: 'advanced', + }, + { + id: 'securityQuestion', + title: 'Security Question', + type: 'short-input', + placeholder: 'disliked_food (required for the question factor)', + condition: { field: 'operation', value: 'okta_enroll_factor' }, + mode: 'advanced', + }, + { + id: 'securityAnswer', + title: 'Security Answer', + type: 'short-input', + password: true, + placeholder: 'Answer, minimum 4 characters', + condition: { field: 'operation', value: 'okta_enroll_factor' }, + mode: 'advanced', + }, + { + id: 'removeRecoveryEnrollment', + title: 'Remove Recovery Enrollment', + type: 'switch', + condition: { field: 'operation', value: 'okta_reset_factor' }, + mode: 'advanced', + }, + // Session params + { + id: 'sessionId', + title: 'Session ID', + type: 'short-input', + placeholder: 'Okta session ID', + condition: { field: 'operation', value: ['okta_get_session', 'okta_revoke_session'] }, + required: { field: 'operation', value: ['okta_get_session', 'okta_revoke_session'] }, + }, + { + id: 'oauthTokens', + title: 'Revoke OAuth Tokens', + type: 'switch', + condition: { field: 'operation', value: 'okta_clear_user_sessions' }, + mode: 'advanced', + }, + { + id: 'forgetDevices', + title: 'Forget Devices', + type: 'switch', + /** + * Okta defaults this to true, so an unseeded switch would render off while + * remembered factors were in fact being cleared. + */ + value: () => 'true', + condition: { field: 'operation', value: 'okta_clear_user_sessions' }, + mode: 'advanced', + }, + // Application params + { + id: 'appId', + title: 'Application ID', + type: 'short-input', + placeholder: 'Okta application ID', + condition: { + field: 'operation', + value: [ + 'okta_get_app', + 'okta_list_app_users', + 'okta_assign_user_to_app', + 'okta_remove_user_from_app', + 'okta_list_app_groups', + 'okta_assign_group_to_app', + 'okta_remove_group_from_app', + ], + }, + required: { + field: 'operation', + value: [ + 'okta_get_app', + 'okta_list_app_users', + 'okta_assign_user_to_app', + 'okta_remove_user_from_app', + 'okta_list_app_groups', + 'okta_assign_group_to_app', + 'okta_remove_group_from_app', ], }, + }, + { + id: 'appUserName', + title: 'Application Username', + type: 'short-input', + placeholder: 'Username the user signs in to the app with', + condition: { field: 'operation', value: 'okta_assign_user_to_app' }, + }, + { + id: 'scope', + title: 'Assignment Scope', + type: 'dropdown', + options: [ + { label: 'User', id: 'USER' }, + { label: 'Group', id: 'GROUP' }, + ], + condition: { field: 'operation', value: 'okta_assign_user_to_app' }, + mode: 'advanced', + }, + { + id: 'priority', + title: 'Priority', + type: 'short-input', + placeholder: 'Assignment priority', + condition: { field: 'operation', value: 'okta_assign_group_to_app' }, + mode: 'advanced', + }, + { + id: 'includeNonDeleted', + title: 'Include Non-Deleted', + type: 'switch', + condition: { field: 'operation', value: 'okta_list_apps' }, + mode: 'advanced', + }, + // Admin role params + { + id: 'roleType', + title: 'Role Type', + type: 'dropdown', + options: [ + { label: 'Super Admin', id: 'SUPER_ADMIN' }, + { label: 'Org Admin', id: 'ORG_ADMIN' }, + { label: 'App Admin', id: 'APP_ADMIN' }, + { label: 'User Admin', id: 'USER_ADMIN' }, + { label: 'Help Desk Admin', id: 'HELP_DESK_ADMIN' }, + { label: 'Read Only Admin', id: 'READ_ONLY_ADMIN' }, + { label: 'API Access Management Admin', id: 'API_ACCESS_MANAGEMENT_ADMIN' }, + { label: 'Group Membership Admin', id: 'GROUP_MEMBERSHIP_ADMIN' }, + { label: 'Report Admin', id: 'REPORT_ADMIN' }, + { label: 'Workflows Admin', id: 'WORKFLOWS_ADMIN' }, + { label: 'Access Certifications Admin', id: 'ACCESS_CERTIFICATIONS_ADMIN' }, + { label: 'Access Requests Admin', id: 'ACCESS_REQUESTS_ADMIN' }, + { label: 'Custom', id: 'CUSTOM' }, + ], + condition: { field: 'operation', value: 'okta_assign_user_role' }, + required: { field: 'operation', value: 'okta_assign_user_role' }, + }, + { + id: 'customRoleId', + title: 'Custom Role ID', + type: 'short-input', + placeholder: 'ID of the custom role to grant', + condition: { + field: 'operation', + value: 'okta_assign_user_role', + and: { field: 'roleType', value: 'CUSTOM' }, + }, + required: { + field: 'operation', + value: 'okta_assign_user_role', + and: { field: 'roleType', value: 'CUSTOM' }, + }, + }, + { + id: 'resourceSetId', + title: 'Resource Set ID', + type: 'short-input', + placeholder: 'Resource set the custom role applies to', + condition: { + field: 'operation', + value: 'okta_assign_user_role', + and: { field: 'roleType', value: 'CUSTOM' }, + }, + required: { + field: 'operation', + value: 'okta_assign_user_role', + and: { field: 'roleType', value: 'CUSTOM' }, + }, + }, + { + id: 'disableNotifications', + title: 'Third-Party Admin', + type: 'switch', + condition: { field: 'operation', value: 'okta_assign_user_role' }, + mode: 'advanced', + }, + { + id: 'roleAssignmentId', + title: 'Role Assignment ID', + type: 'short-input', + placeholder: 'Role assignment ID from List User Roles', + condition: { field: 'operation', value: 'okta_remove_user_role' }, + required: { field: 'operation', value: 'okta_remove_user_role' }, + }, + // System Log params + { + id: 'since', + title: 'Since', + type: 'short-input', + placeholder: '2026-08-01T00:00:00.000Z', + condition: { field: 'operation', value: 'okta_get_logs' }, + wandConfig: { + enabled: true, + placeholder: 'Describe the start of the time window', + prompt: + 'Generate an ISO 8601 UTC timestamp for the start of a System Log query window, for example 2026-08-01T00:00:00.000Z. Return ONLY the timestamp - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'until', + title: 'Until', + type: 'short-input', + placeholder: '2026-08-15T00:00:00.000Z', + condition: { field: 'operation', value: 'okta_get_logs' }, + wandConfig: { + enabled: true, + placeholder: 'Describe the end of the time window', + prompt: + 'Generate an ISO 8601 UTC timestamp for the end of a System Log query window, for example 2026-08-15T00:00:00.000Z. Return ONLY the timestamp - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'sortOrder', + title: 'Sort Order', + type: 'dropdown', + options: [ + { label: 'Ascending', id: 'ASCENDING' }, + { label: 'Descending', id: 'DESCENDING' }, + ], + condition: { field: 'operation', value: 'okta_get_logs' }, mode: 'advanced', }, // Pagination @@ -307,10 +915,92 @@ export const OktaBlock: BlockConfig = { placeholder: 'Max results to return', condition: { field: 'operation', - value: ['okta_list_users', 'okta_list_groups', 'okta_list_group_members'], + value: [ + 'okta_list_users', + 'okta_list_groups', + 'okta_list_group_members', + 'okta_get_logs', + 'okta_list_apps', + 'okta_list_app_users', + 'okta_list_app_groups', + 'okta_list_group_rules', + ], }, mode: 'advanced', }, + /** + * One cursor field per operation. + * + * Okta mints `after` per endpoint and rejects a cursor issued by another + * one, so a single shared field carried a `list_users` cursor straight into + * `list_groups`. Being advanced, `shouldSerializeSubBlock` skips its + * condition, so the stale value reached the wire unseen; the params mapper + * publishes only the field belonging to the selected operation. + */ + { + id: 'after', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_list_users' }, + mode: 'advanced', + }, + { + id: 'groupsAfter', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_list_groups' }, + mode: 'advanced', + }, + { + id: 'groupMembersAfter', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_list_group_members' }, + mode: 'advanced', + }, + { + id: 'logsAfter', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_get_logs' }, + mode: 'advanced', + }, + { + id: 'appsAfter', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_list_apps' }, + mode: 'advanced', + }, + { + id: 'appUsersAfter', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_list_app_users' }, + mode: 'advanced', + }, + { + id: 'appGroupsAfter', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_list_app_groups' }, + mode: 'advanced', + }, + { + id: 'groupRulesAfter', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous run', + condition: { field: 'operation', value: 'okta_list_group_rules' }, + mode: 'advanced', + }, ], tools: { @@ -333,38 +1023,101 @@ export const OktaBlock: BlockConfig = { 'okta_add_user_to_group', 'okta_remove_user_from_group', 'okta_list_group_members', + 'okta_list_group_rules', + 'okta_get_group_rule', + 'okta_create_group_rule', + 'okta_activate_group_rule', + 'okta_deactivate_group_rule', + 'okta_delete_group_rule', + 'okta_list_factors', + 'okta_get_factor', + 'okta_enroll_factor', + 'okta_reset_factor', + 'okta_reset_all_factors', + 'okta_clear_user_sessions', + 'okta_get_session', + 'okta_revoke_session', + 'okta_list_apps', + 'okta_get_app', + 'okta_list_app_users', + 'okta_assign_user_to_app', + 'okta_remove_user_from_app', + 'okta_list_app_groups', + 'okta_assign_group_to_app', + 'okta_remove_group_from_app', + 'okta_list_user_roles', + 'okta_assign_user_role', + 'okta_remove_user_role', + 'okta_get_logs', ], config: { tool: (params) => params.operation as string, + /** + * Every param this block can send is assigned here, including the ones it + * decides to drop. + * + * The executor merges the result over the raw serialized inputs + * (`{ ...inputs, ...transformedParams }`), so a key this function *omits* + * keeps the raw subBlock string instead of being dropped. Assigning + * `undefined` is what actually removes it: otherwise a non-numeric `limit` + * would still reach Okta verbatim, and a blank field in a partial update + * (`update_user` is a POST merge) would overwrite the stored Okta value + * with an empty string rather than leaving it untouched. + */ params: (params) => { + const operation = String(params.operation) + const cursorField = CURSOR_FIELD_BY_OPERATION[operation] + const result: Record = { apiKey: params.apiKey, domain: params.domain, + limit: toFiniteNumber(params.limit), + priority: toFiniteNumber(params.priority), + /** Group-specific UI fields carry the tool's generic param names. */ + name: blankToUndefined(params.groupName), + description: blankToUndefined(params.groupDescription), + /** Group rules get their own keyword field but the same wire param. */ + search: + params.operation === 'okta_list_group_rules' + ? blankToUndefined(params.ruleSearch) + : blankToUndefined(params.search), + /** + * Keyed off the operation rather than `??`: both switches are advanced, + * and `shouldSerializeSubBlock` skips `condition` for advanced fields, + * so a stale value from a previously selected operation can still be + * present here. + */ + sendEmail: SEND_EMAIL_DEFAULT_ON.has(operation) + ? blankToUndefined(params.sendEmail) + : blankToUndefined(params.sendDeactivationEmail), + /** Same stale-advanced-value hazard: pick the toggle for this operation. */ + activate: + operation === 'okta_enroll_factor' + ? blankToUndefined(params.activateFactor) + : blankToUndefined(params.activate), + /** A cursor is only valid on the endpoint that minted it. */ + after: cursorField ? blankToUndefined(params[cursorField]) : undefined, } - if (params.limit) result.limit = Number(params.limit) - - // Map group-specific UI fields to tool param names - if (params.groupName) result.name = params.groupName - if (params.groupDescription !== undefined) result.description = params.groupDescription - - // Pass through all other params, skipping empty values. Blank fields in a - // partial update (e.g. update_user, a POST merge) must be omitted so they - // leave the existing Okta value unchanged rather than overwriting it with - // an empty string. This mirrors the agent tool-call path, which already - // filters empty params before execution. - const skipKeys = new Set([ + const mappedKeys = new Set([ 'operation', 'apiKey', 'domain', 'limit', + 'priority', 'groupName', 'groupDescription', + 'search', + 'ruleSearch', + 'sendEmail', + 'sendDeactivationEmail', + 'activate', + 'activateFactor', + 'after', + ...Object.values(CURSOR_FIELD_BY_OPERATION), ]) for (const [key, value] of Object.entries(params)) { - if (!skipKeys.has(key) && value !== undefined && value !== null && value !== '') { - result[key] = value - } + if (!mappedKeys.has(key)) result[key] = blankToUndefined(value) } return result @@ -379,6 +1132,7 @@ export const OktaBlock: BlockConfig = { userId: { type: 'string', description: 'User ID or login' }, groupId: { type: 'string', description: 'Group ID' }, search: { type: 'string', description: 'Search expression' }, + ruleSearch: { type: 'string', description: 'Keyword to search group rules for' }, filter: { type: 'string', description: 'Filter expression' }, limit: { type: 'number', description: 'Max results to return' }, firstName: { type: 'string', description: 'First name' }, @@ -390,9 +1144,77 @@ export const OktaBlock: BlockConfig = { title: { type: 'string', description: 'Job title' }, department: { type: 'string', description: 'Department' }, activate: { type: 'boolean', description: 'Activate user immediately on creation' }, + activateFactor: { + type: 'boolean', + description: 'Activate the MFA factor immediately on enrollment', + }, groupName: { type: 'string', description: 'Group name' }, groupDescription: { type: 'string', description: 'Group description' }, sendEmail: { type: 'boolean', description: 'Whether to send email notification' }, + sendDeactivationEmail: { + type: 'boolean', + description: 'Whether to send the deactivation or removal email notification', + }, + q: { type: 'string', description: 'Keyword search query' }, + after: { type: 'string', description: 'Cursor for the next page of users' }, + groupsAfter: { type: 'string', description: 'Cursor for the next page of groups' }, + groupMembersAfter: { type: 'string', description: 'Cursor for the next page of group members' }, + logsAfter: { type: 'string', description: 'Cursor for the next page of System Log events' }, + appsAfter: { type: 'string', description: 'Cursor for the next page of applications' }, + appUsersAfter: { + type: 'string', + description: 'Cursor for the next page of application users', + }, + appGroupsAfter: { + type: 'string', + description: 'Cursor for the next page of application groups', + }, + groupRulesAfter: { type: 'string', description: 'Cursor for the next page of group rules' }, + since: { type: 'string', description: 'Start of the System Log time window' }, + until: { type: 'string', description: 'End of the System Log time window' }, + sortOrder: { type: 'string', description: 'System Log sort order' }, + sessionId: { type: 'string', description: 'Session ID' }, + oauthTokens: { type: 'boolean', description: 'Also revoke OAuth and OIDC tokens' }, + forgetDevices: { type: 'boolean', description: 'Clear remembered factors on all devices' }, + factorId: { type: 'string', description: 'MFA factor ID' }, + factorType: { type: 'string', description: 'MFA factor type to enroll' }, + provider: { type: 'string', description: 'MFA factor provider' }, + phoneNumber: { type: 'string', description: 'Phone number for the sms or call factor' }, + factorEmail: { type: 'string', description: 'Email address for the email factor' }, + securityQuestion: { type: 'string', description: 'Security question key' }, + securityAnswer: { type: 'string', description: 'Security question answer' }, + removeRecoveryEnrollment: { + type: 'boolean', + description: 'Also remove the phone number as a recovery method', + }, + appId: { type: 'string', description: 'Application ID' }, + appUserName: { type: 'string', description: 'Username the user signs in to the app with' }, + scope: { type: 'string', description: 'Application assignment scope' }, + priority: { type: 'number', description: 'Application group assignment priority' }, + includeNonDeleted: { + type: 'boolean', + description: 'Include applications that are not deleted', + }, + roleType: { type: 'string', description: 'Admin role type to assign' }, + customRoleId: { type: 'string', description: 'Custom role ID' }, + resourceSetId: { type: 'string', description: 'Resource set ID for a custom role' }, + disableNotifications: { type: 'boolean', description: 'Grant third-party admin status' }, + roleAssignmentId: { type: 'string', description: 'Role assignment ID to revoke' }, + groupRuleId: { type: 'string', description: 'Group rule ID' }, + ruleName: { type: 'string', description: 'Group rule name' }, + expression: { type: 'string', description: 'Okta expression driving the group rule' }, + assignUserToGroupIds: { + type: 'string', + description: 'Comma-separated group IDs the rule assigns users to', + }, + excludedUserIds: { + type: 'string', + description: 'Comma-separated user IDs excluded from the rule', + }, + removeUsers: { + type: 'boolean', + description: 'Remove users the rule assigned when deleting it', + }, }, outputs: { @@ -420,13 +1242,35 @@ export const OktaBlock: BlockConfig = { name: { type: 'string', description: 'Group name' }, description: { type: 'string', description: 'Group description' }, type: { type: 'string', description: 'Group type' }, + mobilePhone: { type: 'string', description: 'Mobile phone number' }, + secondEmail: { type: 'string', description: 'Secondary email address' }, + displayName: { type: 'string', description: 'Display name' }, + title: { type: 'string', description: 'Job title' }, + department: { type: 'string', description: 'Department' }, + organization: { type: 'string', description: 'Organization' }, + manager: { type: 'string', description: 'Manager name' }, + managerId: { type: 'string', description: 'Manager ID' }, + division: { type: 'string', description: 'Division' }, + employeeNumber: { type: 'string', description: 'Employee number' }, + userType: { type: 'string', description: 'User type' }, + lastLogin: { type: 'string', description: 'Last sign-in timestamp' }, + statusChanged: { type: 'string', description: 'Status change timestamp' }, + passwordChanged: { type: 'string', description: 'Password change timestamp' }, + lastMembershipUpdated: { + type: 'string', + description: 'Timestamp of the last group membership change', + }, count: { type: 'number', description: 'Number of results' }, added: { type: 'boolean', description: 'Whether user was added to group' }, removed: { type: 'boolean', description: 'Whether user was removed from group' }, deactivated: { type: 'boolean', description: 'Whether user was deactivated' }, suspended: { type: 'boolean', description: 'Whether user was suspended' }, unsuspended: { type: 'boolean', description: 'Whether user was unsuspended' }, - activated: { type: 'boolean', description: 'Whether user was activated' }, + activated: { + type: 'string', + description: + 'Activation timestamp on a user read. Activate User reports `true` here instead.', + }, deleted: { type: 'boolean', description: 'Whether resource was deleted' }, activationUrl: { type: 'string', description: 'Activation URL (when sendEmail is false)' }, activationToken: { type: 'string', description: 'Activation token (when sendEmail is false)' }, @@ -436,6 +1280,95 @@ export const OktaBlock: BlockConfig = { }, created: { type: 'string', description: 'Creation timestamp' }, lastUpdated: { type: 'string', description: 'Last update timestamp' }, + events: { + type: 'json', + description: + 'Array of System Log events (uuid, published, eventType, severity, displayMessage, outcomeResult, actor and client details, targets)', + }, + factors: { + type: 'json', + description: + 'Array of enrolled MFA factors (id, factorType, provider, vendorName, status, created, lastUpdated, profile)', + }, + apps: { + type: 'json', + description: + 'Array of applications (id, name, label, status, signOnMode, features, created, lastUpdated)', + }, + appUsers: { + type: 'json', + description: + 'Array of application user assignments (id, externalId, scope, status, syncState, userName, profile)', + }, + appGroups: { + type: 'json', + description: 'Array of application group assignments (id, priority, lastUpdated, profile)', + }, + roles: { + type: 'json', + description: + 'Array of admin role assignments (id, label, type, status, assignmentType, role, resourceSet)', + }, + rules: { + type: 'json', + description: + 'Array of group rules (id, name, type, status, expression, assignUserToGroupIds, excludedUserIds)', + }, + profile: { type: 'json', description: 'Factor, application, or app user profile attributes' }, + settings: { type: 'json', description: 'Application settings' }, + visibility: { type: 'json', description: 'Application visibility settings' }, + accessibility: { type: 'json', description: 'Application accessibility settings' }, + assignUserToGroupIds: { type: 'json', description: 'Groups a rule assigns matching users to' }, + excludedUserIds: { type: 'json', description: 'Users excluded from a group rule' }, + excludedGroupIds: { + type: 'json', + description: + 'Groups excluded from a group rule. Always empty — Okta does not currently support group exclusions.', + }, + amr: { type: 'json', description: 'Authentication methods used to establish a session' }, + features: { type: 'json', description: 'Provisioning features enabled on an application' }, + label: { type: 'string', description: 'Application or role label' }, + signOnMode: { type: 'string', description: 'Application sign-on mode' }, + factorType: { type: 'string', description: 'MFA factor type' }, + provider: { type: 'string', description: 'MFA factor provider' }, + vendorName: { type: 'string', description: 'MFA factor vendor name' }, + scope: { type: 'string', description: 'Application assignment scope' }, + externalId: { type: 'string', description: 'ID of the user in the downstream application' }, + syncState: { type: 'string', description: 'Application provisioning sync state' }, + lastSync: { type: 'string', description: 'Last application provisioning sync' }, + userName: { type: 'string', description: 'Username the user signs in to the application with' }, + priority: { type: 'number', description: 'Application group assignment priority' }, + assignmentType: { type: 'string', description: 'How an admin role was assigned' }, + role: { type: 'string', description: 'Custom role ID' }, + resourceSet: { type: 'string', description: 'Resource set ID for a custom role' }, + expression: { type: 'string', description: 'Okta expression driving a group rule' }, + expressionType: { type: 'string', description: 'Group rule expression language' }, + userId: { type: 'string', description: 'User ID the operation acted on' }, + groupId: { type: 'string', description: 'Group ID the operation acted on' }, + appId: { type: 'string', description: 'Application ID the operation acted on' }, + factorId: { type: 'string', description: 'Factor ID the operation acted on' }, + sessionId: { type: 'string', description: 'Session ID the operation acted on' }, + groupRuleId: { type: 'string', description: 'Group rule ID the operation acted on' }, + roleAssignmentId: { type: 'string', description: 'Role assignment ID that was revoked' }, + createdAt: { type: 'string', description: 'Session creation timestamp' }, + expiresAt: { type: 'string', description: 'Session expiry timestamp' }, + lastPasswordVerification: { + type: 'string', + description: 'Timestamp of the last password verification', + }, + lastFactorVerification: { + type: 'string', + description: 'Timestamp of the last factor verification', + }, + idpId: { type: 'string', description: 'Identity provider ID' }, + idpType: { type: 'string', description: 'Identity provider type' }, + nextCursor: { type: 'string', description: 'Cursor for the next page of results' }, + hasMore: { type: 'boolean', description: 'Whether more results are available' }, + assigned: { type: 'boolean', description: 'Whether the assignment was created' }, + enrolled: { type: 'boolean', description: 'Whether the factor was enrolled' }, + reset: { type: 'boolean', description: 'Whether the factors were reset' }, + cleared: { type: 'boolean', description: 'Whether the sessions were cleared' }, + revoked: { type: 'boolean', description: 'Whether the session was revoked' }, success: { type: 'boolean', description: 'Operation success status' }, }, } @@ -532,6 +1465,24 @@ export const OktaBlockMeta = { content: '# Audit Group Membership\n\nReview who belongs to Okta groups, focusing on privileged access.\n\n## Steps\n1. Run List Groups to enumerate the groups, or Get Group for a specific one.\n2. For each group of interest, run List Group Members.\n3. Highlight privileged or admin groups and call out any unexpected members.\n\n## Output\nA per-group roster with member counts, and a short list of access concerns to review.', }, + { + name: 'reset-user-mfa', + description: 'Reset a locked-out user MFA enrollment so they can enroll a factor again.', + content: + '# Reset User MFA\n\nClear a stuck multifactor enrollment, the most common Okta help desk request.\n\n## Steps\n1. Run List Factors for the user to see which factors are enrolled and their status.\n2. Reset the narrowest thing that fixes it: Reset Factor for one factor id, or Reset All Factors when every enrollment must go.\n3. Note that resetting push also unenrolls the related Okta Verify factors, and that factors cannot be reset on a deactivated user.\n4. Run Clear User Sessions if the user must be signed out of existing sessions before re-enrolling.\n\n## Output\nName the factors that were reset and state that the user must re-enroll before they can complete MFA again. Both resets are irreversible, so confirm the target user before running either.', + }, + { + name: 'investigate-sign-in-failures', + description: 'Query the Okta System Log for failed sign-ins and suspicious authentication.', + content: + '# Investigate Sign-In Failures\n\nUse the System Log to explain why a user cannot sign in, or to review suspicious authentication.\n\n## Steps\n1. Run Get System Log Events with a Since and Until that bracket the incident.\n2. Filter to the events that matter, for example eventType eq "user.session.start" and outcome.result eq "FAILURE" for failed sign-ins.\n3. Narrow to one person or origin by adding actor.alternateId or client.ipAddress to the filter, or use the keyword query for a free-text sweep.\n4. Read outcomeReason, clientIpAddress, and the client geography on each event to separate a wrong password from an unexpected location.\n5. Page with the returned cursor while hasMore is true when the window is wide.\n\n## Output\nA short timeline of the matching events with actor, time, outcome, reason, and source IP, plus a plain statement of the likely cause.', + }, + { + name: 'review-app-access', + description: 'Audit who can reach an Okta application through direct and group assignments.', + content: + '# Review Application Access\n\nEstablish who has access to an application and how they got it.\n\n## Steps\n1. Run List Applications to resolve the application id, or Get Application when the id is known.\n2. Run List Application Users and read the scope on each assignment: USER means a direct grant, GROUP means it was inherited.\n3. Run List Application Groups to see which groups confer access, since every member of those groups reaches the app.\n4. Expand any group of interest with List Group Members to get the real roster.\n5. Revoke with Remove User from Application for a direct grant, or Remove Group from Application to cut the whole group.\n\n## Output\nA roster split into direct and group-inherited access, naming the groups that grant it, and a list of assignments that look unjustified.', + }, { name: 'reset-user-password', description: 'Trigger an Okta password reset for a user who is locked out.', diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index d3252b25438..1aeaf91c8d5 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -484,6 +484,7 @@ export const PiBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, paramVisibility: 'user-only', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', required: { diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts index d3867e13df0..74fad7758a5 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -138,6 +138,7 @@ export const SecretsManagerBlock: BlockConfig = { id: 'secretValue', title: 'Secret Value', type: 'code', + password: true, placeholder: '{"username":"admin","password":"secret123"}', condition: { field: 'operation', value: ['create_secret', 'update_secret'] }, required: { field: 'operation', value: ['create_secret', 'update_secret'] }, diff --git a/apps/sim/blocks/blocks/servicenow.ts b/apps/sim/blocks/blocks/servicenow.ts index 087c1f577e5..561260caeea 100644 --- a/apps/sim/blocks/blocks/servicenow.ts +++ b/apps/sim/blocks/blocks/servicenow.ts @@ -2,6 +2,18 @@ import { ServiceNowIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' +import { + APPROVAL_DECISION_OPTIONS, + APPROVAL_STATE, + CHANGE_CLOSE_CODE_OPTIONS, + CHANGE_STATE_OPTIONS, + CHANGE_TYPE_OPTIONS, + COMMENT_FIELD_OPTIONS, + DISPLAY_VALUE_OPTIONS, + IMPACT_URGENCY_OPTIONS, + INCIDENT_STATE_OPTIONS, + PRIORITY_OPTIONS, +} from '@/tools/servicenow/constants' import type { ServiceNowResponse } from '@/tools/servicenow/types' import { getTrigger } from '@/triggers' @@ -9,6 +21,146 @@ const FILE_FIELD = ['uploadFile', 'fileReference'] as const const RECORD_MATCH_FIELD = ['sysId', 'number', 'query'] as const +/** JSON-valued subblocks, paired with the control label to name in a parse error. */ +const JSON_SUBBLOCKS = [ + ['additionalFields', 'Additional Fields'], + ['variables', 'Item Variables'], +] as const + +/** + * Parses a JSON-valued subblock, naming the control the typo is in. + * + * `tools.config.params` runs unguarded, so a bare `JSON.parse` escapes as + * `JSON Parse error: Expected '}'` with nothing pointing at the field to fix. + */ +function parseJsonSubBlock(value: string, label: string): unknown { + try { + return JSON.parse(value) + } catch { + throw new Error(`${label} must be a JSON object`) + } +} + +/** Generic Table API operations, which address an arbitrary table by name. */ +const GENERIC_TABLE_OPS = [ + 'servicenow_create_record', + 'servicenow_read_record', + 'servicenow_update_record', + 'servicenow_delete_record', + 'servicenow_aggregate', + 'servicenow_list_attachments', + 'servicenow_upload_attachment', +] as const + +/** Semantic operations that fetch exactly one record by number or sys_id. */ +const SEMANTIC_GET_OPS = [ + 'servicenow_get_incident', + 'servicenow_get_change_request', + 'servicenow_get_requested_item', +] as const + +/** Semantic operations that return a filtered list of Table API records. */ +const SEMANTIC_LIST_OPS = [ + 'servicenow_list_incidents', + 'servicenow_list_change_requests', + 'servicenow_list_requested_items', + 'servicenow_list_approvals', + 'servicenow_search_cis', + 'servicenow_list_ci_relationships', + 'servicenow_find_user', + 'servicenow_list_group_members', +] as const + +/** Semantic operations that create a record. */ +const SEMANTIC_CREATE_OPS = [ + 'servicenow_create_incident', + 'servicenow_create_change_request', +] as const + +/** Semantic operations that update a record addressed by sys_id. */ +const SEMANTIC_UPDATE_OPS = [ + 'servicenow_update_incident', + 'servicenow_resolve_incident', + 'servicenow_close_incident', + 'servicenow_add_incident_comment', + 'servicenow_update_change_request', + 'servicenow_update_change_state', +] as const + +/** Every operation that writes through the Table API. */ +const SEMANTIC_WRITE_OPS = [...SEMANTIC_CREATE_OPS, ...SEMANTIC_UPDATE_OPS] as const + +/** + * Every operation whose tool spreads `writeParams` — `fields`, `displayValue`, + * and `inputDisplayValue`. + * + * `update_approval` addresses its record by `approvalSysId` rather than the + * shared `sysId`, so it stays out of `SEMANTIC_UPDATE_OPS`, but it declares the + * same write params. Leaving it out of these controls made it the one operation + * where a stale advanced `displayValue`/`returnFields` reached the wire unseen. + */ +const SEMANTIC_WRITE_PARAM_OPS = [...SEMANTIC_WRITE_OPS, 'servicenow_update_approval'] as const + +/** + * Write operations that accept the raw `additionalFields` escape hatch. Excludes + * `add_incident_comment`, whose body is exactly one journal field, so a value + * entered there would be silently discarded. + */ +const ADDITIONAL_FIELDS_OPS = SEMANTIC_WRITE_OPS.filter( + (op) => op !== 'servicenow_add_incident_comment' +) + +/** Every operation that reads through the Table API. */ +const SEMANTIC_READ_OPS = [...SEMANTIC_GET_OPS, ...SEMANTIC_LIST_OPS] as const + +/** Operations that accept a `limit`/`offset` pair of some kind. */ +const PAGINATED_OPS = [ + ...SEMANTIC_LIST_OPS, + 'servicenow_list_change_tasks', + 'servicenow_list_catalog_items', + 'servicenow_search_knowledge', +] as const + +/** + * Operations whose `sysparm_display_value` comes from the semantic `all`-defaulted + * control rather than the generic Table API one, which stays unset by default so + * the pre-existing generic tools keep their original wire behavior. + */ +const SEMANTIC_DISPLAY_VALUE_OPS: ReadonlySet = new Set([ + ...SEMANTIC_READ_OPS, + ...SEMANTIC_WRITE_PARAM_OPS, +]) + +/** Operations whose tool takes a `state`, from whichever control owns that state model. */ +const SEMANTIC_STATE_OPS: ReadonlySet = new Set([ + 'servicenow_create_incident', + 'servicenow_list_incidents', + 'servicenow_update_incident', + 'servicenow_list_change_requests', + 'servicenow_update_change_request', + 'servicenow_update_change_state', + 'servicenow_list_approvals', +]) + +/** Operations whose `state` comes from the change model rather than the incident one. */ +const CHANGE_STATE_OPS: ReadonlySet = new Set([ + 'servicenow_list_change_requests', + 'servicenow_update_change_request', +]) + +/** Operations whose close code and notes are the change model's, not an incident's. */ +const CHANGE_CLOSE_OPS: ReadonlySet = new Set([ + 'servicenow_update_change_request', + 'servicenow_update_change_state', +]) + +const OPTIONAL_CHOICE = { label: 'Any (not set)', id: '' } as const + +const optionalChoices = (options: readonly T[]) => [ + OPTIONAL_CHOICE, + ...options, +] + export const ServiceNowBlock: BlockConfig = { type: 'servicenow', name: 'ServiceNow', @@ -71,6 +223,132 @@ export const ServiceNowBlock: BlockConfig = { { text: 'to record', field: 'recordSysId', core: true }, { text: 'in', field: 'tableName' }, ], + servicenow_create_incident: [ + { text: 'Create an incident', field: 'shortDescription', core: true }, + { text: 'for caller', field: 'callerId' }, + { text: ', assigned to', field: 'assignmentGroup' }, + ], + servicenow_get_incident: [{ text: 'Get incident', field: ['number', 'sysId'], core: true }], + servicenow_list_incidents: [ + 'List incidents', + { text: 'matching', field: 'searchText', core: true }, + { text: ', in state', field: 'incidentState' }, + { text: ', up to', field: 'limit' }, + ], + servicenow_update_incident: [ + { text: 'Update incident', field: 'sysId', core: true }, + { text: ', setting state', field: 'incidentState' }, + { text: ', assigning to', field: 'assignedTo' }, + ], + servicenow_resolve_incident: [ + { text: 'Resolve incident', field: 'sysId', core: true }, + { text: 'with code', field: 'resolutionCode' }, + { text: ', noting', field: 'resolutionNotes' }, + ], + servicenow_close_incident: [ + { text: 'Close incident', field: 'sysId', core: true }, + { text: 'with code', field: 'resolutionCode' }, + { text: ', noting', field: 'resolutionNotes' }, + ], + servicenow_add_incident_comment: [ + { text: 'Comment on incident', field: 'sysId', core: true }, + { text: 'in', field: 'commentField' }, + { text: ', saying', field: 'comment' }, + ], + servicenow_create_change_request: [ + { text: 'Create a change request', field: 'shortDescription', core: true }, + { text: 'of type', field: 'type' }, + { text: ', assigned to', field: 'assignmentGroup' }, + ], + servicenow_get_change_request: [ + { text: 'Get change request', field: ['number', 'sysId'], core: true }, + ], + servicenow_list_change_requests: [ + 'List change requests', + { text: 'matching', field: 'searchText', core: true }, + { text: ', in state', field: 'changeState' }, + { text: ', up to', field: 'limit' }, + ], + servicenow_update_change_request: [ + { text: 'Update change request', field: 'sysId', core: true }, + { text: ', setting state', field: 'changeState' }, + { text: ', assigning to', field: 'assignedTo' }, + ], + servicenow_update_change_state: [ + { text: 'Move change request', field: 'sysId', core: true }, + { text: 'to state', field: 'targetState', core: true }, + ], + servicenow_list_change_tasks: [ + { text: 'List tasks on change request', field: 'changeSysId', core: true }, + { text: ', matching', field: 'query' }, + ], + servicenow_get_change_next_states: [ + { + text: 'Get the next available states for change request', + field: 'changeSysId', + core: true, + }, + ], + servicenow_list_catalog_items: [ + 'List catalog items', + { text: 'matching', field: 'searchText', core: true }, + { text: ', in category', field: 'categorySysId' }, + ], + servicenow_order_catalog_item: [ + { text: 'Order catalog item', field: 'catalogItemSysId', core: true }, + { text: ', quantity', field: 'quantity' }, + { text: ', for', field: 'requestedFor' }, + ], + servicenow_list_requested_items: [ + 'List requested items', + { text: 'on request', field: 'requestSysId', core: true }, + { text: ', for catalog item', field: 'catalogItemSysId' }, + ], + servicenow_get_requested_item: [ + { text: 'Get requested item', field: ['number', 'sysId'], core: true }, + ], + servicenow_list_approvals: [ + 'List approvals', + { text: 'for approver', field: 'approverSysId', core: true }, + { text: ', in state', field: 'approvalState' }, + ], + servicenow_update_approval: [ + { text: 'Record decision', field: 'decision', core: true }, + { text: 'on approval', field: 'approvalSysId', core: true }, + ], + servicenow_search_cis: [ + 'Search configuration items', + { text: 'named', field: 'name', core: true }, + { text: ', in class', field: 'ciClass' }, + { text: ', up to', field: 'limit' }, + ], + servicenow_get_ci: [ + { text: 'Get configuration item', field: 'sysId', core: true }, + { text: 'in class', field: 'ciClass' }, + ], + servicenow_list_ci_relationships: [ + { text: 'List relationships for CI', field: 'ciSysId', core: true }, + { text: ', on the', field: 'direction' }, + ], + servicenow_search_knowledge: [ + 'Search knowledge articles', + { text: 'for', field: 'knowledgeQuery', core: true }, + { text: ', up to', field: 'limit' }, + ], + servicenow_get_knowledge_article: [ + { text: 'Get knowledge article', field: 'articleId', core: true }, + ], + servicenow_find_user: [ + 'Find users', + { text: 'by email', field: 'email', core: true }, + { text: ', by user name', field: 'userName' }, + { text: ', by name', field: 'name' }, + ], + servicenow_list_group_members: [ + 'List members of group', + { text: 'named', field: 'groupName', core: true }, + { text: ', with sys_id', field: 'groupSysId' }, + ], }, }, }, @@ -81,6 +359,33 @@ export const ServiceNowBlock: BlockConfig = { title: 'Operation', type: 'dropdown', options: [ + { label: 'Create Incident', id: 'servicenow_create_incident' }, + { label: 'Get Incident', id: 'servicenow_get_incident' }, + { label: 'List Incidents', id: 'servicenow_list_incidents' }, + { label: 'Update Incident', id: 'servicenow_update_incident' }, + { label: 'Resolve Incident', id: 'servicenow_resolve_incident' }, + { label: 'Close Incident', id: 'servicenow_close_incident' }, + { label: 'Add Incident Comment', id: 'servicenow_add_incident_comment' }, + { label: 'Create Change Request', id: 'servicenow_create_change_request' }, + { label: 'Get Change Request', id: 'servicenow_get_change_request' }, + { label: 'List Change Requests', id: 'servicenow_list_change_requests' }, + { label: 'Update Change Request', id: 'servicenow_update_change_request' }, + { label: 'Move Change State', id: 'servicenow_update_change_state' }, + { label: 'List Change Tasks', id: 'servicenow_list_change_tasks' }, + { label: 'Get Change Next States', id: 'servicenow_get_change_next_states' }, + { label: 'List Catalog Items', id: 'servicenow_list_catalog_items' }, + { label: 'Order Catalog Item', id: 'servicenow_order_catalog_item' }, + { label: 'List Requested Items', id: 'servicenow_list_requested_items' }, + { label: 'Get Requested Item', id: 'servicenow_get_requested_item' }, + { label: 'List Approvals', id: 'servicenow_list_approvals' }, + { label: 'Approve or Reject', id: 'servicenow_update_approval' }, + { label: 'Search Configuration Items', id: 'servicenow_search_cis' }, + { label: 'Get Configuration Item', id: 'servicenow_get_ci' }, + { label: 'List CI Relationships', id: 'servicenow_list_ci_relationships' }, + { label: 'Search Knowledge', id: 'servicenow_search_knowledge' }, + { label: 'Get Knowledge Article', id: 'servicenow_get_knowledge_article' }, + { label: 'Find User', id: 'servicenow_find_user' }, + { label: 'List Group Members', id: 'servicenow_list_group_members' }, { label: 'Create Record', id: 'servicenow_create_record' }, { label: 'Read Records', id: 'servicenow_read_record' }, { label: 'Update Record', id: 'servicenow_update_record' }, @@ -90,7 +395,7 @@ export const ServiceNowBlock: BlockConfig = { { label: 'Download Attachment', id: 'servicenow_download_attachment' }, { label: 'Upload Attachment', id: 'servicenow_upload_attachment' }, ], - value: () => 'servicenow_read_record', + value: () => 'servicenow_list_incidents', }, // Instance URL { @@ -126,7 +431,7 @@ export const ServiceNowBlock: BlockConfig = { title: 'Table Name', type: 'short-input', placeholder: 'incident, task, sys_user, etc.', - condition: { field: 'operation', value: 'servicenow_download_attachment', not: true }, + condition: { field: 'operation', value: [...GENERIC_TABLE_OPS] }, required: true, description: 'ServiceNow table name', }, @@ -170,14 +475,20 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Record sys_id', type: 'short-input', placeholder: 'Specific record sys_id (optional)', - condition: { field: 'operation', value: 'servicenow_read_record' }, + condition: { + field: 'operation', + value: ['servicenow_read_record', ...SEMANTIC_GET_OPS], + }, }, { id: 'number', title: 'Record Number', type: 'short-input', placeholder: 'e.g., INC0010001 (optional)', - condition: { field: 'operation', value: 'servicenow_read_record' }, + condition: { + field: 'operation', + value: ['servicenow_read_record', ...SEMANTIC_GET_OPS], + }, }, { id: 'query', @@ -186,9 +497,14 @@ Output: {"short_description": "Network outage", "description": "Network connecti placeholder: 'active=true^priority=1', condition: { field: 'operation', - value: ['servicenow_read_record', 'servicenow_aggregate'], + value: [ + 'servicenow_read_record', + 'servicenow_aggregate', + 'servicenow_list_change_tasks', + ...SEMANTIC_LIST_OPS, + ], }, - description: 'ServiceNow encoded query string', + description: 'ServiceNow encoded query string, ANDed with the other filters', mode: 'advanced', }, { @@ -196,7 +512,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Limit', type: 'short-input', placeholder: '10', - condition: { field: 'operation', value: 'servicenow_read_record' }, + condition: { field: 'operation', value: ['servicenow_read_record', ...PAGINATED_OPS] }, mode: 'advanced', }, { @@ -204,7 +520,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Offset', type: 'short-input', placeholder: '0', - condition: { field: 'operation', value: 'servicenow_read_record' }, + condition: { field: 'operation', value: ['servicenow_read_record', ...PAGINATED_OPS] }, description: 'Number of records to skip for pagination', mode: 'advanced', }, @@ -227,7 +543,29 @@ Output: {"short_description": "Network outage", "description": "Network connecti mode: 'advanced', }, { - id: 'fields', + id: 'semanticDisplayValue', + title: 'Display Value', + type: 'dropdown', + options: [...DISPLAY_VALUE_OPTIONS], + value: () => 'all', + condition: { + field: 'operation', + value: [...SEMANTIC_READ_OPS, ...SEMANTIC_WRITE_PARAM_OPS], + }, + description: + 'How reference and choice fields come back. "all" (the default) returns both the sys_id and the label as {value, display_value}.', + mode: 'advanced', + }, + /** + * Read Records keeps its projection on its own id. + * + * Sharing `fields` with the Create/Update Record JSON bodies meant one + * stored value served two value spaces: a projection selected into Create + * Record threw an uncaught `SyntaxError`, and a JSON body selected into + * Read Records went out as `sysparm_fields=[object Object]`. + */ + { + id: 'readFields', title: 'Fields to Return', type: 'short-input', placeholder: 'number,short_description,priority', @@ -235,6 +573,23 @@ Output: {"short_description": "Network outage", "description": "Network connecti description: 'Comma-separated list of fields', mode: 'advanced', }, + { + id: 'returnFields', + title: 'Fields to Return', + type: 'short-input', + placeholder: 'number,short_description,priority', + condition: { + field: 'operation', + value: [ + 'servicenow_search_knowledge', + 'servicenow_get_knowledge_article', + ...SEMANTIC_READ_OPS, + ...SEMANTIC_WRITE_PARAM_OPS, + ], + }, + description: 'Comma-separated list of fields', + mode: 'advanced', + }, // Update-specific: sysId and fields { id: 'sysId', @@ -346,9 +701,10 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st id: 'having', title: 'Having', type: 'short-input', - placeholder: 'count>5', + placeholder: 'count^priority^>^3', condition: { field: 'operation', value: 'servicenow_aggregate' }, - description: 'Filter on aggregate results', + description: + 'Filter on aggregate results, written as aggregate^field^operator^value and comma-separated for more than one', mode: 'advanced', }, // Attachment record sys_id (list + upload) @@ -415,6 +771,812 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st mode: 'advanced', required: true, }, + // Semantic operations: record being written to + { + id: 'sysId', + title: 'Record sys_id', + type: 'short-input', + placeholder: 'sys_id of the record to update', + condition: { field: 'operation', value: [...SEMANTIC_UPDATE_OPS] }, + required: true, + description: + 'The Table API addresses updates by sys_id. Look the record up first if you only have its number.', + }, + { + id: 'sysId', + title: 'Configuration Item sys_id', + type: 'short-input', + placeholder: 'sys_id of the CI', + condition: { field: 'operation', value: 'servicenow_get_ci' }, + required: true, + }, + // Incident and change: core fields + { + id: 'shortDescription', + title: 'Short Description', + type: 'short-input', + placeholder: 'One-line summary', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_create_change_request', + 'servicenow_update_incident', + 'servicenow_update_change_request', + ], + }, + required: { + field: 'operation', + value: ['servicenow_create_incident', 'servicenow_create_change_request'], + }, + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + placeholder: 'Detailed description', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_create_change_request', + 'servicenow_update_incident', + 'servicenow_update_change_request', + ], + }, + }, + { + id: 'searchText', + title: 'Search Text', + type: 'short-input', + placeholder: 'Text to match in the short description', + condition: { + field: 'operation', + value: [ + 'servicenow_list_incidents', + 'servicenow_list_change_requests', + 'servicenow_list_catalog_items', + ], + }, + description: 'LIKE match — matches anywhere in the field', + }, + // State dropdowns — one per state model + { + id: 'incidentState', + title: 'Incident State', + type: 'combobox', + options: optionalChoices(INCIDENT_STATE_OPTIONS), + value: () => '', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_list_incidents', + 'servicenow_update_incident', + ], + }, + description: + 'Base-system incident states. Instances with a customized state model can use different coded values. Moving to On Hold also needs an On hold reason (Awaiting Caller, Awaiting Change, Awaiting Problem, or Awaiting Vendor), which you set through Additional Fields; Awaiting Caller additionally requires Additional Comments.', + }, + { + id: 'changeState', + title: 'Change State', + type: 'combobox', + options: optionalChoices(CHANGE_STATE_OPTIONS), + value: () => '', + condition: { + field: 'operation', + value: ['servicenow_list_change_requests', 'servicenow_update_change_request'], + }, + description: + 'Base-system change model states. Instances with a customized state model can use different coded values.', + }, + { + id: 'targetState', + title: 'Target State', + type: 'combobox', + options: [...CHANGE_STATE_OPTIONS], + /** + * Deliberately unseeded. A seeded first option ("New") meant running the + * operation untouched moved the change backwards; `required` now forces an + * explicit choice instead. + */ + condition: { field: 'operation', value: 'servicenow_update_change_state' }, + required: true, + description: + 'The change state machine rejects a transition whose conditions have not been met.', + }, + { + id: 'approvalState', + title: 'Approval State', + type: 'combobox', + options: [ + { label: 'Any (not set)', id: '' }, + { label: 'Requested (pending)', id: APPROVAL_STATE.REQUESTED }, + { label: 'Approved', id: APPROVAL_STATE.APPROVED }, + { label: 'Rejected', id: APPROVAL_STATE.REJECTED }, + ], + value: () => APPROVAL_STATE.REQUESTED, + condition: { field: 'operation', value: 'servicenow_list_approvals' }, + description: + 'ServiceNow publishes coded values only for these three. An instance that defines further approval states can be filtered by typing the raw value.', + }, + // Prioritization + { + id: 'impact', + title: 'Impact', + type: 'combobox', + options: optionalChoices(IMPACT_URGENCY_OPTIONS), + value: () => '', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_update_incident', + 'servicenow_create_change_request', + 'servicenow_update_change_request', + ], + }, + }, + { + id: 'urgency', + title: 'Urgency', + type: 'combobox', + options: optionalChoices(IMPACT_URGENCY_OPTIONS), + value: () => '', + condition: { + field: 'operation', + value: ['servicenow_create_incident', 'servicenow_update_incident'], + }, + }, + { + id: 'priority', + title: 'Priority', + type: 'combobox', + options: optionalChoices(PRIORITY_OPTIONS), + value: () => '', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_list_incidents', + 'servicenow_update_incident', + 'servicenow_create_change_request', + 'servicenow_update_change_request', + ], + }, + description: 'Normally derived from impact and urgency.', + mode: 'advanced', + }, + // Categorization + { + id: 'category', + title: 'Category', + type: 'short-input', + placeholder: 'e.g., network', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_update_incident', + 'servicenow_create_change_request', + ], + }, + mode: 'advanced', + }, + { + id: 'subcategory', + title: 'Subcategory', + type: 'short-input', + placeholder: 'e.g., dhcp', + condition: { + field: 'operation', + value: ['servicenow_create_incident', 'servicenow_update_incident'], + }, + mode: 'advanced', + }, + { + id: 'contactType', + title: 'Channel', + type: 'short-input', + placeholder: 'e.g., email, phone, self-service', + condition: { field: 'operation', value: 'servicenow_create_incident' }, + description: 'The contact_type field', + mode: 'advanced', + }, + // Reference fields + { + id: 'callerId', + title: 'Caller sys_id', + type: 'short-input', + placeholder: 'sys_id of the sys_user who reported the issue', + condition: { + field: 'operation', + value: ['servicenow_create_incident', 'servicenow_list_incidents'], + }, + description: 'Use Find User to resolve an email address to a sys_id.', + }, + { + id: 'assignmentGroup', + title: 'Assignment Group sys_id', + type: 'short-input', + placeholder: 'sys_id of the sys_user_group', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_list_incidents', + 'servicenow_update_incident', + 'servicenow_create_change_request', + 'servicenow_list_change_requests', + 'servicenow_update_change_request', + ], + }, + }, + { + id: 'assignedTo', + title: 'Assigned To sys_id', + type: 'short-input', + placeholder: 'sys_id of the sys_user', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_list_incidents', + 'servicenow_update_incident', + 'servicenow_create_change_request', + 'servicenow_list_change_requests', + 'servicenow_update_change_request', + ], + }, + }, + { + id: 'cmdbCi', + title: 'Configuration Item sys_id', + type: 'short-input', + placeholder: 'sys_id of the affected CI', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_update_incident', + 'servicenow_create_change_request', + ], + }, + mode: 'advanced', + }, + { + id: 'businessService', + title: 'Service sys_id', + type: 'short-input', + placeholder: 'sys_id of the affected business service', + condition: { field: 'operation', value: 'servicenow_create_incident' }, + mode: 'advanced', + }, + { + id: 'requestedBy', + title: 'Requested By sys_id', + type: 'short-input', + placeholder: 'sys_id of the sys_user', + condition: { field: 'operation', value: 'servicenow_create_change_request' }, + mode: 'advanced', + }, + { + id: 'active', + title: 'Active', + type: 'dropdown', + options: [ + { label: 'Any (not set)', id: '' }, + { label: 'Active only', id: 'true' }, + { label: 'Inactive only', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: [ + 'servicenow_list_incidents', + 'servicenow_list_change_requests', + 'servicenow_list_requested_items', + 'servicenow_find_user', + ], + }, + mode: 'advanced', + }, + // Journal fields + { + id: 'workNotes', + title: 'Work Notes', + type: 'long-input', + placeholder: 'Internal note', + condition: { + field: 'operation', + value: [ + 'servicenow_create_incident', + 'servicenow_update_incident', + 'servicenow_resolve_incident', + 'servicenow_close_incident', + 'servicenow_update_change_request', + 'servicenow_update_change_state', + ], + }, + mode: 'advanced', + }, + { + id: 'incidentComments', + title: 'Additional Comments', + type: 'long-input', + placeholder: 'Customer-visible comment', + condition: { + field: 'operation', + value: ['servicenow_create_incident', 'servicenow_update_incident'], + }, + mode: 'advanced', + }, + { + id: 'approvalComments', + title: 'Approval Comments', + type: 'long-input', + placeholder: 'Reason for the decision', + condition: { field: 'operation', value: 'servicenow_update_approval' }, + }, + { + id: 'comment', + title: 'Comment', + type: 'long-input', + placeholder: 'Text to append to the journal field', + condition: { field: 'operation', value: 'servicenow_add_incident_comment' }, + required: true, + }, + { + id: 'commentField', + title: 'Journal Field', + type: 'dropdown', + options: [...COMMENT_FIELD_OPTIONS], + value: () => COMMENT_FIELD_OPTIONS[0].id, + condition: { field: 'operation', value: 'servicenow_add_incident_comment' }, + }, + // Resolution and closure + { + id: 'resolutionCode', + title: 'Resolution Code', + type: 'short-input', + placeholder: 'A close_code choice configured on your instance', + condition: { + field: 'operation', + value: ['servicenow_resolve_incident', 'servicenow_close_incident'], + }, + required: true, + description: 'The incident close_code choice list is configured per instance.', + }, + { + id: 'changeCloseCode', + title: 'Close Code', + type: 'combobox', + options: optionalChoices(CHANGE_CLOSE_CODE_OPTIONS), + value: () => '', + condition: { + field: 'operation', + value: ['servicenow_update_change_request', 'servicenow_update_change_state'], + }, + description: 'Required when moving a change request to Closed.', + }, + { + id: 'resolutionNotes', + title: 'Resolution Notes', + type: 'long-input', + placeholder: 'How the record was resolved', + condition: { + field: 'operation', + value: ['servicenow_resolve_incident', 'servicenow_close_incident'], + }, + required: true, + }, + { + id: 'changeCloseNotes', + title: 'Close Notes', + type: 'long-input', + placeholder: 'Outcome of the change', + condition: { + field: 'operation', + value: ['servicenow_update_change_request', 'servicenow_update_change_state'], + }, + }, + // Change-specific + { + id: 'type', + title: 'Change Type', + type: 'combobox', + options: optionalChoices(CHANGE_TYPE_OPTIONS), + value: () => '', + condition: { + field: 'operation', + value: ['servicenow_create_change_request', 'servicenow_list_change_requests'], + }, + }, + { + id: 'risk', + title: 'Risk', + type: 'short-input', + placeholder: 'Risk coded value', + condition: { + field: 'operation', + value: [ + 'servicenow_create_change_request', + 'servicenow_list_change_requests', + 'servicenow_update_change_request', + ], + }, + description: 'The risk choice list is configured per instance.', + mode: 'advanced', + }, + { + id: 'startDate', + title: 'Planned Start', + type: 'short-input', + placeholder: '2026-01-15 09:00:00', + condition: { + field: 'operation', + value: ['servicenow_create_change_request', 'servicenow_update_change_request'], + }, + description: 'UTC, formatted as YYYY-MM-DD HH:mm:ss', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a ServiceNow date-time string formatted as "YYYY-MM-DD HH:mm:ss" in UTC. Return ONLY the string.', + generationType: 'timestamp', + }, + }, + { + id: 'endDate', + title: 'Planned End', + type: 'short-input', + placeholder: '2026-01-15 11:00:00', + condition: { + field: 'operation', + value: ['servicenow_create_change_request', 'servicenow_update_change_request'], + }, + description: 'UTC, formatted as YYYY-MM-DD HH:mm:ss', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a ServiceNow date-time string formatted as "YYYY-MM-DD HH:mm:ss" in UTC. Return ONLY the string.', + generationType: 'timestamp', + }, + }, + { + id: 'justification', + title: 'Justification', + type: 'long-input', + placeholder: 'Why the change is needed', + condition: { field: 'operation', value: 'servicenow_create_change_request' }, + mode: 'advanced', + }, + { + id: 'implementationPlan', + title: 'Implementation Plan', + type: 'long-input', + placeholder: 'Steps to implement the change', + condition: { field: 'operation', value: 'servicenow_create_change_request' }, + mode: 'advanced', + }, + { + id: 'backoutPlan', + title: 'Backout Plan', + type: 'long-input', + placeholder: 'Steps to roll the change back', + condition: { field: 'operation', value: 'servicenow_create_change_request' }, + mode: 'advanced', + }, + { + id: 'testPlan', + title: 'Test Plan', + type: 'long-input', + placeholder: 'How the change is validated', + condition: { field: 'operation', value: 'servicenow_create_change_request' }, + mode: 'advanced', + }, + { + id: 'changeSysId', + title: 'Change Request sys_id', + type: 'short-input', + placeholder: 'sys_id of the change request', + condition: { + field: 'operation', + value: ['servicenow_list_change_tasks', 'servicenow_get_change_next_states'], + }, + required: true, + }, + { + id: 'order', + title: 'Sort By', + type: 'short-input', + placeholder: 'number', + condition: { field: 'operation', value: 'servicenow_list_change_tasks' }, + description: 'Field to sort the returned tasks by', + mode: 'advanced', + }, + // Service catalog + { + id: 'catalogSysId', + title: 'Catalog sys_id', + type: 'short-input', + placeholder: 'Restrict to one catalog', + condition: { field: 'operation', value: 'servicenow_list_catalog_items' }, + mode: 'advanced', + }, + { + id: 'categorySysId', + title: 'Category sys_id', + type: 'short-input', + placeholder: 'Restrict to one category', + condition: { field: 'operation', value: 'servicenow_list_catalog_items' }, + mode: 'advanced', + }, + { + id: 'catalogItemSysId', + title: 'Catalog Item sys_id', + type: 'short-input', + placeholder: 'sys_id from List Catalog Items', + condition: { + field: 'operation', + value: ['servicenow_order_catalog_item', 'servicenow_list_requested_items'], + }, + required: { field: 'operation', value: 'servicenow_order_catalog_item' }, + }, + { + id: 'quantity', + title: 'Quantity', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: 'servicenow_order_catalog_item' }, + }, + { + id: 'requestedFor', + title: 'Requested For sys_id', + type: 'short-input', + placeholder: 'sys_id of the sys_user the item is for', + condition: { field: 'operation', value: 'servicenow_order_catalog_item' }, + description: + 'Ordering on behalf of another user is governed by the glide.sc.req_for.roles instance properties.', + }, + { + id: 'variables', + title: 'Item Variables (JSON)', + type: 'code', + language: 'json', + placeholder: '{\n "data_plan": "500MB"\n}', + condition: { field: 'operation', value: 'servicenow_order_catalog_item' }, + description: 'Every variable the catalog item marks mandatory must be supplied.', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON object of ServiceNow catalog item variable name-value pairs based on the request. Output ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'requestSysId', + title: 'Request sys_id', + type: 'short-input', + placeholder: 'sys_id of the parent sc_request', + condition: { field: 'operation', value: 'servicenow_list_requested_items' }, + }, + // Approvals + { + id: 'approverSysId', + title: 'Approver sys_id', + type: 'short-input', + placeholder: 'sys_id of the sys_user', + condition: { field: 'operation', value: 'servicenow_list_approvals' }, + description: 'Use Find User to resolve an email address to a sys_id.', + }, + { + id: 'approvalFor', + title: 'Approving Record sys_id', + type: 'short-input', + placeholder: 'sys_id of the record being approved', + condition: { field: 'operation', value: 'servicenow_list_approvals' }, + description: 'Matched against the sysapproval reference field.', + mode: 'advanced', + }, + { + id: 'approvalSysId', + title: 'Approval sys_id', + type: 'short-input', + placeholder: 'sys_id on the sysapproval_approver table', + condition: { field: 'operation', value: 'servicenow_update_approval' }, + required: true, + }, + { + id: 'decision', + title: 'Decision', + type: 'dropdown', + options: [...APPROVAL_DECISION_OPTIONS], + /** + * Deliberately unseeded. Seeding the first option defaulted the operation + * to Approve, so `required` forces the caller to pick a decision. + */ + condition: { field: 'operation', value: 'servicenow_update_approval' }, + required: true, + }, + // CMDB + { + id: 'ciClass', + title: 'CI Class', + type: 'short-input', + placeholder: 'cmdb_ci_linux_server', + condition: { + field: 'operation', + value: ['servicenow_search_cis', 'servicenow_get_ci'], + }, + required: { field: 'operation', value: 'servicenow_get_ci' }, + description: 'Read it from the sys_class_name field on a CI record.', + }, + { + id: 'name', + title: 'Name', + type: 'short-input', + placeholder: 'Text to match in the name', + condition: { + field: 'operation', + value: ['servicenow_search_cis', 'servicenow_find_user'], + }, + description: 'LIKE match — matches anywhere in the field', + }, + { + id: 'operationalStatus', + title: 'Operational Status', + type: 'short-input', + placeholder: 'Coded value', + condition: { field: 'operation', value: 'servicenow_search_cis' }, + description: 'The operational_status choice list is configured per instance.', + mode: 'advanced', + }, + { + id: 'ciSysId', + title: 'Configuration Item sys_id', + type: 'short-input', + placeholder: 'sys_id of the CI', + condition: { field: 'operation', value: 'servicenow_list_ci_relationships' }, + required: true, + }, + { + id: 'direction', + title: 'Direction', + type: 'dropdown', + options: [ + { label: 'Both sides', id: 'both' }, + { label: 'CI is the parent', id: 'parent' }, + { label: 'CI is the child', id: 'child' }, + ], + value: () => 'both', + condition: { field: 'operation', value: 'servicenow_list_ci_relationships' }, + }, + // Knowledge + { + id: 'knowledgeQuery', + title: 'Search Text', + type: 'short-input', + placeholder: 'vpn setup', + condition: { field: 'operation', value: 'servicenow_search_knowledge' }, + description: 'Text searched across knowledge articles', + }, + { + id: 'filter', + title: 'Article Filter', + type: 'short-input', + placeholder: 'workflow_state=published', + condition: { field: 'operation', value: 'servicenow_search_knowledge' }, + description: 'Encoded query against the Knowledge [kb_knowledge] table', + mode: 'advanced', + }, + { + id: 'knowledgeBaseSysIds', + title: 'Knowledge Base sys_ids', + type: 'short-input', + placeholder: 'Comma-separated kb_knowledge_base sys_ids', + condition: { field: 'operation', value: 'servicenow_search_knowledge' }, + mode: 'advanced', + }, + { + id: 'language', + title: 'Language', + type: 'short-input', + placeholder: 'en', + condition: { + field: 'operation', + value: ['servicenow_search_knowledge', 'servicenow_get_knowledge_article'], + }, + description: 'ISO 639-1 language codes, or "all" when searching', + mode: 'advanced', + }, + { + id: 'articleId', + title: 'Article sys_id or KB Number', + type: 'short-input', + placeholder: 'KB0000011', + condition: { field: 'operation', value: 'servicenow_get_knowledge_article' }, + required: true, + }, + { + id: 'updateView', + title: 'Count as a View', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'servicenow_get_knowledge_article' }, + description: 'Increments the view count and records an entry in Knowledge Use [kb_use].', + mode: 'advanced', + }, + // Users and groups + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'user@example.com', + condition: { field: 'operation', value: 'servicenow_find_user' }, + description: 'Exact match', + }, + { + id: 'userName', + title: 'User Name', + type: 'short-input', + placeholder: 'abel.tuter', + condition: { field: 'operation', value: 'servicenow_find_user' }, + description: 'Exact match on the user_name field', + }, + { + id: 'groupSysId', + title: 'Group sys_id', + type: 'short-input', + placeholder: 'sys_id of the sys_user_group', + condition: { field: 'operation', value: 'servicenow_list_group_members' }, + }, + { + id: 'groupName', + title: 'Group Name', + type: 'short-input', + placeholder: 'Network', + condition: { field: 'operation', value: 'servicenow_list_group_members' }, + description: 'Exact group name. Provide this or the group sys_id.', + }, + // Shared write controls + { + id: 'additionalFields', + title: 'Additional Fields (JSON)', + type: 'code', + language: 'json', + placeholder: '{\n "correlation_id": "abc-123"\n}', + condition: { field: 'operation', value: [...ADDITIONAL_FIELDS_OPS] }, + description: + 'Raw ServiceNow column names for anything not covered above. Merged last, so it overrides the fields set here.', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON object of raw ServiceNow column names and values for the target table. Output ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'inputDisplayValue', + title: 'Write Display Names', + type: 'dropdown', + options: [ + { label: 'No — reference fields are sys_ids', id: 'false' }, + { label: 'Yes — resolve display names to sys_ids', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: [...SEMANTIC_WRITE_PARAM_OPS] }, + description: + 'Sets sysparm_input_display_value, letting you write "Beth Anglin" into assigned_to instead of a sys_id. Also reinterprets date and time values in your timezone rather than GMT.', + mode: 'advanced', + }, ...getTrigger('servicenow_incident_created').subBlocks, ...getTrigger('servicenow_incident_updated').subBlocks, ...getTrigger('servicenow_change_request_created').subBlocks, @@ -431,17 +1593,110 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st 'servicenow_list_attachments', 'servicenow_download_attachment', 'servicenow_upload_attachment', + 'servicenow_create_incident', + 'servicenow_get_incident', + 'servicenow_list_incidents', + 'servicenow_update_incident', + 'servicenow_resolve_incident', + 'servicenow_close_incident', + 'servicenow_add_incident_comment', + 'servicenow_create_change_request', + 'servicenow_get_change_request', + 'servicenow_list_change_requests', + 'servicenow_update_change_request', + 'servicenow_update_change_state', + 'servicenow_list_change_tasks', + 'servicenow_get_change_next_states', + 'servicenow_list_catalog_items', + 'servicenow_order_catalog_item', + 'servicenow_list_requested_items', + 'servicenow_get_requested_item', + 'servicenow_list_approvals', + 'servicenow_update_approval', + 'servicenow_search_cis', + 'servicenow_get_ci', + 'servicenow_list_ci_relationships', + 'servicenow_search_knowledge', + 'servicenow_get_knowledge_article', + 'servicenow_find_user', + 'servicenow_list_group_members', ], config: { tool: (params) => params.operation, params: (params) => { - const { operation, fields, file, attachmentLimit, ...rest } = params + const { + operation, + fields, + readFields, + returnFields, + file, + attachmentLimit, + semanticDisplayValue, + targetState, + approvalState, + incidentState, + changeState, + incidentComments, + approvalComments, + resolutionCode, + changeCloseCode, + resolutionNotes, + changeCloseNotes, + knowledgeQuery, + ...rest + } = params const isCreateOrUpdate = operation === 'servicenow_create_record' || operation === 'servicenow_update_record' + if (SEMANTIC_DISPLAY_VALUE_OPS.has(operation)) { + rest.displayValue = semanticDisplayValue + } + + /** + * Subblock values are stored per block keyed by subblock id, so an id + * reused across operations carries one value between them. Each of these + * tool params has more than one value space — an incident `state` is not + * a change `state`, and a knowledge search phrase is not an encoded + * query — so each gets its own subblock and is republished here for the + * operations that own it. Assign explicitly rather than conditionally: + * `finalInputs` merges this result over the raw inputs, so a key left + * off is not dropped, it keeps whatever the raw input held. + */ + const stateSource = + operation === 'servicenow_update_change_state' + ? targetState + : operation === 'servicenow_list_approvals' + ? approvalState + : CHANGE_STATE_OPS.has(operation) + ? changeState + : incidentState + rest.state = SEMANTIC_STATE_OPS.has(operation) ? stateSource : undefined + rest.comments = + operation === 'servicenow_update_approval' ? approvalComments : incidentComments + rest.closeCode = CHANGE_CLOSE_OPS.has(operation) ? changeCloseCode : resolutionCode + rest.closeNotes = CHANGE_CLOSE_OPS.has(operation) ? changeCloseNotes : resolutionNotes + if (operation === 'servicenow_search_knowledge') rest.query = knowledgeQuery + if (attachmentLimit != null && attachmentLimit !== '') rest.limit = Number(attachmentLimit) if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit) if (rest.offset != null && rest.offset !== '') rest.offset = Number(rest.offset) + if (rest.quantity != null && rest.quantity !== '') rest.quantity = Number(rest.quantity) + + if (rest.inputDisplayValue != null) { + rest.inputDisplayValue = + rest.inputDisplayValue === true || rest.inputDisplayValue === 'true' + } + if (rest.updateView != null) { + rest.updateView = rest.updateView === true || rest.updateView === 'true' + } + + for (const [key, label] of JSON_SUBBLOCKS) { + const value = rest[key] + if (typeof value === 'string') { + const trimmed = value.trim() + rest[key] = trimmed ? parseJsonSubBlock(trimmed, label) : undefined + } + } if (operation === 'servicenow_aggregate') { rest.count = rest.count === true || rest.count === 'true' @@ -452,15 +1707,23 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st return normalizedFile ? { ...rest, file: normalizedFile } : rest } - if (fields) { - if (isCreateOrUpdate) { - const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields - return { ...rest, fields: parsedFields } - } - return { ...rest, fields } + /** + * The `fields` tool param means two different things: a JSON body on + * Create/Update Record, and a comma-separated projection everywhere + * else. Every subblock therefore owns exactly one of those value + * spaces — `fields` is the Create/Update body, `readFields` is Read + * Records' projection, `returnFields` is every other operation's — so a + * JSON body can never arrive as a projection or the reverse. + */ + if (isCreateOrUpdate) { + if (!fields) return { ...rest, fields: undefined } + const parsedFields = + typeof fields === 'string' ? parseJsonSubBlock(fields, 'Fields') : fields + return { ...rest, fields: parsedFields } } - return rest + const projection = operation === 'servicenow_read_record' ? readFields : returnFields + return { ...rest, fields: projection || undefined } }, }, }, @@ -480,7 +1743,15 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st }, offset: { type: 'number', description: 'Pagination offset' }, fields: { type: 'json', description: 'Fields object or JSON string' }, + readFields: { + type: 'string', + description: 'Comma-separated fields to return (Read Records)', + }, displayValue: { type: 'string', description: 'Display value mode for reference fields' }, + semanticDisplayValue: { + type: 'string', + description: 'Display value mode for the semantic operations, defaulting to "all"', + }, count: { type: 'boolean', description: 'Return record count (aggregate)' }, groupBy: { type: 'string', description: 'Comma-separated fields to group by (aggregate)' }, avgFields: { type: 'string', description: 'Comma-separated fields to average (aggregate)' }, @@ -492,6 +1763,81 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st attachmentSysId: { type: 'string', description: 'Attachment sys_id to download' }, fileName: { type: 'string', description: 'Name of the file to upload' }, file: { type: 'json', description: 'File to upload (canonical param)' }, + shortDescription: { type: 'string', description: 'Short description of the record' }, + description: { type: 'string', description: 'Detailed description of the record' }, + searchText: { type: 'string', description: 'Text matched against the short description' }, + state: { type: 'string', description: 'State coded value' }, + targetState: { type: 'string', description: 'Target state for a change request transition' }, + approvalState: { type: 'string', description: 'Approval state filter' }, + incidentState: { type: 'string', description: 'Incident state coded value' }, + changeState: { type: 'string', description: 'Change request state coded value' }, + incidentComments: { type: 'string', description: 'Customer-visible incident comment' }, + approvalComments: { type: 'string', description: 'Comment recorded with an approval decision' }, + resolutionCode: { type: 'string', description: 'Incident resolution (close) code' }, + changeCloseCode: { type: 'string', description: 'Change request close code' }, + resolutionNotes: { type: 'string', description: 'Incident resolution notes' }, + changeCloseNotes: { type: 'string', description: 'Change request close notes' }, + knowledgeQuery: { type: 'string', description: 'Knowledge article search text' }, + returnFields: { type: 'string', description: 'Comma-separated fields to return' }, + impact: { type: 'string', description: 'Impact coded value' }, + urgency: { type: 'string', description: 'Urgency coded value' }, + priority: { type: 'string', description: 'Priority coded value' }, + category: { type: 'string', description: 'Category' }, + subcategory: { type: 'string', description: 'Subcategory' }, + contactType: { type: 'string', description: 'Channel the record came in on' }, + callerId: { type: 'string', description: 'sys_id of the caller' }, + assignmentGroup: { type: 'string', description: 'sys_id of the assignment group' }, + assignedTo: { type: 'string', description: 'sys_id of the assigned user' }, + cmdbCi: { type: 'string', description: 'sys_id of the affected configuration item' }, + businessService: { type: 'string', description: 'sys_id of the affected business service' }, + requestedBy: { type: 'string', description: 'sys_id of the requesting user' }, + active: { type: 'string', description: 'Active filter ("true" or "false")' }, + workNotes: { type: 'string', description: 'Internal work note' }, + comments: { type: 'string', description: 'Customer-visible comment or approval comment' }, + comment: { type: 'string', description: 'Text appended to an incident journal field' }, + commentField: { type: 'string', description: 'Journal field to append to' }, + closeCode: { type: 'string', description: 'Resolution or close code' }, + closeNotes: { type: 'string', description: 'Resolution or close notes' }, + type: { type: 'string', description: 'Change request type' }, + risk: { type: 'string', description: 'Change risk coded value' }, + startDate: { type: 'string', description: 'Planned start date' }, + endDate: { type: 'string', description: 'Planned end date' }, + justification: { type: 'string', description: 'Change justification' }, + implementationPlan: { type: 'string', description: 'Change implementation plan' }, + backoutPlan: { type: 'string', description: 'Change backout plan' }, + testPlan: { type: 'string', description: 'Change test plan' }, + changeSysId: { type: 'string', description: 'sys_id of the change request' }, + order: { type: 'string', description: 'Field to sort change tasks by' }, + catalogSysId: { type: 'string', description: 'sys_id of the catalog' }, + categorySysId: { type: 'string', description: 'sys_id of the catalog category' }, + catalogItemSysId: { type: 'string', description: 'sys_id of the catalog item' }, + quantity: { type: 'number', description: 'Quantity to order' }, + requestedFor: { type: 'string', description: 'sys_id of the user the item is ordered for' }, + variables: { type: 'json', description: 'Catalog item variable name-value pairs' }, + requestSysId: { type: 'string', description: 'sys_id of the parent request' }, + approverSysId: { type: 'string', description: 'sys_id of the approver' }, + approvalFor: { type: 'string', description: 'sys_id of the record being approved' }, + approvalSysId: { type: 'string', description: 'sys_id of the approval record' }, + decision: { type: 'string', description: 'Approval decision' }, + ciClass: { type: 'string', description: 'CMDB class name' }, + name: { type: 'string', description: 'Name filter' }, + operationalStatus: { type: 'string', description: 'CI operational status coded value' }, + ciSysId: { type: 'string', description: 'sys_id of the configuration item' }, + direction: { type: 'string', description: 'Relationship direction to match' }, + filter: { type: 'string', description: 'Knowledge article encoded query filter' }, + knowledgeBaseSysIds: { type: 'string', description: 'Knowledge base sys_ids to search' }, + language: { type: 'string', description: 'ISO 639-1 language codes' }, + articleId: { type: 'string', description: 'Knowledge article sys_id or KB number' }, + updateView: { type: 'boolean', description: 'Whether to count the read as an article view' }, + email: { type: 'string', description: 'User email address' }, + userName: { type: 'string', description: 'ServiceNow user name' }, + groupSysId: { type: 'string', description: 'sys_id of the group' }, + groupName: { type: 'string', description: 'Group name' }, + additionalFields: { type: 'json', description: 'Extra raw ServiceNow fields to write' }, + inputDisplayValue: { + type: 'boolean', + description: 'Whether input values are treated as display values', + }, }, outputs: { record: { type: 'json', description: 'Single ServiceNow record' }, @@ -502,11 +1848,51 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st count: { type: 'number', description: 'Aggregate matching record count' }, attachments: { type: 'json', - description: 'Attachment metadata list (sys_id, file_name, content_type, download_link)', + description: + 'Attachment metadata list — record attachments (sys_id, file_name, content_type, download_link) or knowledge article attachments (sys_id, file_name, size_bytes, state)', }, file: { type: 'file', description: 'Downloaded attachment file' }, - content: { type: 'string', description: 'Base64-encoded downloaded file content' }, + content: { + type: 'string', + description: + 'Base64-encoded downloaded file content, or the HTML body of a knowledge article', + }, attachment: { type: 'json', description: 'Uploaded attachment metadata' }, + tasks: { + type: 'json', + description: + 'Change tasks belonging to a change request. Unlike the Table API operations, the Change Management API always returns every field as {value, display_value} regardless of the display value setting', + }, + items: { type: 'json', description: 'Service catalog items' }, + articles: { type: 'json', description: 'Knowledge article search results' }, + attributes: { type: 'json', description: 'Configuration item attributes' }, + inboundRelations: { type: 'json', description: 'Inbound CI relationships' }, + outboundRelations: { type: 'json', description: 'Outbound CI relationships' }, + sysId: { type: 'string', description: 'sys_id returned by the operation' }, + number: { type: 'string', description: 'Record number returned by the operation' }, + requestNumber: { type: 'string', description: 'Catalog request number' }, + requestId: { type: 'string', description: 'sys_id of the catalog request' }, + table: { type: 'string', description: 'Table the catalog request was created on' }, + availableStates: { + type: 'json', + description: 'State coded values a change request can reach, including its current state', + }, + allowedStates: { + type: 'json', + description: + 'State coded values whose transition conditions the change request already meets', + }, + stateLabels: { + type: 'json', + description: "This instance's own map of state coded value to label, e.g. {'0': 'Review'}", + }, + stateTransitions: { + type: 'json', + description: + '[{sys_id, display_value, from_state, to_state, transition_available, automatic_transition, conditions}]', + }, + title: { type: 'string', description: 'Knowledge article title' }, + fields: { type: 'json', description: 'Requested knowledge article field values' }, }, triggers: { enabled: true, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index 5c88b787ae5..62181bdaabb 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -100,6 +100,7 @@ export const SftpBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/splunk.test.ts b/apps/sim/blocks/blocks/splunk.test.ts new file mode 100644 index 00000000000..4a3d502195b --- /dev/null +++ b/apps/sim/blocks/blocks/splunk.test.ts @@ -0,0 +1,232 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/triggers', () => ({ + getTrigger: () => ({ subBlocks: [] }), +})) + +import { SplunkBlock } from '@/blocks/blocks/splunk' + +const toParams = SplunkBlock.tools.config?.params + +function mapParams(params: Record) { + if (!toParams) throw new Error('SplunkBlock is missing tools.config.params') + return toParams(params as Parameters[0]) +} + +/** + * What the tool actually receives. The generic handler merges the mapper's return + * over the raw serialized subBlock values (`{ ...inputs, ...transformedParams }`), so a + * key the mapper only assigns conditionally leaves the raw subBlock string in place. + * Assertions about dropping a value are only meaningful against this merged result. + */ +function mergedInputs(params: Record) { + return { ...params, ...mapParams(params) } +} + +describe('SplunkBlock tools.config.params', () => { + describe('search-job toggles', () => { + it('preserves a typed boolean false from a variable or agent tool call', () => { + const result = mapParams({ + operation: 'splunk_create_search_job', + enableLookups: false, + allowPartialResults: false, + }) + + expect(result.enableLookups).toBe(false) + expect(result.allowPartialResults).toBe(false) + }) + + it('reads the dropdown string form', () => { + expect( + mapParams({ + operation: 'splunk_create_search_job', + enableLookups: 'false', + allowPartialResults: 'true', + }) + ).toMatchObject({ enableLookups: false, allowPartialResults: true }) + }) + + it('leaves an untouched toggle undefined so Splunk applies its own default', () => { + const result = mapParams({ + operation: 'splunk_create_search_job', + enableLookups: null, + allowPartialResults: '', + }) + + expect(result.enableLookups).toBeUndefined() + expect(result.allowPartialResults).toBeUndefined() + }) + }) + + describe('pagination', () => { + it('omits Max Results when untouched rather than asking for every row', () => { + const result = mapParams({ operation: 'splunk_list_indexes', count: null, offset: null }) + + expect(result).not.toHaveProperty('count') + expect(result).not.toHaveProperty('offset') + }) + + it('coerces a typed Max Results, including an explicit 0', () => { + expect( + mapParams({ operation: 'splunk_list_indexes', count: '50', offset: '10' }) + ).toMatchObject({ count: 50, offset: 10 }) + expect(mapParams({ operation: 'splunk_list_indexes', count: '0' })).toMatchObject({ + count: 0, + }) + }) + }) + + describe('switch-typed toggles', () => { + it('converts the switch string form so the tool sees a real boolean', () => { + expect( + mergedInputs({ + operation: 'splunk_dispatch_saved_search', + savedSearchName: 'Errors', + triggerActions: 'true', + forceDispatch: 'false', + }) + ).toMatchObject({ triggerActions: true, forceDispatch: false }) + + expect( + mergedInputs({ + operation: 'splunk_get_search_results', + sid: '1.1', + addSummaryToMetadata: 'false', + }) + ).toMatchObject({ addSummaryToMetadata: false }) + }) + + it('drops an untouched switch from the merged inputs, not just from the mapper', () => { + const merged = mergedInputs({ + operation: 'splunk_dispatch_saved_search', + savedSearchName: 'Errors', + triggerActions: null, + forceDispatch: null, + }) + + expect(merged.triggerActions).toBeUndefined() + expect(merged.forceDispatch).toBeUndefined() + }) + }) + + describe('subBlock to tool param remapping', () => { + it('maps the saved-search and alert name subBlocks onto the tool name param', () => { + expect( + mapParams({ operation: 'splunk_dispatch_saved_search', savedSearchName: 'Errors' }) + ).toMatchObject({ name: 'Errors' }) + expect( + mapParams({ operation: 'splunk_get_fired_alerts', alertName: 'Errors' }) + ).toMatchObject({ name: 'Errors' }) + }) + }) +}) + +describe('SplunkBlock subBlocks', () => { + it('has no duplicate subBlock ids', () => { + const ids = SplunkBlock.subBlocks.map((subBlock) => subBlock.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('exposes every tool in tools.access as an operation option', () => { + const operation = SplunkBlock.subBlocks.find((subBlock) => subBlock.id === 'operation') + const optionIds = (operation?.options as { id: string }[]).map((option) => option.id) + expect([...optionIds].sort()).toEqual([...SplunkBlock.tools.access].sort()) + }) +}) + +describe('SplunkBlock subBlock placeholders', () => { + function subBlock(id: string) { + const found = SplunkBlock.subBlocks.find((block) => block.id === id) + if (!found) throw new Error(`SplunkBlock is missing the ${id} subBlock`) + return found + } + + function subBlocksFor(id: string, operation: string) { + return SplunkBlock.subBlocks.filter((block) => { + if (block.id !== id) return false + const value = block.condition?.value + return Array.isArray(value) ? value.includes(operation) : value === operation + }) + } + + /** + * `nobody` names the shared-application owner, so it is one specific owner + * rather than a neutral filler — and users copy placeholders. `-` is the + * documented wildcard for all users, which is what the namespace builder + * already substitutes. + */ + it('offers the - wildcard as the namespace owner, not nobody', () => { + expect(subBlock('owner').placeholder).toBe('-') + }) + + /** + * The old placeholder claimed a default of 100 for all five operations (the real + * default is 30 for the four collection endpoints) and advertised `0 returns + * all` — an unbounded read that Get Search Results now rejects outright. Because + * this block keeps subBlock ids unique, one field serves every operation, so it + * must not state a rule that holds for only some of them. + */ + it('does not advertise a wrong default or an unbounded read on Max Results', () => { + const shown = subBlocksFor('count', 'splunk_get_search_results') + expect(shown).toHaveLength(1) + + const placeholder = String(shown[0].placeholder) + expect(placeholder).not.toContain('100') + expect(placeholder).not.toMatch(/0 returns all/) + }) + + /** The per-operation detail the placeholder can no longer carry. */ + it('documents the differing defaults and the 0 rule on the count input', () => { + const description = String(SplunkBlock.inputs.count.description) + + expect(description).toContain('30') + expect(description).toContain('100') + expect(description).toMatch(/reject/i) + }) +}) + +describe('SplunkBlock numeric coercion', () => { + /** + * A bare `Number()` sent `NaN` for an unparseable value, which serializes as the + * literal `NaN` and makes Splunk reject the request with an error that names the + * field but not the cause. Omitting it lets Splunk apply its own default. + */ + it.each(['abc', 'twenty', '12px'])('omits an unparseable Max Results (%s)', (count) => { + const merged = mergedInputs({ operation: 'splunk_list_indexes', count }) + + expect(merged.count).not.toBe(Number.NaN) + expect(mapParams({ operation: 'splunk_list_indexes', count })).not.toHaveProperty('count') + }) + + it('omits an unparseable value on every numeric field it maps', () => { + const result = mapParams({ + operation: 'splunk_dispatch_saved_search', + savedSearchName: 'Errors', + dispatchMaxCount: 'abc', + dispatchMaxTime: 'abc', + dispatchTtl: 'abc', + offset: 'abc', + }) + + expect(result).not.toHaveProperty('dispatchMaxCount') + expect(result).not.toHaveProperty('dispatchMaxTime') + expect(result).not.toHaveProperty('dispatchTtl') + expect(result).not.toHaveProperty('offset') + }) + + it('still coerces the numeric forms it is given', () => { + expect( + mapParams({ operation: 'splunk_create_search_job', autoCancel: '300', maxCount: 5000 }) + ).toMatchObject({ autoCancel: 300, maxCount: 5000 }) + }) +}) + +describe('SplunkBlock outputs', () => { + it('declares the paging total and offset the list tools now project', () => { + expect(SplunkBlock.outputs).toHaveProperty('total') + expect(SplunkBlock.outputs).toHaveProperty('offset') + }) +}) diff --git a/apps/sim/blocks/blocks/splunk.ts b/apps/sim/blocks/blocks/splunk.ts new file mode 100644 index 00000000000..689fce7e504 --- /dev/null +++ b/apps/sim/blocks/blocks/splunk.ts @@ -0,0 +1,763 @@ +import { SplunkIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { SplunkResponse } from '@/tools/splunk/types' + +/** + * Normalize a Splunk toggle. A dropdown supplies the strings `'true'`/`'false'`, + * while a workflow variable, an agent tool call, or a block created over the API + * supplies a real boolean — so both forms must survive. An untouched subBlock + * resolves to `null`, which stays `undefined` here so the field is omitted from + * the request and Splunk applies its own documented default. + */ +function toSplunkToggle(value: unknown): boolean | undefined { + if (value == null || value === '') return undefined + if (typeof value === 'boolean') return value + return value !== 'false' && value !== '0' +} + +/** + * Assign a numeric Splunk field, dropping anything that is not a finite number. + * + * A bare `Number()` turns a typo like `abc` into `NaN`, which serializes into the + * query string or form body as the literal `NaN` — Splunk then rejects the whole + * request with an error that names the field but not the cause. Omitting the field + * instead lets Splunk apply its own documented default, which is what an unusable + * value should fall back to. + * + * An untouched subBlock resolves to `null` and an empty one to `''`; both are + * omissions rather than zeros, so neither may reach `Number()` (which reads both + * as `0`). + */ +function assignSplunkNumber(target: Record, key: string, value: unknown): void { + if (value == null || value === '') return + const parsed = Number(value) + if (!Number.isFinite(parsed)) return + target[key] = parsed +} + +export const SplunkBlock: BlockConfig = { + type: 'splunk', + name: 'Splunk', + description: 'Run SPL searches and manage saved searches and alerts in Splunk', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Splunk Enterprise or Splunk Cloud into workflows. Run SPL searches synchronously or as asynchronous jobs, fetch results, dispatch saved searches, and inspect fired alerts and indexes.', + docsLink: 'https://docs.sim.ai/integrations/splunk', + category: 'tools', + integrationType: IntegrationType.Observability, + bgColor: '#FFFFFF', + icon: SplunkIcon, + canvasPresentation: { + defaultTitle: 'Splunk', + sentences: { + byOperation: { + splunk_run_search: [ + { text: 'Run search', field: 'search', core: true }, + { text: ', from', field: 'earliestTime' }, + { text: ', until', field: 'latestTime' }, + ], + splunk_create_search_job: [ + { text: 'Start search job for', field: 'search', core: true }, + { text: ', from', field: 'earliestTime' }, + { text: ', until', field: 'latestTime' }, + ], + splunk_get_search_job: [{ text: 'Check search job', field: 'sid', core: true }], + splunk_get_search_results: [ + { text: 'Fetch results of search job', field: 'sid', core: true }, + { text: ', returning at most', field: 'count' }, + { text: ', starting at', field: 'offset' }, + ], + splunk_cancel_search_job: [{ text: 'Cancel search job', field: 'sid', core: true }], + splunk_list_saved_searches: [ + 'List saved searches', + { text: ', matching', field: 'savedSearchFilter' }, + { text: ', returning at most', field: 'count' }, + ], + splunk_get_saved_search: [ + { text: 'Fetch saved search', field: 'savedSearchName', core: true }, + ], + splunk_dispatch_saved_search: [ + { text: 'Run saved search', field: 'savedSearchName', core: true }, + { text: ', from', field: 'dispatchEarliestTime' }, + { text: ', until', field: 'dispatchLatestTime' }, + ], + splunk_list_fired_alerts: [ + 'List fired alerts', + { text: ', returning at most', field: 'count' }, + ], + splunk_get_fired_alerts: [ + { text: 'Fetch fired alerts for', field: 'alertName', core: true }, + ], + splunk_list_indexes: [ + 'List indexes', + { text: ', of type', field: 'datatype' }, + { text: ', returning at most', field: 'count' }, + ], + splunk_list_apps: ['List apps', { text: ', returning at most', field: 'count' }], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Run Search', id: 'splunk_run_search' }, + { label: 'Create Search Job', id: 'splunk_create_search_job' }, + { label: 'Get Search Job', id: 'splunk_get_search_job' }, + { label: 'Get Search Results', id: 'splunk_get_search_results' }, + { label: 'Cancel Search Job', id: 'splunk_cancel_search_job' }, + { label: 'List Saved Searches', id: 'splunk_list_saved_searches' }, + { label: 'Get Saved Search', id: 'splunk_get_saved_search' }, + { label: 'Dispatch Saved Search', id: 'splunk_dispatch_saved_search' }, + { label: 'List Fired Alerts', id: 'splunk_list_fired_alerts' }, + { label: 'Get Fired Alerts', id: 'splunk_get_fired_alerts' }, + { label: 'List Indexes', id: 'splunk_list_indexes' }, + { label: 'List Apps', id: 'splunk_list_apps' }, + ], + value: () => 'splunk_run_search', + }, + + { + id: 'baseUrl', + title: 'Splunk URL', + type: 'short-input', + placeholder: 'https://splunk.example.com:8089', + required: true, + }, + { + id: 'authToken', + title: 'Authentication Token', + type: 'short-input', + placeholder: 'Splunk bearer token', + password: true, + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Only needed without a token', + mode: 'advanced', + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + placeholder: 'Only needed without a token', + password: true, + mode: 'advanced', + }, + { + id: 'owner', + title: 'Namespace Owner', + type: 'short-input', + placeholder: '-', + mode: 'advanced', + }, + { + id: 'app', + title: 'Namespace App', + type: 'short-input', + placeholder: 'search', + mode: 'advanced', + }, + + { + id: 'search', + title: 'SPL Search', + type: 'long-input', + placeholder: 'index=main error | stats count by host', + required: true, + condition: { + field: 'operation', + value: ['splunk_run_search', 'splunk_create_search_job'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a Splunk SPL search from the user's description. + +Rules: +- Return ONLY the SPL string, no explanations, no quotes, no code fences. +- Start with an index filter when the user names a data source (e.g. index=main). +- Use pipes for transforming commands (e.g. | stats count by host). + +Examples: +- "errors in the main index in the last hour grouped by host" -> index=main error | stats count by host +- "top 10 slowest web requests" -> index=web | sort - duration | head 10`, + placeholder: 'Describe the search you want to run...', + }, + }, + { + id: 'earliestTime', + title: 'Earliest Time', + type: 'short-input', + placeholder: '-24h', + condition: { + field: 'operation', + value: ['splunk_run_search', 'splunk_create_search_job'], + }, + }, + { + id: 'latestTime', + title: 'Latest Time', + type: 'short-input', + placeholder: 'now', + condition: { + field: 'operation', + value: ['splunk_run_search', 'splunk_create_search_job'], + }, + }, + { + id: 'adhocSearchLevel', + title: 'Search Mode', + type: 'dropdown', + options: [ + { label: 'Fast', id: 'fast' }, + { label: 'Smart', id: 'smart' }, + { label: 'Verbose', id: 'verbose' }, + ], + mode: 'advanced', + condition: { + field: 'operation', + value: ['splunk_run_search', 'splunk_create_search_job'], + }, + }, + { + id: 'autoCancel', + title: 'Auto-Cancel After (seconds)', + type: 'short-input', + placeholder: '0 never auto-cancels', + mode: 'advanced', + condition: { + field: 'operation', + value: ['splunk_run_search', 'splunk_create_search_job'], + }, + }, + { + id: 'maxCount', + title: 'Max Stored Results', + type: 'short-input', + placeholder: '10000', + mode: 'advanced', + condition: { + field: 'operation', + value: ['splunk_run_search', 'splunk_create_search_job'], + }, + }, + { + id: 'execMode', + title: 'Execution Mode', + type: 'dropdown', + options: [ + { label: 'Normal (returns sid immediately)', id: 'normal' }, + { label: 'Blocking (returns sid when done)', id: 'blocking' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_create_search_job' }, + }, + { + id: 'searchId', + title: 'Custom Search ID', + type: 'short-input', + placeholder: 'Generated automatically when empty', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_create_search_job' }, + }, + { + id: 'indexEarliest', + title: 'Index Earliest Time', + type: 'short-input', + placeholder: '-24h', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_create_search_job' }, + }, + { + id: 'indexLatest', + title: 'Index Latest Time', + type: 'short-input', + placeholder: 'now', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_create_search_job' }, + }, + { + id: 'enableLookups', + title: 'Enable Lookups', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_create_search_job' }, + }, + { + id: 'allowPartialResults', + title: 'Allow Partial Results', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_create_search_job' }, + }, + + { + id: 'sid', + title: 'Search ID', + type: 'short-input', + placeholder: '1457683115.100', + required: true, + condition: { + field: 'operation', + value: ['splunk_get_search_job', 'splunk_get_search_results', 'splunk_cancel_search_job'], + }, + }, + { + id: 'fields', + title: 'Fields', + type: 'short-input', + placeholder: '_time, host, source (comma-separated)', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_get_search_results' }, + }, + { + id: 'addSummaryToMetadata', + title: 'Include Field Summary', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_get_search_results' }, + }, + + { + id: 'savedSearchName', + title: 'Saved Search Name', + type: 'short-input', + placeholder: 'Errors in the last 24 hours', + required: true, + condition: { + field: 'operation', + value: ['splunk_get_saved_search', 'splunk_dispatch_saved_search'], + }, + }, + { + id: 'savedSearchFilter', + title: 'Filter', + type: 'short-input', + placeholder: 'name=Errors*', + condition: { field: 'operation', value: 'splunk_list_saved_searches' }, + }, + { + id: 'triggerActions', + title: 'Trigger Alert Actions', + type: 'switch', + condition: { field: 'operation', value: 'splunk_dispatch_saved_search' }, + }, + { + id: 'dispatchEarliestTime', + title: 'Earliest Time Override', + type: 'short-input', + placeholder: '-24h', + condition: { field: 'operation', value: 'splunk_dispatch_saved_search' }, + }, + { + id: 'dispatchLatestTime', + title: 'Latest Time Override', + type: 'short-input', + placeholder: 'now', + condition: { field: 'operation', value: 'splunk_dispatch_saved_search' }, + }, + { + id: 'dispatchMaxCount', + title: 'Max Results', + type: 'short-input', + placeholder: '10000', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_dispatch_saved_search' }, + }, + { + id: 'dispatchMaxTime', + title: 'Max Run Time (seconds)', + type: 'short-input', + placeholder: '300', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_dispatch_saved_search' }, + }, + { + id: 'dispatchTtl', + title: 'Artifact TTL (seconds)', + type: 'short-input', + placeholder: '600', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_dispatch_saved_search' }, + }, + + { + id: 'forceDispatch', + title: 'Force Dispatch', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: 'splunk_dispatch_saved_search' }, + }, + + { + id: 'alertName', + title: 'Alerting Saved Search', + type: 'short-input', + placeholder: 'Errors in the last 24 hours', + required: true, + condition: { field: 'operation', value: 'splunk_get_fired_alerts' }, + }, + { + id: 'datatype', + title: 'Index Type', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Event', id: 'event' }, + { label: 'Metric', id: 'metric' }, + ], + value: () => 'all', + condition: { field: 'operation', value: 'splunk_list_indexes' }, + }, + + /** + * One Max Results field serves five operations whose defaults differ (30 for + * the four collection endpoints, 100 for search results) and whose handling of + * `count=0` differs too — the collections read it as "every entry", while + * search results reject it because nothing downstream bounds that read. This + * block keeps subBlock ids unique, so rather than state one group's rule as if + * it were shared, the placeholder states neither and the per-operation detail + * lives in the `count` input description. + */ + { + id: 'count', + title: 'Max Results', + type: 'short-input', + placeholder: 'Leave empty for the Splunk default', + condition: { + field: 'operation', + value: [ + 'splunk_get_search_results', + 'splunk_list_saved_searches', + 'splunk_list_fired_alerts', + 'splunk_list_indexes', + 'splunk_list_apps', + ], + }, + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'splunk_get_search_results', + 'splunk_list_saved_searches', + 'splunk_list_fired_alerts', + 'splunk_list_indexes', + 'splunk_list_apps', + ], + }, + }, + ], + + tools: { + access: [ + 'splunk_run_search', + 'splunk_create_search_job', + 'splunk_get_search_job', + 'splunk_get_search_results', + 'splunk_cancel_search_job', + 'splunk_list_saved_searches', + 'splunk_get_saved_search', + 'splunk_dispatch_saved_search', + 'splunk_list_fired_alerts', + 'splunk_get_fired_alerts', + 'splunk_list_indexes', + 'splunk_list_apps', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const result: Record = {} + + assignSplunkNumber(result, 'count', params.count) + assignSplunkNumber(result, 'offset', params.offset) + + switch (params.operation) { + case 'splunk_run_search': + assignSplunkNumber(result, 'autoCancel', params.autoCancel) + assignSplunkNumber(result, 'maxCount', params.maxCount) + break + case 'splunk_create_search_job': + assignSplunkNumber(result, 'autoCancel', params.autoCancel) + assignSplunkNumber(result, 'maxCount', params.maxCount) + result.enableLookups = toSplunkToggle(params.enableLookups) + result.allowPartialResults = toSplunkToggle(params.allowPartialResults) + break + case 'splunk_get_search_results': + result.addSummaryToMetadata = toSplunkToggle(params.addSummaryToMetadata) + break + case 'splunk_list_saved_searches': + result.search = params.savedSearchFilter ?? '' + break + case 'splunk_get_saved_search': + case 'splunk_dispatch_saved_search': + result.name = params.savedSearchName + result.triggerActions = toSplunkToggle(params.triggerActions) + result.forceDispatch = toSplunkToggle(params.forceDispatch) + assignSplunkNumber(result, 'dispatchMaxCount', params.dispatchMaxCount) + assignSplunkNumber(result, 'dispatchMaxTime', params.dispatchMaxTime) + assignSplunkNumber(result, 'dispatchTtl', params.dispatchTtl) + break + case 'splunk_get_fired_alerts': + result.name = params.alertName + break + } + + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + baseUrl: { type: 'string', description: 'Splunk management URL including port' }, + authToken: { type: 'string', description: 'Splunk authentication (bearer) token' }, + username: { type: 'string', description: 'Username for basic authentication' }, + password: { type: 'string', description: 'Password for basic authentication' }, + owner: { type: 'string', description: 'Namespace owner for /servicesNS requests' }, + app: { type: 'string', description: 'Namespace app context for /servicesNS requests' }, + search: { type: 'string', description: 'SPL search string' }, + earliestTime: { type: 'string', description: 'Earliest time bound for the search' }, + latestTime: { type: 'string', description: 'Latest time bound for the search' }, + adhocSearchLevel: { type: 'string', description: 'Search mode: fast, smart, or verbose' }, + autoCancel: { type: 'number', description: 'Seconds of inactivity before auto-cancelling' }, + execMode: { type: 'string', description: 'Execution mode: normal or blocking' }, + searchId: { type: 'string', description: 'Custom search ID for the new job' }, + indexEarliest: { type: 'string', description: 'Earliest index-time bound' }, + indexLatest: { type: 'string', description: 'Latest index-time bound' }, + enableLookups: { type: 'boolean', description: 'Whether lookups are applied to events' }, + allowPartialResults: { + type: 'boolean', + description: 'Whether partial results are allowed when a search peer fails', + }, + maxCount: { + type: 'number', + description: 'Maximum number of results the search stores and returns', + }, + sid: { type: 'string', description: 'Search ID of an existing job' }, + fields: { type: 'string', description: 'Comma-separated fields to return per result row' }, + addSummaryToMetadata: { + type: 'boolean', + description: 'Include field summary statistics with the results', + }, + name: { type: 'string', description: 'Saved search or alerting saved search name' }, + savedSearchName: { type: 'string', description: 'Name of the saved search' }, + savedSearchFilter: { type: 'string', description: 'Filter expression for saved searches' }, + alertName: { type: 'string', description: 'Name of the alerting saved search' }, + triggerActions: { + type: 'boolean', + description: 'Whether to trigger alert actions when dispatching', + }, + dispatchEarliestTime: { + type: 'string', + description: 'Earliest time override for the dispatch', + }, + dispatchLatestTime: { type: 'string', description: 'Latest time override for the dispatch' }, + dispatchMaxCount: { type: 'number', description: 'Maximum results before finalizing' }, + dispatchMaxTime: { type: 'number', description: 'Maximum run time in seconds' }, + dispatchTtl: { type: 'number', description: 'Time to live for the search artifacts' }, + forceDispatch: { + type: 'boolean', + description: 'Whether to dispatch even when the saved search is already running', + }, + datatype: { type: 'string', description: 'Index type filter: all, event, or metric' }, + count: { + type: 'number', + description: + 'Maximum number of entries to return. The Splunk default is 30 for the collection endpoints and 100 for search results. The collection endpoints read 0 as "return every entry"; search results reject it, since a completed job can hold hundreds of thousands of rows.', + }, + offset: { type: 'number', description: 'Index of the first entry to return' }, + }, + + outputs: { + results: { + type: 'json', + description: 'Search result rows, each holding the fields the search produced', + }, + resultCount: { type: 'number', description: 'Number of result rows returned' }, + preview: { type: 'boolean', description: 'Whether the results are previews' }, + initOffset: { type: 'number', description: 'Offset of the first returned row' }, + messages: { type: 'json', description: 'Messages returned with the response ([{type, text}])' }, + sid: { type: 'string', description: 'Search ID of the job' }, + label: { type: 'string', description: 'Custom name of the search job' }, + dispatchState: { type: 'string', description: 'Current state of the search job' }, + doneProgress: { type: 'number', description: 'Approximate job progress between 0 and 1' }, + isDone: { type: 'boolean', description: 'Whether the search has completed' }, + isFailed: { type: 'boolean', description: 'Whether the search failed' }, + isFinalized: { type: 'boolean', description: 'Whether the search was finalized' }, + isPaused: { type: 'boolean', description: 'Whether the search is paused' }, + isZombie: { type: 'boolean', description: 'Whether the search process died' }, + isSaved: { type: 'boolean', description: 'Whether the job artifacts are saved' }, + isSavedSearch: { type: 'boolean', description: 'Whether the job came from a saved search' }, + isRealTimeSearch: { type: 'boolean', description: 'Whether this is a real-time search' }, + eventCount: { type: 'number', description: 'Number of events returned' }, + eventAvailableCount: { type: 'number', description: 'Number of events available for export' }, + eventFieldCount: { type: 'number', description: 'Number of fields found in the results' }, + resultPreviewCount: { type: 'number', description: 'Number of rows in the latest preview' }, + scanCount: { type: 'number', description: 'Number of events scanned off disk' }, + runDuration: { type: 'number', description: 'Seconds the search took to complete' }, + priority: { type: 'number', description: 'Search priority between 0 and 10' }, + earliestTime: { type: 'string', description: 'Earliest time bound of the job' }, + latestTime: { type: 'string', description: 'Latest time bound of the job' }, + searchEarliestTime: { + type: 'string', + description: 'Earliest time as specified in the search command', + }, + searchLatestTime: { + type: 'string', + description: 'Latest time as specified in the search command', + }, + savedSearches: { + type: 'json', + description: + 'Saved searches ([{name, id, author, updated, search, description, disabled, isScheduled, cronSchedule, alertType}])', + }, + name: { type: 'string', description: 'Saved search name' }, + id: { type: 'string', description: 'Fully qualified REST URI of the resource' }, + author: { type: 'string', description: 'Owner of the saved search' }, + updated: { type: 'string', description: 'Last update timestamp' }, + search: { type: 'string', description: 'SPL the saved search runs' }, + qualifiedSearch: { type: 'string', description: 'Exact search string the scheduler runs' }, + description: { type: 'string', description: 'Saved search description' }, + disabled: { type: 'boolean', description: 'Whether the saved search is disabled' }, + isScheduled: { type: 'boolean', description: 'Whether the search runs on a schedule' }, + isVisible: { type: 'boolean', description: 'Whether the search is listed as visible' }, + cronSchedule: { type: 'string', description: 'Cron schedule for the search' }, + nextScheduledTime: { type: 'string', description: 'Next scheduled run time' }, + alertType: { type: 'string', description: 'Alert condition type' }, + dispatchEarliestTime: { type: 'string', description: 'Earliest time used when dispatching' }, + dispatchLatestTime: { type: 'string', description: 'Latest time used when dispatching' }, + alerts: { + type: 'json', + description: + 'Saved searches with currently triggered alerts ([{name, id, updated, triggeredAlertCount}])', + }, + firedAlerts: { + type: 'json', + description: + 'Triggered instances of an alert ([{name, savedSearchName, alertType, severity, sid, triggerTime}])', + }, + indexes: { + type: 'json', + description: + 'Indexes configured on the instance ([{name, datatype, disabled, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, minTime, maxTime}])', + }, + apps: { + type: 'json', + description: 'Apps installed on the instance (name, label, version, author, disabled)', + }, + total: { + type: 'number', + description: + 'Total number of entries matching a list request, from the response paging envelope. Compare with offset to decide whether another page remains.', + }, + offset: { + type: 'number', + description: 'Offset of the first entry in the returned page, from the paging envelope', + }, + }, +} + +export const SplunkBlockMeta = { + tags: ['monitoring', 'data-analytics'], + url: 'https://www.splunk.com', + templates: [ + { + icon: SplunkIcon, + title: 'Splunk error spike triage', + prompt: + 'Build a scheduled workflow that runs a Splunk SPL search for error spikes every 15 minutes, summarizes the top offending hosts and sourcetypes with an agent, and posts the triage summary to Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: SplunkIcon, + title: 'Splunk alert to incident', + prompt: + 'Create a scheduled workflow that polls Splunk fired alerts, deduplicates them by saved search, and opens a PagerDuty incident with the alert severity and the search ID attached.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'incident-response'], + alsoIntegrations: ['pagerduty'], + }, + { + icon: SplunkIcon, + title: 'Splunk security digest', + prompt: + 'Build a scheduled daily workflow that dispatches a Splunk saved search for failed authentication attempts, waits for the job to finish, and emails a security digest with the top source IPs.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['security', 'reporting'], + alsoIntegrations: ['gmail'], + }, + { + icon: SplunkIcon, + title: 'Splunk log export to tables', + prompt: + 'Create a scheduled workflow that runs a Splunk search for the previous hour of transaction logs and writes the results into a Sim table for downstream reporting.', + modules: ['scheduled', 'tables', 'workflows'], + category: 'engineering', + tags: ['analysis', 'sync'], + }, + { + icon: SplunkIcon, + title: 'Splunk index capacity watch', + prompt: + 'Build a scheduled weekly workflow that lists Splunk indexes, flags any index close to its maximum data size or retention limit, and posts a capacity warning to the platform team in Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: SplunkIcon, + title: 'Splunk saved search inventory', + prompt: + 'Create a scheduled monthly workflow that lists every Splunk saved search, flags scheduled searches that have not run recently or are disabled, and writes a cleanup queue to a Sim table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'automation'], + }, + { + icon: SplunkIcon, + title: 'Splunk incident context agent', + prompt: + 'Build an agent workflow that takes an incident description, runs targeted Splunk searches for the affected service, and returns a timeline of the relevant log events with the matching search ID.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'analysis'], + }, + { + icon: SplunkIcon, + title: 'Splunk + Jira defect linking', + prompt: + 'Create a scheduled workflow that runs a Splunk search for recurring application exceptions and opens or updates a Jira bug for each distinct stack trace with the event count attached.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['engineering', 'ticketing'], + alsoIntegrations: ['jira'], + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index 74d0c570aac..a991a0ea6c8 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -150,6 +150,7 @@ export const SSHBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts index 6cb1a05048f..e18134b9483 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -130,6 +130,7 @@ export const STSBlock: BlockConfig = { id: 'webIdentityToken', title: 'Web Identity Token', type: 'long-input', + password: true, placeholder: 'OIDC/OAuth 2.0 token from the identity provider', condition: { field: 'operation', value: 'assume_role_with_web_identity' }, required: { field: 'operation', value: 'assume_role_with_web_identity' }, @@ -155,6 +156,7 @@ export const STSBlock: BlockConfig = { id: 'samlAssertion', title: 'SAML Assertion', type: 'long-input', + password: true, placeholder: 'Base64-encoded SAML authentication response', condition: { field: 'operation', value: 'assume_role_with_saml' }, required: { field: 'operation', value: 'assume_role_with_saml' }, @@ -240,6 +242,7 @@ export const STSBlock: BlockConfig = { id: 'tokenCode', title: 'MFA Token Code', type: 'short-input', + password: true, placeholder: '123456', condition: { field: 'operation', value: ['assume_role', 'get_session_token'] }, required: false, diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index f29968b3061..c971e41eb53 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -271,6 +271,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'password', title: 'Password', type: 'short-input', + password: true, + required: false, placeholder: 'Meeting password', mode: 'advanced', condition: { diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index d12e0beade3..629df4273d7 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -221,6 +221,7 @@ import { import { MondayBlock, MondayBlockMeta } from '@/blocks/blocks/monday' import { MongoDBBlock, MongoDBBlockMeta } from '@/blocks/blocks/mongodb' import { MothershipBlock } from '@/blocks/blocks/mothership' +import { MSSQLBlock, MSSQLBlockMeta } from '@/blocks/blocks/mssql' import { MySQLBlock, MySQLBlockMeta } from '@/blocks/blocks/mysql' import { Neo4jBlock, Neo4jBlockMeta } from '@/blocks/blocks/neo4j' import { NetSuiteBlock, NetSuiteBlockMeta } from '@/blocks/blocks/netsuite' @@ -294,6 +295,7 @@ import { SlackBlock, SlackBlockMeta, SlackV2Block } from '@/blocks/blocks/slack' import { SmartleadBlock, SmartleadBlockMeta } from '@/blocks/blocks/smartlead' import { SmtpBlock, SmtpBlockMeta } from '@/blocks/blocks/smtp' import { SnowflakeBlock, SnowflakeBlockMeta } from '@/blocks/blocks/snowflake' +import { SplunkBlock, SplunkBlockMeta } from '@/blocks/blocks/splunk' import { SportmonksBlock, SportmonksBlockMeta } from '@/blocks/blocks/sportmonks' import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify' import { SQSBlock, SQSBlockMeta } from '@/blocks/blocks/sqs' @@ -552,6 +554,7 @@ export const BLOCK_REGISTRY: Record = { monday: MondayBlock, mongodb: MongoDBBlock, mothership: MothershipBlock, + mssql: MSSQLBlock, mysql: MySQLBlock, neo4j: Neo4jBlock, netsuite: NetSuiteBlock, @@ -625,6 +628,7 @@ export const BLOCK_REGISTRY: Record = { smartlead: SmartleadBlock, smtp: SmtpBlock, snowflake: SnowflakeBlock, + splunk: SplunkBlock, sportmonks: SportmonksBlock, spotify: SpotifyBlock, sqs: SQSBlock, @@ -858,6 +862,7 @@ export const BLOCK_META_REGISTRY: Record = { mistral_parse: MistralParseBlockMeta, monday: MondayBlockMeta, mongodb: MongoDBBlockMeta, + mssql: MSSQLBlockMeta, mysql: MySQLBlockMeta, neo4j: Neo4jBlockMeta, netsuite: NetSuiteBlockMeta, @@ -920,6 +925,7 @@ export const BLOCK_META_REGISTRY: Record = { smartlead: SmartleadBlockMeta, smtp: SmtpBlockMeta, snowflake: SnowflakeBlockMeta, + splunk: SplunkBlockMeta, sportmonks: SportmonksBlockMeta, spotify: SpotifyBlockMeta, sqs: SQSBlockMeta, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index da688484873..3d9c312436e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -489,7 +489,8 @@ export interface SubBlockConfig { // Called when component mounts with a stored value to display the correct label before options load fetchOptionById?: ( blockId: string, - optionId: string + optionId: string, + signal?: AbortSignal ) => Promise<{ label: string; id: string } | null> /** * tool-input only: tool categories the consuming block cannot execute. They diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 812d38ea311..e40d9af2efd 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4945,6 +4945,53 @@ export function ParallelIcon(props: SVGProps) { ) } +/** + * Microsoft SQL Server brand mark. Multi-color artwork, so the fills stay + * hardcoded and no `iconColor` is set. + */ +export function MicrosoftSqlIcon(props: SVGProps) { + return ( + + + + + + + + + + + + ) +} + export function PostgresIcon(props: SVGProps) { return ( ) { ) } +/** + * Splunk wordmark. The lettering is drawn with `currentColor` so it stays legible + * bare in both light and dark mode; the chevron keeps its brand green. + */ +export function SplunkIcon(props: SVGProps) { + return ( + + + + + ) +} + export function SnowflakeIcon(props: SVGProps) { return ( diff --git a/apps/sim/components/permissions/role-lock.tsx b/apps/sim/components/permissions/role-lock.tsx index a3f6af078c1..4b6fed19f04 100644 --- a/apps/sim/components/permissions/role-lock.tsx +++ b/apps/sim/components/permissions/role-lock.tsx @@ -54,6 +54,9 @@ interface RoleLockTooltipProps { /** * Wraps a disabled role control in a tooltip explaining why the role is fixed. * Renders children unchanged when there is no lock reason. + * + * The trigger is a `grid` so the wrapper stays layout-transparent; a + * shrink-to-content wrapper would size locked and unlocked rows differently. */ export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) { if (!reason) return <>{children} @@ -61,7 +64,7 @@ export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) { return ( -
{children}
+
{children}
{reason} diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 6a66e3ed75a..c6afcf93ca2 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -93,6 +93,7 @@ describe('settings navigation boundaries', () => { 'secrets', 'byok', 'sandboxes', + 'credential-groups', 'custom-tools', 'mcp', 'workflow-mcp-servers', @@ -100,7 +101,6 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'forks', - 'credential-groups', 'custom-blocks', 'self-host', ]) @@ -492,10 +492,10 @@ describe('settings navigation boundaries', () => { 'teammates', 'byok', 'sandboxes', + 'credential-groups', 'workflow-mcp-servers', 'recently-deleted', 'forks', - 'credential-groups', 'custom-blocks', 'self-host', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index d7707e37bcb..f70cc4193d1 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -537,13 +537,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'credential-groups', description: 'Collect and manage OAuth credentials for people outside this workspace.', group: 'workspace', - order: 2, + order: 9, requiresEnterprise: true, allowNonOrgAdmin: true, selfHostedOverride: true, }, planes: { - workspace: { id: 'credential-groups', group: 'enterprise', order: 10 }, + workspace: { id: 'credential-groups', group: 'workspace', order: 4 }, }, }, { @@ -676,7 +676,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'recently-deleted', description: 'Restore items deleted in the last 30 days.', group: 'workspace', - order: 9, + order: 10, }, planes: { workspace: { id: 'recently-deleted', group: 'system', order: 9 }, diff --git a/apps/sim/content/library/automation-anywhere-alternative/index.mdx b/apps/sim/content/library/automation-anywhere-alternative/index.mdx new file mode 100644 index 00000000000..bc81db79df6 --- /dev/null +++ b/apps/sim/content/library/automation-anywhere-alternative/index.mdx @@ -0,0 +1,160 @@ +--- +slug: automation-anywhere-alternative +title: 'Automation Anywhere Alternative: AI Agents vs. RPA for Real Reasoning' +description: 'Compare Sim and Automation Anywhere for AI agents, RPA, exception handling, deployment, and pricing to choose the right automation architecture.' +date: 2026-08-15 +updated: 2026-08-15 +authors: + - andrew +readingTime: 11 +tags: [AI Agents, RPA, Workflow Automation, Sim] +ogImage: /library/automation-anywhere-alternative/cover.jpg +canonical: https://www.sim.ai/library/automation-anywhere-alternative +draft: false +faq: + - q: "Is RPA the same as an AI agent?" + a: "No. An RPA bot follows configured steps, rules, and interface actions. An AI agent interprets context and chooses among available actions. Modern Automation Anywhere products combine both, and Sim combines Agent blocks with deterministic workflow controls. The useful distinction is whether a particular step should replay a known procedure or reason over variable input." + - q: "Can Sim replace Automation Anywhere bots entirely?" + a: "Sometimes, but that should not be the default goal. Sim can replace workflows that primarily interpret documents, messages, and changing requests before acting through integrations, APIs, or MCP tools. Automation Anywhere remains a stronger fit for stable desktop automation across legacy systems, especially inside an existing RPA program. A hybrid workflow can use Sim for interpretation and Automation Anywhere for the final UI-driven action." + - q: "Does Sim require coding?" + a: "No. You can build through Mothership in natural language or use the visual canvas. Technical users can add functions, call APIs, and expose workflows as services when the process needs custom behavior. You can begin visually and add code only where it earns its place." + - q: "Does Automation Anywhere have AI agents?" + a: "Yes. Automation 360 includes AI Agent Studio, Document Automation, Automation Co-Pilot, and the Process Reasoning Engine. Sim is not differentiated by merely having AI. Its difference is an agent-first workflow graph, an Apache 2.0 core, public entry pricing, and deployment as APIs, chat experiences, or MCP tools." + - q: "What does Sim cost compared with Automation Anywhere?" + a: "Paid Automation Anywhere deployments are quote-based, while Community Edition is free for eligible organizations with usage limits. Sim publishes Free, Pro, Max, and Enterprise plans. Compare the vendor quote and published rates alongside model usage, hosting, runner capacity, infrastructure, and maintenance for the actual process." +--- + +## TL;DR + +- **Choose Sim when your process has to interpret before it acts.** Variable emails, changing documents, exception-heavy queues, and workflows grounded in company knowledge are better fits for an agent-first graph than a recorded UI script. +- **Keep Automation Anywhere when the process is stable, high-volume, and already governed as RPA.** If your bots run reliably across fixed screens and predefined rules, replacing them creates work without creating value. +- **Automation Anywhere is not “RPA without AI.”** [Automation 360 includes AI Agent Studio](https://www.automationanywhere.com/products/ai-agent-studio), [Document Automation](https://www.automationanywhere.com/products/document-automation), [Automation Co-Pilot](https://www.automationanywhere.com/products/automation-copilot), and the [Process Reasoning Engine](https://www.automationanywhere.com/products/process-reasoning-engine). The real difference is architecture: an enterprise bot estate centered on Control Room and Bot Runners versus an open-source agent workspace built around reasoning, APIs, and tools. +- **Sim is easier to pilot and own.** Its [core is Apache 2.0](https://github.com/simstudioai/sim), [pricing is public](https://www.sim.ai/pricing), and workflows can deploy as APIs, hosted chat experiences, or MCP tools. +- **Do not migrate everything.** Start with the queue generating the most exceptions. That is where reasoning has the clearest chance to beat another RPA rule. + +## Is Sim a good Automation Anywhere alternative? + +[Sim](https://github.com/simstudioai/sim) is a good Automation Anywhere alternative when your bots spend more time falling into exception queues than completing the happy path. + +Automation Anywhere's [Automation 360 platform is built to create, govern, and run enterprise automations](https://www.automationanywhere.com/products/automation-360). Its architecture centers on [Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html), Bot Creators, and Bot Runners. [Bot Agent connects each runtime machine to Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html), where teams manage access, schedules, deployments, and execution across attended and unattended bots. + +That model works well when the job is predictable. If a bot always opens the same application, reads the same fields, applies the same validation rules, and enters the same output, RPA is a practical way to automate it. For a broader framework, see [AI agents vs. RPA](https://www.sim.ai/library/ai-agents-vs-rpa). + +The problem starts when the input stops matching the script. A supplier changes an invoice layout. A customer describes the same request in a new way. A policy exception requires reading three documents before deciding what to do. You can keep adding branches to the bot, but every new exception becomes another rule to maintain. + +Sim starts from the opposite direction. You build an agent-first workflow that can interpret natural language and unstructured documents, retrieve relevant context, and choose an action. You then constrain that reasoning with functions, conditions, routers, loops, and human approval. Instead of pretending every case is deterministic, you use fixed logic where the rules are known and reasoning where they are not. This combination is the basis of an [agentic workflow](https://www.sim.ai/library/what-is-an-agentic-workflow). + +This is not an argument that Automation Anywhere lacks AI. Automation Anywhere offers [AI Agent Studio for custom agents](https://www.automationanywhere.com/products/ai-agent-studio), [Document Automation for intelligent document processing](https://www.automationanywhere.com/products/document-automation), [Automation Co-Pilot for conversational assistance](https://www.automationanywhere.com/products/automation-copilot), and a [Process Reasoning Engine for agentic process execution](https://www.automationanywhere.com/products/process-reasoning-engine). The decision is not “AI or no AI.” It is whether you want to extend an enterprise RPA estate with agent capabilities or build the workflow in an open agent workspace from the start. + +## Automation Anywhere vs. Sim at a glance + +Automation Anywhere and Sim can both combine AI with automation, but they make you operate that automation differently. Automation Anywhere provides a centralized RPA control plane and bot-runner estate. Sim provides an agent-first workflow graph that you can inspect, self-host, and expose directly to other systems. + +| Comparison | Automation Anywhere | Sim | +| --- | --- | --- | +| Core model | [Automation 360 combines automation, agents, and document processing](https://www.automationanywhere.com/products/automation-360). | Sim is an open-source workspace for building agent workflows with deterministic controls. | +| Builder | [Automation Workspace](https://www.automationanywhere.com/products/automation-workspace) and Bot Creator tooling author automations managed through Control Room. | You build through Mothership, the visual canvas, or the API. | +| Runtime | [Attended and unattended automation runs on devices connected to Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html). | Workflows run in Sim Cloud or on infrastructure you control. | +| Reasoning | [AI Agent Studio](https://www.automationanywhere.com/products/ai-agent-studio) and the [Process Reasoning Engine](https://www.automationanywhere.com/products/process-reasoning-engine) add goal-driven agents to the platform. | Agent blocks interpret variable input directly inside the workflow graph. | +| Document work | [Document Automation extracts and processes data from business documents](https://www.automationanywhere.com/products/document-automation). | Agent blocks can interpret documents, retrieve knowledge, and return structured output for later blocks. | +| Deterministic control | Bot steps, rules, and exception paths define execution around applications and screens. | Functions, conditions, routers, loops, and approval steps constrain agent behavior. | +| Context | Enterprise systems and Automation 360 products supply process data and governance. | Native Tables, Files, and knowledge bases keep structured data and retrieved context near the workflow. | +| Deployment | [Control Room centralizes orchestration and management](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html). | Deploy a workflow as an API, hosted chat experience, or MCP tool. | +| Observability | [Bot Insight provides analytics for bot operations](https://www.automationanywhere.com/products/bot-insight). | Block-level traces show inputs, outputs, errors, usage, and cost. | +| Hosting and license | [A proprietary platform with cloud deployment options](https://www.automationanywhere.com/products/automation-360). | Sim Cloud plus an [Apache 2.0 core](https://github.com/simstudioai/sim) you can self-host and modify. | +| Pricing | [Paid plans require contacting sales](https://www.automationanywhere.com/company/contact-us); [Community Edition is free for eligible users](https://www.automationanywhere.com/products/automation-360/community-edition). | [Free, Pro, Max, and custom Enterprise plans](https://www.sim.ai/pricing). | +| Best fit | Mature enterprises running governed, stable, high-volume automation across desktops and legacy systems. | Technical teams automating variable work that requires reasoning, retrieval, APIs, or agent tools. | + +## The architectural difference that matters + +Automation Anywhere separates authoring, control, and execution. Bot Creators build automations, [Control Room manages the automation environment](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html), and Bot Runners execute them on connected runtime devices. [Bot Agent connects those devices back to Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html). + +That separation is a feature if you already run an RPA center of excellence. It gives IT a familiar operating model for roles, schedules, devices, attended automations, and unattended automations. It also means a new process may require more than drawing the flow. You are managing runtime machines, runner capacity, permissions, and the licensed Automation 360 components the process uses. + +Sim removes the bot-estate assumption. You build a workflow around integrations, APIs, MCP tools, and agents, then deploy that workflow as a service. If the underlying application exposes an API, Sim can act on the system directly instead of opening its interface and clicking through it. Workflows can also become reusable tools; see [how to turn a workflow into an MCP tool](https://www.sim.ai/library/how-to-turn-a-workflow-into-a-reusable-mcp-tool). + +That distinction changes maintenance. An API contract can still change, but it is usually more stable than a screen selector. A button moving or a page layout changing should not break a workflow that never touches the page. When no usable API exists and desktop automation is the only path, Automation Anywhere has the advantage. + +## Where scripted RPA breaks + +A recorded bot is strongest when the world stays still. The screen loads on time, selectors remain valid, fields appear in the expected order, and every input fits a known branch. Real operations eventually violate those assumptions. + +### UI changes turn into maintenance work + +A renamed button, revised login flow, or changed page structure can stop a UI-driven bot from reaching its next step. Automation Anywhere provides [centralized tools for operating bots through Control Room](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/control-room-overview/control-room-overview.html), but the underlying interaction still depends on the interface when no connector or API is used. + +Sim avoids the interface when the target system exposes an integration, API, or MCP tool. The workflow sends structured requests to the service and receives structured results. You remove an entire class of selector failures because the workflow never clicks the button. + +### Unstructured input does not fit fixed fields + +An invoice, support email, or procurement request can express the same intent in dozens of ways. Adding a rule for every phrasing turns the workflow into a growing tree of special cases. + +A Sim Agent block can classify the request, extract a structured payload, and retrieve the relevant policy from a knowledge base. The next blocks can enforce exact rules: verify required fields, compare a total against an approval threshold, route by department, and stop for human approval before payment. This pattern is especially useful for [AI agents in procurement](https://www.sim.ai/library/ai-agents-in-procurement). + +### Exceptions become the real process + +The happy path may be automated while the operations team spends its day resolving everything that fell outside it. At that point, the exception queue is no longer an edge case. It is the work. + +Sim lets you place reasoning at the point where the fixed workflow loses certainty. A confidence check can route unclear cases to a person while allowing clean cases to continue. You do not need to let an agent improvise the entire process. Give it the narrow job of interpreting the variable input, then hand the result back to deterministic blocks. + +## Where Automation Anywhere is still the better choice + +Do not replace a stable RPA estate just because agents are newer. + +Automation Anywhere is the stronger choice when you have high-volume work running across legacy applications with no usable API, especially if your company already operates [Control Room and connected runtime devices](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html) under a mature center of excellence. + +That operating model matters. Your IT team may already have device pools, runner capacity, access controls, audit procedures, deployment gates, and support ownership built around Automation 360. A reliable bot that enters fixed data into a legacy desktop application is not automatically improved by moving it into an agent platform. + +Automation Anywhere also gives large enterprises a broader RPA operating surface. [AI Agent Studio](https://www.automationanywhere.com/products/ai-agent-studio), [Document Automation](https://www.automationanywhere.com/products/document-automation), [Automation Co-Pilot](https://www.automationanywhere.com/products/automation-copilot), and the [Process Reasoning Engine](https://www.automationanywhere.com/products/process-reasoning-engine) extend the same platform instead of forcing a separate platform decision. If your priority is adding agent capabilities while preserving the existing bot estate and its controls, staying in Automation 360 is the lower-risk move. + +The honest dividing line is simple: keep the process in Automation Anywhere when its rules are stable and its UI dependencies are acceptable. Move the exception-heavy part to Sim when the process needs interpretation, retrieval, or context-sensitive decisions that keep producing new bot branches. + +## A concrete test: invoice exception handling + +Do not begin with a platform-wide migration. Take one invoice queue that already creates manual work and run the same representative cases through both approaches. + +Assume your current bot handles invoices from approved suppliers. The happy path is straightforward: open the attachment, read known fields, validate the purchase order, enter the invoice into the finance system, and archive the file. The bot works until a supplier changes its layout, references two purchase orders, describes a credit in free text, or submits a total that conflicts with the contract. + +A practical Sim pilot would look like this: + +1. **Ingest the invoice and message.** Trigger the workflow from email, file upload, or an API call, and preserve both the document and the sender's message. +2. **Interpret the variable input.** Use an Agent block to identify the supplier, invoice number, line items, totals, purchase-order references, and any free-text explanation. +3. **Ground the decision.** Retrieve the supplier contract, purchasing policy, and known exceptions from a knowledge base instead of asking the model to rely on memory. +4. **Return structured data.** Require the agent to produce a fixed schema so later blocks receive predictable fields. +5. **Apply exact rules.** Use functions and conditions to check arithmetic, required fields, duplicate invoice numbers, approval thresholds, and purchase-order status. +6. **Route uncertainty instead of hiding it.** Send low-confidence extraction, conflicting purchase orders, or policy mismatches to a human approval step with the source document and the agent's explanation attached. +7. **Act through the system interface.** Submit approved invoices through an integration, API, or MCP tool. If the finance system only supports desktop UI automation, keep that final entry step in Automation Anywhere. +8. **Compare outcomes.** Measure straight-through completion, manual reviews, false approvals, time per exception, and how often a new input requires another hard-coded rule. + +This test does not ask whether an agent can replace every bot. It asks whether reasoning can shrink the exception queue without weakening control. If it does, keep the stable UI work where it is and move the interpretation layer to Sim. That hybrid is often better than forcing one platform to own every step. + +## Pricing and licensing + +Automation Anywhere does not publish list prices for paid Automation 360 on its public site; prospective buyers are directed to [contact sales](https://www.automationanywhere.com/company/contact-us). The total quote can depend on the environment, creator and runner requirements, and additional capabilities. + +[Community Edition is free for eligible users](https://www.automationanywhere.com/products/automation-360/community-edition), but it is not an unlimited substitute for a paid deployment. Under Automation Anywhere's [Community Edition terms](https://www.automationanywhere.com/terms/community-edition), eligibility requires an organization with fewer than 250 machines, fewer than 250 users, and less than $5 million in annual revenue. The terms also limit use to five machines in the organization and include up to 100 Document Automation pages per month. + +Sim publishes its entry pricing. The [Free plan lets you start without a sales process, Pro costs $25 per user per month, Max costs $100 per user per month, and Enterprise uses custom pricing](https://www.sim.ai/pricing). Model usage is credit-based, so include expected execution volume and model choice when you estimate a production deployment. + +The comparison is not as simple as one seat price against another. An Automation Anywhere budget can include platform licensing, runner capacity, runtime machines, and add-on products. A Sim budget can include seats, model usage, hosting, and any infrastructure you operate yourself. Price the actual process, including the people who maintain it and resolve its exceptions. + +## How to choose between Automation Anywhere and Sim + +Choose Automation Anywhere when: + +- The process depends on legacy desktop applications with no reliable API. +- Inputs and screens are stable enough that scripted execution stays predictable. +- You already operate [Control Room and connected bot runtime devices](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/enterprise-cloud/topics/control-room/devices/cloud-add-local-device.html) at scale. +- Attended or unattended desktop automation is the core requirement. +- Keeping new agent capabilities inside the existing Automation 360 estate matters more than adopting an open platform. + +Choose [Sim](https://sim.ai) when: + +- The workflow must interpret changing emails, documents, or natural-language requests. +- Exceptions require policy retrieval and context rather than another hard-coded branch. +- You want to combine agent reasoning with exact functions, conditions, routers, loops, and approvals. +- You want an [Apache 2.0 core](https://github.com/simstudioai/sim) that you can self-host, inspect, modify, or run in an isolated environment. +- You need to deploy the result as an API, hosted chat experience, or MCP tool. +- [Public entry pricing](https://www.sim.ai/pricing) and a fast self-serve pilot matter. + +If openness and infrastructure control are central to the decision, compare the tradeoffs among [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). The best first project is not your biggest bot. It is the workflow with the highest volume of manual exceptions. That gives you a measurable question: can Sim resolve more variable cases without adding rules or increasing risk? diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts b/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts deleted file mode 100644 index e0f11afb3c8..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups' -import { getEnrollmentStatus } from '@/ee/credential-groups/components/credential-group-detail' - -const ENROLLMENT: CredentialGroupEnrollmentDetail = { - id: 'enrollment-1', - credentialGroupId: 'group-1', - email: 'person@example.com', - status: 'in_progress', - expiresAt: '2026-08-13T00:00:00.000Z', - invitedAt: '2026-08-12T00:00:00.000Z', - sentAt: '2026-08-12T00:00:00.000Z', - completedAt: null, - revokedAt: null, - expired: true, - createdAt: '2026-08-12T00:00:00.000Z', - updatedAt: '2026-08-13T00:00:00.000Z', - connections: [{ provider: 'gmail', status: 'needs_reauth', count: 1 }], -} - -describe('Credential Group enrollment status', () => { - it('keeps expired incomplete invitations ahead of credential reauthorization', () => { - expect(getEnrollmentStatus(ENROLLMENT, ['gmail'])).toEqual({ - label: 'Expired', - invalid: true, - }) - }) - - it('shows reauthorization for completed enrollments after their invitation expires', () => { - expect(getEnrollmentStatus({ ...ENROLLMENT, status: 'completed' }, ['gmail'])).toEqual({ - label: 'Reconnect needed', - invalid: false, - }) - }) -}) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 3bab5af6bc0..062b8783cd8 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -1,18 +1,19 @@ 'use client' import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn' -import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' +import { Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn' +import { ArrowLeft, Plus, User } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, - CredentialGroupEnrollmentDetail, } from '@/lib/api/contracts/credential-groups' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { credentialGroupTabParam, credentialGroupTabUrlKeys, @@ -26,12 +27,15 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' import { useCredentialGroupDetail, + useDeleteCredentialGroup, + useDeleteCredentialGroupEnrollment, useResendCredentialGroupEnrollment, - useRevokeCredentialGroupEnrollment, + useUpdateCredentialGroup, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' @@ -48,35 +52,6 @@ const CREDENTIAL_GROUP_TABS = [ { value: 'people', label: 'People' }, ] as const -export function getEnrollmentStatus( - enrollment: CredentialGroupEnrollmentDetail, - activeProviders: CredentialGroupProvider[] -) { - if (enrollment.status === 'revoked') return { label: 'Revoked', invalid: false } - if (enrollment.status === 'delivery_failed') return { label: 'Delivery failed', invalid: true } - if (enrollment.status !== 'completed' && enrollment.expired) { - return { label: 'Expired', invalid: true } - } - const needsReauthorization = enrollment.connections.some( - (connection) => connection.status === 'needs_reauth' - ) - if (needsReauthorization) return { label: 'Reconnect needed', invalid: false } - const connectedProviders = new Set( - enrollment.connections - .filter((connection) => connection.status === 'active') - .map((connection) => connection.provider) - ) - const allProvidersConnected = - activeProviders.length > 0 && - activeProviders.every((provider) => connectedProviders.has(provider)) - if (enrollment.status === 'completed' && allProvidersConnected) { - return { label: 'Connected', invalid: false } - } - if (enrollment.status === 'completed') return { label: 'In progress', invalid: false } - if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false } - return { label: 'Invited', invalid: false } -} - interface EnrollmentConnectionsProps { connections: CredentialGroupEnrollmentConnection[] } @@ -119,22 +94,23 @@ export function CredentialGroupDetail({ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) const resend = useResendCredentialGroupEnrollment() - const revoke = useRevokeCredentialGroupEnrollment() + const deleteEnrollment = useDeleteCredentialGroupEnrollment() + const updateGroup = useUpdateCredentialGroup() + const deleteGroup = useDeleteCredentialGroup() const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { ...credentialGroupTabParam.parser, ...credentialGroupTabUrlKeys, }) const [showInvite, setShowInvite] = useState(false) - const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const [showDelete, setShowDelete] = useState(false) + const [deletingEnrollmentId, setDeletingEnrollmentId] = useState(null) + const [draftName, setDraftName] = useState(null) + const [draftDescription, setDraftDescription] = useState(null) const credentialGroup = detail.data?.pages[0]?.credentialGroup const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] - const revokingEnrollment = revokingEnrollmentId - ? (enrollments.find((enrollment) => enrollment.id === revokingEnrollmentId) ?? null) + const deletingEnrollment = deletingEnrollmentId + ? (enrollments.find((enrollment) => enrollment.id === deletingEnrollmentId) ?? null) : null - const activeProviders = - credentialGroup?.options - .filter((option) => option.status === 'active') - .map((option) => option.provider) ?? [] const configurationReady = Boolean(credentialGroup?.options.length) && credentialGroup?.options.every( @@ -144,14 +120,65 @@ export function CredentialGroupDetail({ slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) ) + const name = draftName ?? credentialGroup?.name ?? '' + const description = draftDescription ?? credentialGroup?.description ?? '' + const normalizedDescription = description.trim() || null + const detailsDirty = Boolean( + credentialGroup && + (name.trim() !== credentialGroup.name || + normalizedDescription !== credentialGroup.description) + ) + const guard = useSettingsUnsavedGuard({ isDirty: detailsDirty }) + + const discardDetails = () => { + setDraftName(null) + setDraftDescription(null) + } + + const handleSaveDetails = async () => { + if (!credentialGroup || !name.trim()) return + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { name: name.trim(), description: normalizedDescription }, + }) + discardDetails() + toast.success('Details saved') + } catch (error) { + toast.error(getErrorMessage(error, 'Could not save details')) + } + } + + /** + * Each tab owns its own primary action: Details commits the edited name and + * description, People invites more users. Delete is available from both. + */ const actions: SettingsAction[] = credentialGroup ? [ + ...(activeTab === 'details' + ? saveDiscardActions({ + dirty: detailsDirty, + saving: updateGroup.isPending, + onSave: () => void handleSaveDetails(), + onDiscard: discardDetails, + saveDisabled: !name.trim(), + saveTooltip: name.trim() ? undefined : 'Name is required', + }) + : [ + { + text: 'Invite users', + icon: Plus, + variant: 'primary' as const, + onSelect: () => setShowInvite(true), + disabled: credentialGroup.status !== 'active' || !configurationReady, + }, + ]), { - text: 'Invite users', - icon: Plus, - variant: 'primary', - onSelect: () => setShowInvite(true), - disabled: credentialGroup.status !== 'active' || !configurationReady, + id: 'delete', + text: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onSelect: () => setShowDelete(true), + disabled: deleteGroup.isPending, }, ] : [] @@ -165,30 +192,40 @@ export function CredentialGroupDetail({ } } - const handleRevoke = async () => { - if (!revokingEnrollment) return + const handleDeleteEnrollment = async () => { + if (!deletingEnrollment) return try { - await revoke.mutateAsync({ + await deleteEnrollment.mutateAsync({ workspaceId, groupId, - enrollmentId: revokingEnrollment.id, + enrollmentId: deletingEnrollment.id, }) - toast.success(`Invitation revoked for ${revokingEnrollment.email}`) - setRevokingEnrollmentId(null) + toast.success(`${deletingEnrollment.email} deleted`) + setDeletingEnrollmentId(null) } catch (error) { - toast.error(getErrorMessage(error, 'Failed to revoke invitation')) + toast.error(getErrorMessage(error, 'Failed to delete person')) } } - const handleBack = () => { - void setActiveTab(null, { history: 'replace' }) - onBack() + const handleDelete = async () => { + if (!credentialGroup) return + try { + await deleteGroup.mutateAsync({ workspaceId, groupId }) + setShowDelete(false) + onBack() + } catch (error) { + toast.error(getErrorMessage(error, 'Could not delete credential group')) + } } return ( <> guard.guardBack(onBack), + }} title={credentialGroup?.name ?? 'Credential group'} description={credentialGroup?.description ?? undefined} actions={actions} @@ -198,7 +235,7 @@ export function CredentialGroupDetail({ {getErrorMessage(detail.error, "Couldn't load credential group")} ) : detail.isPending || !credentialGroup ? null : ( -
+ <> {activeTab === 'details' && ( - + )} {activeTab === 'people' && ( @@ -229,41 +273,31 @@ export function CredentialGroupDetail({ ) : (
{enrollments.map((enrollment) => { - const status = getEnrollmentStatus(enrollment, activeProviders) return ( } + icon={} + iconFilled title={enrollment.email} description={ } - badge={ - - {status.label} - - } trailing={ - enrollment.status === 'revoked' ? undefined : ( - void handleResend(enrollment), - disabled: resend.isPending, - }, - { - label: 'Revoke', - destructive: true, - onSelect: () => setRevokingEnrollmentId(enrollment.id), - }, - ]} - /> - ) + void handleResend(enrollment), + disabled: resend.isPending, + }, + { + label: 'Delete', + destructive: true, + onSelect: () => setDeletingEnrollmentId(enrollment.id), + }, + ]} + /> } /> ) @@ -272,7 +306,7 @@ export function CredentialGroupDetail({ )} )} -
+ )} {credentialGroup && ( @@ -284,18 +318,47 @@ export function CredentialGroupDetail({ /> )} !open && !revoke.isPending && setRevokingEnrollmentId(null)} - srTitle='Revoke invitation' - title='Revoke invitation?' - text={`Revoke the invitation for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working immediately.`} + open={Boolean(deletingEnrollment)} + onOpenChange={(open) => + !open && !deleteEnrollment.isPending && setDeletingEnrollmentId(null) + } + srTitle='Delete person' + title='Delete person' + text={[ + `Delete ${deletingEnrollment?.email ?? 'this person'}?`, + { + text: ' Their private link will stop working and all accounts they connected to this Credential Group will be removed.', + error: true, + }, + ]} dismissLabel='Cancel' confirm={{ - label: revoke.isPending ? 'Revoking...' : 'Revoke', - onClick: handleRevoke, - disabled: revoke.isPending, + label: deleteEnrollment.isPending ? 'Deleting...' : 'Delete', + onClick: handleDeleteEnrollment, + disabled: deleteEnrollment.isPending, }} /> + !open && !deleteGroup.isPending && setShowDelete(false)} + srTitle='Delete credential group' + title='Delete credential group' + text={[ + `Delete ${credentialGroup?.name ?? 'this credential group'}?`, + { text: ' This cannot be undone.', error: true }, + ]} + dismissLabel='Cancel' + confirm={{ + label: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onClick: handleDelete, + disabled: deleteGroup.isPending, + }} + /> + ) } diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index 501f0a7b366..7104fe3214c 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' +import type { WorkspaceCredential } from '@/lib/api/contracts' import type { CredentialGroup, CredentialGroupOption, @@ -28,9 +29,17 @@ import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack- import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +/** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */ +const EMPTY_SLACK_BOTS: WorkspaceCredential[] = [] + interface CredentialGroupDetailsProps { credentialGroup: CredentialGroup workspaceId: string + /** Edited name; committed by the panel header's Save action, which owns the dirty state. */ + name: string + onNameChange: (name: string) => void + description: string + onDescriptionChange: (description: string) => void } function toOptionUpdateInput( @@ -39,7 +48,7 @@ function toOptionUpdateInput( const common = { id: option.id, label: getCredentialGroupProviderService(option.provider).name, - required: true, + required: false, } if (option.provider !== 'slack') return { ...common, provider: option.provider } return { @@ -52,6 +61,10 @@ function toOptionUpdateInput( export function CredentialGroupDetails({ credentialGroup, workspaceId, + name, + onNameChange, + description, + onDescriptionChange, }: CredentialGroupDetailsProps) { const updateGroup = useUpdateCredentialGroup() const slackBots = useWorkspaceCredentials({ @@ -59,15 +72,9 @@ export function CredentialGroupDetails({ type: 'service_account', providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) - const [name, setName] = useState(credentialGroup.name) - const [description, setDescription] = useState(credentialGroup.description ?? '') - const [slackSetupOpen, setSlackSetupOpen] = useState(false) - const [slackSetupCredentialId, setSlackSetupCredentialId] = useState() + const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null) const [removingProvider, setRemovingProvider] = useState(null) - const normalizedDescription = description.trim() || null - const detailsDirty = - name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description const isUpdating = updateGroup.isPending const updateOptions = async ( @@ -94,14 +101,13 @@ export function CredentialGroupDetails({ const nextOption: NonNullable[number] = { provider, label: service.name, - required: true, + required: false, } return updateOptions([...existing, nextOption], `${service.name} added`) } const openSlackSetup = (credentialId?: string) => { - setSlackSetupCredentialId(credentialId) - setSlackSetupOpen(true) + setSlackSetup({ credentialId }) } const handleProviderAction = (provider: CredentialGroupProvider) => { @@ -117,20 +123,6 @@ export function CredentialGroupDetails({ throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`) } - const handleSaveDetails = async () => { - if (!detailsDirty || !name.trim() || isUpdating) return - try { - await updateGroup.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { name: name.trim(), description: normalizedDescription }, - }) - toast.success('Details saved') - } catch (error) { - toast.error(getErrorMessage(error, 'Could not save details')) - } - } - const handleRemoveProvider = async () => { if (!removingProvider) return const service = getCredentialGroupProviderService(removingProvider) @@ -142,141 +134,129 @@ export function CredentialGroupDetails({ return ( <> -
- void handleSaveDetails()} - disabled={!name.trim() || isUpdating} - > - {isUpdating ? 'Saving...' : 'Save changes'} - - ) : undefined - } - > -
- - setName(event.target.value)} - error={!name.trim()} - /> - - - setDescription(event.target.value)} - placeholder='What these accounts will be used for' - rows={3} - /> - -
-
+ +
+ + onNameChange(event.target.value)} + error={!name.trim()} + /> + + + onDescriptionChange(event.target.value)} + placeholder='What these accounts will be used for' + rows={3} + /> + +
+
- -
- {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { - const service = getCredentialGroupProviderService(provider) - const support = getCredentialGroupProviderSupport(provider) - const option = credentialGroup.options.find( - (candidate) => candidate.provider === provider - ) - const ProviderIcon = service.icon - const slackBot = - provider === 'slack' && option?.provider === 'slack' - ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) - : undefined - const slackNeedsSetup = - provider === 'slack' && - option?.provider === 'slack' && - (!slackBot || option.configurationStatus !== 'ready') - const descriptionText = - provider === 'slack' && option - ? slackBot - ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` - : slackBots.isPending - ? 'Loading custom Slack app...' - : 'Custom Slack app unavailable' - : support.description + +
+ {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + const service = getCredentialGroupProviderService(provider) + const support = getCredentialGroupProviderSupport(provider) + const option = credentialGroup.options.find( + (candidate) => candidate.provider === provider + ) + const ProviderIcon = service.icon + const slackBot = + provider === 'slack' && option?.provider === 'slack' + ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) + : undefined + const slackNeedsSetup = + provider === 'slack' && + option?.provider === 'slack' && + (!slackBot || option.configurationStatus !== 'ready') + const descriptionText = + provider === 'slack' && option + ? slackBot + ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` + : slackBots.isPending + ? 'Loading custom Slack app...' + : 'Custom Slack app unavailable' + : support.description - return ( - } - title={service.name} - description={descriptionText} - badge={ - option && !slackNeedsSetup ? ( - Connected - ) : undefined - } - trailing={ - option ? ( -
- {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( - openSlackSetup(slackBot.id)} disabled={isUpdating}> - Continue setup - - ) : null} - - openSlackSetup( - option?.provider === 'slack' - ? option.slackBotCredentialId - : undefined - ), - disabled: isUpdating, - }, - ] - : []), - { - label: 'Remove', - destructive: true, - onSelect: () => setRemovingProvider(provider), - disabled: isUpdating, - }, - ]} - /> -
- ) : ( - handleProviderAction(provider)} - disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} - > - {support.configuration === 'oauth' ? 'Add' : 'Set up'} - - ) - } - /> - ) - })} -
-
-
+ return ( + } + title={service.name} + description={descriptionText} + badge={ + option && !slackNeedsSetup ? ( + Connected + ) : undefined + } + trailing={ + option ? ( +
+ {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( + openSlackSetup(slackBot.id)} disabled={isUpdating}> + Continue setup + + ) : null} + + openSlackSetup( + option?.provider === 'slack' + ? option.slackBotCredentialId + : undefined + ), + disabled: isUpdating, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingProvider(provider), + disabled: isUpdating, + }, + ]} + /> +
+ ) : ( + handleProviderAction(provider)} + disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} + > + {support.configuration === 'oauth' ? 'Add' : 'Set up'} + + ) + } + /> + ) + })} +
+ { - setSlackSetupOpen(nextOpen) - if (!nextOpen) setSlackSetupCredentialId(undefined) + if (!nextOpen) setSlackSetup(null) }} - bots={slackBots.data ?? []} + bots={slackBots.data ?? EMPTY_SLACK_BOTS} isLoading={slackBots.isPending} error={slackBots.error} - initialCredentialId={slackSetupCredentialId} + initialCredentialId={slackSetup?.credentialId} /> item.email).join(', ')}` : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` ) - } catch (error) { - setDeliveryError(getErrorMessage(error, 'Failed to send invitations')) + } catch { + return } } @@ -100,7 +100,8 @@ export function CredentialGroupInviteModal({ disabled={invite.isPending} /> - {deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)} + {deliveryError ?? + (invite.error ? getErrorMessage(invite.error, 'Failed to send invitations') : null)} (null) const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { ...credentialGroupIdParam.parser, ...credentialGroupIdUrlKeys, }) - const deletingGroup = groups.find((group) => group.id === deletingGroupId) + /** + * The detail view's tab is scoped to one group, so both transitions reset it — + * otherwise a `credential-group-id` that never resolves leaves + * `credential-group-tab` behind and the next group opens on the previous + * group's tab. nuqs batches these same-tick writes into one URL update. + */ + const [, setSelectedTab] = useQueryState(credentialGroupTabParam.key, { + ...credentialGroupTabParam.parser, + ...credentialGroupTabUrlKeys, + }) + const openGroup = (groupId: string) => { + void setSelectedGroupId(groupId) + void setSelectedTab(null) + } + const closeGroup = () => { + void setSelectedGroupId(null, { history: 'replace' }) + void setSelectedTab(null) + } const selectedGroup = selectedGroupId ? groups.find((group) => group.id === selectedGroupId) : undefined @@ -59,22 +75,20 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin }, ] - const handleDelete = async () => { - if (!deletingGroupId) return - try { - await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) - setDeletingGroupId(null) - } catch { - return - } - } + /** + * Hold the first paint while a deep-linked id could still resolve, so a valid + * link never flashes the list before jumping to it. A dead id still falls back + * to the list. + */ + if (selectedGroupId !== null && isPending) return null if (selectedGroup) { return ( void setSelectedGroupId(null, { history: 'replace' })} + onBack={closeGroup} /> ) } @@ -97,18 +111,24 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin ) : isPending ? null : groups.length === 0 ? ( Click "Create group" above to get started ) : filtered.length === 0 ? ( - No groups match "{search}" + + No credential groups found matching "{search}" + ) : (
{filtered.map((group) => { const optionCount = group.options.length + const accountTypes = `${optionCount} account type${optionCount === 1 ? '' : 's'}` return ( } + icon={} + iconFilled title={group.name} - description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`} - onClick={() => void setSelectedGroupId(group.id)} + description={ + group.description ? `${accountTypes} · ${group.description}` : accountTypes + } + onClick={() => openGroup(group.id)} clickLabel={`Open ${group.name}`} navigable badge={ @@ -116,18 +136,6 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin Disabled ) : undefined } - trailing={ - setDeletingGroupId(group.id), - }, - ]} - /> - } /> ) })} @@ -137,25 +145,9 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin void setSelectedGroupId(groupId)} + onCreated={openGroup} workspaceId={workspaceId} /> - !open && !deleteGroup.isPending && setDeletingGroupId(null)} - srTitle='Delete credential group' - title='Delete credential group' - text={[ - `Delete ${deletingGroup?.name ?? 'this credential group'}?`, - { text: ' This cannot be undone.', error: true }, - ]} - dismissLabel='Cancel' - confirm={{ - label: deleteGroup.isPending ? 'Deleting...' : 'Delete', - onClick: handleDelete, - disabled: deleteGroup.isPending, - }} - /> ) } diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx index a6bdef02c80..34d3052565c 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -94,50 +94,78 @@ export function SlackManagedUsersModal({ const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) + const reset = () => { + popup.current?.close() + popup.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + expectedState.current = null + expectedCredentialId.current = null + setSelectedCredentialId(null) + setClientId('') + setClientSecret('') + setPending(false) + startAuthorization.reset() + } + + const handleAuthorizationMessage = (message: SlackManagedUsersMessage) => { + if (!expectedState.current || message.state !== expectedState.current) return + const verifiedCredentialId = expectedCredentialId.current + expectedState.current = null + expectedCredentialId.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + popup.current?.close() + popup.current = null + setPending(false) + if (!message.ok) { + const notification = getSlackManagedUsersFailureNotification(message.reason) + if (notification.variant === 'warning') toast.warning(notification.message) + else toast.error(notification.message) + return + } + if ( + message.credentialGroupId !== credentialGroupId || + !verifiedCredentialId || + message.slackBotCredentialId !== verifiedCredentialId + ) { + toast.error('Slack app verification failed. Please try again.') + return + } + if (!bots.some((bot) => bot.id === verifiedCredentialId)) { + toast.error('The verified Slack app is no longer available.') + return + } + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), + }) + toast.success('Slack configured') + onOpenChange(false) + reset() + } + + /** + * The subscription's identity is `open` alone. Routing the handler through a + * ref keeps a `bots` refetch from closing and reopening the channel mid-flow, + * which would drop an already-queued authorization message from the popup. + */ + const messageHandler = useRef(handleAuthorizationMessage) + useEffect(() => { + messageHandler.current = handleAuthorizationMessage + }) + useEffect(() => { if (!open) return const channel = new BroadcastChannel(CHANNEL_NAME) channel.onmessage = (event: MessageEvent) => { if (!isSlackManagedUsersMessage(event.data)) return - if (!expectedState.current || event.data.state !== expectedState.current) return - const verifiedCredentialId = expectedCredentialId.current - expectedState.current = null - expectedCredentialId.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - popup.current?.close() - popup.current = null - setPending(false) - if (!event.data.ok) { - const notification = getSlackManagedUsersFailureNotification(event.data.reason) - if (notification.variant === 'warning') toast.warning(notification.message) - else toast.error(notification.message) - return - } - if ( - event.data.credentialGroupId !== credentialGroupId || - !verifiedCredentialId || - event.data.slackBotCredentialId !== verifiedCredentialId - ) { - toast.error('Slack app verification failed. Please try again.') - return - } - if (!bots.some((bot) => bot.id === verifiedCredentialId)) { - toast.error('The verified Slack app is no longer available.') - return - } - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.list(workspaceId), - }) - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), - }) - toast.success('Slack configured') - onOpenChange(false) - reset() + messageHandler.current(event.data) } return () => channel.close() - }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) + }, [open]) useEffect( () => () => { @@ -147,20 +175,6 @@ export function SlackManagedUsersModal({ [] ) - const reset = () => { - popup.current?.close() - popup.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - expectedState.current = null - expectedCredentialId.current = null - setSelectedCredentialId(null) - setClientId('') - setClientSecret('') - setPending(false) - startAuthorization.reset() - } - const handleOpenChange = (nextOpen: boolean) => { if (pending && !nextOpen) return onOpenChange(nextOpen) @@ -247,12 +261,12 @@ export function SlackManagedUsersModal({ {isLoading ? ( -
+
- +
) : noBots ? ( -

+

Add a custom Slack app from Integrations before adding Slack to this group.

) : ( diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-noun.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-noun.test.ts new file mode 100644 index 00000000000..3304c6dbd9d --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-noun.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { dependentFieldNoun } from '@/ee/workspace-forking/components/fork-sync/dependent-field-noun' + +describe('dependentFieldNoun', () => { + it('strips a leading imperative verb so copy does not stutter', () => { + // The defect this exists to prevent: `Select ${title.toLowerCase()}` on a title that + // already reads as an instruction rendered "Select select issue". + expect(dependentFieldNoun('Select Issue')).toBe('issue') + expect(dependentFieldNoun('Select Project')).toBe('project') + expect(dependentFieldNoun('Choose Document')).toBe('document') + expect(dependentFieldNoun('Pick a Table')).toBe('a table') + }) + + it('leaves a title that merely starts with those letters alone', () => { + // The trailing `\s+` is what separates the verb from a word that begins with it. + expect(dependentFieldNoun('Selected Files')).toBe('selected files') + expect(dependentFieldNoun('Selection')).toBe('selection') + }) + + it('falls back to the whole title when stripping would leave nothing', () => { + // A bare verb has no noun to extract; an empty result would render "Select " and + // "No found". + expect(dependentFieldNoun('Select')).toBe('select') + expect(dependentFieldNoun('Select ')).toBe('select ') + }) + + it('passes a plain noun through lowercased', () => { + expect(dependentFieldNoun('Label')).toBe('label') + expect(dependentFieldNoun('Conflict Column')).toBe('conflict column') + }) +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-noun.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-noun.ts new file mode 100644 index 00000000000..566bd7166cc --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-noun.ts @@ -0,0 +1,19 @@ +/** + * Leading imperative verb on a field title. Titles are labels, and some already read as an + * instruction ("Select Issue", "Choose Project"), so composing surrounding copy onto them + * verbatim produces "Select select issue". The trailing `\s+` is load-bearing: it stops + * "Selected Files" and "Selection" from being mangled into "ed Files" / "ion". + */ +const LEADING_IMPERATIVE_VERB = /^(?:select|choose|pick)\s+/i + +/** + * The bare noun of a dependent field's title, for copy that supplies its own verb + * ("Select {noun}", "Search {noun}...", "No {noun} found"). + * + * Falls back to the whole title when stripping would leave nothing — a title that is only a + * verb has no noun to extract, and an empty noun would render "Select " and "No found". + */ +export function dependentFieldNoun(title: string): string { + const stripped = title.replace(LEADING_IMPERATIVE_VERB, '').trim() + return (stripped || title).toLowerCase() +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx index a962841025d..a10e098e491 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn' +import { dependentFieldNoun } from '@/ee/workspace-forking/components/fork-sync/dependent-field-noun' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import { useSelectorOptions } from '@/hooks/selectors/use-selector-query' @@ -46,6 +47,8 @@ export function DependentFieldSelector({ [options] ) + const noun = dependentFieldNoun(title) + if (isLoading && enabled) { return (
@@ -62,10 +65,10 @@ export function DependentFieldSelector({ value={value || undefined} onChange={(next) => onChange(next)} searchable - searchPlaceholder={`Search ${title.toLowerCase()}...`} - placeholder={`Select ${title.toLowerCase()}`} + searchPlaceholder={`Search ${noun}...`} + placeholder={`Select ${noun}`} disabled={!enabled} - emptyMessage={`No ${title.toLowerCase()} found`} + emptyMessage={`No ${noun} found`} /> ) } diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts index 0f4963adcad..d91517dd389 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts @@ -4,9 +4,12 @@ import { describe, expect, it } from 'vitest' import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' import { + applyDependentRepick, dependentKey, effectiveCopyDependentValue, effectiveDependentValue, + getActionableDependentFields, + isDependentConfigurationActionable, } from '@/ee/workspace-forking/components/fork-sync/dependent-value' const field = (overrides: Partial = {}): ForkDependentReconfig => ({ @@ -97,3 +100,245 @@ describe('effectiveCopyDependentValue', () => { expect(effectiveCopyDependentValue(f, {})).toBe('') }) }) + +describe('applyDependentRepick', () => { + it('clears direct and transitive descendants without touching unrelated fields', () => { + const site = field({ + subBlockKey: 'siteId', + currentValue: 'site-old', + providesContextKey: 'siteId', + }) + const drive = field({ + subBlockKey: 'driveId', + currentValue: 'drive-old', + providesContextKey: 'driveId', + consumesContextKeys: ['siteId'], + }) + const spreadsheet = field({ + subBlockKey: 'spreadsheetId', + currentValue: 'spreadsheet-old', + providesContextKey: 'spreadsheetId', + consumesContextKeys: ['driveId'], + }) + const sheet = field({ + subBlockKey: 'sheetName', + currentValue: 'Sheet1', + consumesContextKeys: ['spreadsheetId'], + }) + const unrelated = field({ subBlockKey: 'label', currentValue: 'keep-me' }) + const previous = { + [dependentKey(drive)]: 'drive-repicked', + [dependentKey(spreadsheet)]: 'spreadsheet-repicked', + [dependentKey(sheet)]: 'Sheet2', + [dependentKey(unrelated)]: 'still-keep-me', + } + + const next = applyDependentRepick( + previous, + site, + [site, drive, spreadsheet, sheet, unrelated], + 'site-new' + ) + + expect(next).toEqual({ + [dependentKey(site)]: 'site-new', + [dependentKey(drive)]: '', + [dependentKey(spreadsheet)]: '', + [dependentKey(sheet)]: '', + [dependentKey(unrelated)]: 'still-keep-me', + }) + expect(effectiveDependentValue(drive, next, false)).toBe('') + expect(effectiveCopyDependentValue(sheet, next)).toBe('') + }) + + it('only changes the selected field when it provides no selector context', () => { + const leaf = field({ subBlockKey: 'issueKey', currentValue: 'ISSUE-1' }) + const unrelated = field({ subBlockKey: 'label', currentValue: 'keep-me' }) + + expect( + applyDependentRepick( + { [dependentKey(unrelated)]: 'still-keep-me' }, + leaf, + [leaf, unrelated], + 'ISSUE-2' + ) + ).toEqual({ + [dependentKey(leaf)]: 'ISSUE-2', + [dependentKey(unrelated)]: 'still-keep-me', + }) + }) +}) + +describe('isDependentConfigurationActionable', () => { + it('hides stored values when the mapped parent is unchanged', () => { + expect( + isDependentConfigurationActionable( + field({ required: true, currentValue: 'INBOX' }), + {}, + { + parentResolved: true, + parentChanged: false, + copying: false, + } + ) + ).toBe(false) + }) + + it('shows a required value that is missing under an unchanged mapped parent', () => { + expect( + isDependentConfigurationActionable( + field({ required: true, currentValue: '' }), + {}, + { + parentResolved: true, + parentChanged: false, + copying: false, + } + ) + ).toBe(true) + }) + + it('hides a missing optional value under an unchanged mapped parent', () => { + expect( + isDependentConfigurationActionable( + field({ required: false, currentValue: '' }), + {}, + { + parentResolved: true, + parentChanged: false, + copying: false, + } + ) + ).toBe(false) + }) + + it('shows every dependent when the mapped parent changed', () => { + expect( + isDependentConfigurationActionable( + field({ required: false, currentValue: 'INBOX' }), + {}, + { + parentResolved: true, + parentChanged: true, + copying: false, + } + ) + ).toBe(true) + }) + + it('shows every dependent when the parent will be copied', () => { + expect( + isDependentConfigurationActionable( + field({ required: false, currentValue: 'INBOX' }), + {}, + { + parentResolved: true, + parentChanged: false, + copying: true, + } + ) + ).toBe(true) + }) + + it('hides dependents until their parent is resolved', () => { + expect( + isDependentConfigurationActionable( + field({ required: true, currentValue: '' }), + {}, + { + parentResolved: false, + parentChanged: false, + copying: false, + } + ) + ).toBe(false) + }) +}) + +describe('getActionableDependentFields', () => { + const unchangedMappedParent = { + parentResolved: true, + parentChanged: false, + copying: false, + } + + it('includes the context provider for a required missing child', () => { + const spreadsheet = field({ + subBlockKey: 'spreadsheetId', + title: 'Spreadsheet', + currentValue: '', + providesContextKey: 'spreadsheetId', + }) + const sheet = field({ + subBlockKey: 'sheetName', + title: 'Sheet', + currentValue: '', + required: true, + consumesContextKeys: ['spreadsheetId'], + }) + + expect( + getActionableDependentFields([spreadsheet, sheet], {}, unchangedMappedParent).map( + (dependent) => dependent.subBlockKey + ) + ).toEqual(['spreadsheetId', 'sheetName']) + }) + + it('keeps a saved context provider visible while its child needs configuration', () => { + const spreadsheet = field({ + subBlockKey: 'spreadsheetId', + title: 'Spreadsheet', + currentValue: 'spreadsheet-target', + providesContextKey: 'spreadsheetId', + }) + const sheet = field({ + subBlockKey: 'sheetName', + title: 'Sheet', + currentValue: '', + required: true, + consumesContextKeys: ['spreadsheetId'], + }) + + expect( + getActionableDependentFields([spreadsheet, sheet], {}, unchangedMappedParent).map( + (dependent) => dependent.subBlockKey + ) + ).toEqual(['spreadsheetId', 'sheetName']) + }) + + it('walks transitive providers and leaves unrelated optional fields hidden', () => { + const unrelated = field({ + subBlockKey: 'optionalLabel', + title: 'Optional label', + currentValue: '', + }) + const site = field({ + subBlockKey: 'siteId', + title: 'Site', + currentValue: '', + providesContextKey: 'siteId', + }) + const drive = field({ + subBlockKey: 'driveId', + title: 'Drive', + currentValue: '', + providesContextKey: 'driveId', + consumesContextKeys: ['siteId'], + }) + const spreadsheet = field({ + subBlockKey: 'spreadsheetId', + title: 'Spreadsheet', + currentValue: '', + required: true, + consumesContextKeys: ['driveId'], + }) + + expect( + getActionableDependentFields( + [unrelated, site, drive, spreadsheet], + {}, + unchangedMappedParent + ).map((dependent) => dependent.subBlockKey) + ).toEqual(['siteId', 'driveId', 'spreadsheetId']) + }) +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts index 04f8d213377..84cd3a2dc36 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts @@ -5,6 +5,40 @@ export function dependentKey(dependent: ForkDependentReconfig): string { return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}` } +/** + * Store a dependent re-pick and clear every selector transitively scoped by it. Empty-string + * overrides are intentional: an absent override means "fall back to the stored value", while a + * changed provider makes every stored descendant stale for both mapped and copied parents. + */ +export function applyDependentRepick( + reconfig: Record, + changedField: ForkDependentReconfig, + blockFields: ForkDependentReconfig[], + value: string +): Record { + const changedKey = dependentKey(changedField) + const nextState = { ...reconfig, [changedKey]: value } + if (!changedField.providesContextKey) return nextState + + const pendingContextKeys = [changedField.providesContextKey] + const visitedFields = new Set([changedKey]) + for (let index = 0; index < pendingContextKeys.length; index += 1) { + const contextKey = pendingContextKeys[index] + if (!contextKey) continue + + for (const field of blockFields) { + const fieldKey = dependentKey(field) + if (visitedFields.has(fieldKey) || !field.consumesContextKeys.includes(contextKey)) continue + + visitedFields.add(fieldKey) + nextState[fieldKey] = '' + if (field.providesContextKey) pendingContextKeys.push(field.providesContextKey) + } + } + + return nextState +} + /** * The value sent + displayed for a dependent: the user's in-session re-pick if present, else the * stored value (`currentValue`). Blank when the parent target changed in-session, since the old @@ -37,3 +71,57 @@ export function effectiveCopyDependentValue( if (repicked !== undefined) return repicked return field.currentValue || field.sourceValue } + +export interface DependentConfigurationState { + parentResolved: boolean + parentChanged: boolean + copying: boolean +} + +/** + * Whether a dependent selector needs to be shown. A changed or copied parent requires review + * because its children resolve in a different scope. An unchanged mapping only needs a selector + * when a required value is missing; its stored values are already valid and sync-ready. + */ +export function isDependentConfigurationActionable( + field: ForkDependentReconfig, + reconfig: Record, + state: DependentConfigurationState +): boolean { + if (!state.parentResolved) return false + if (state.parentChanged || state.copying) return true + return field.required && effectiveDependentValue(field, reconfig, false) === '' +} + +/** + * Actionable fields plus the transitive in-block providers that scope them. A provider belongs + * in the configuration UI whenever one of its descendants needs action, even if its saved value + * is present, so the user can see and change the context in which the child is selected. + */ +export function getActionableDependentFields( + fields: ForkDependentReconfig[], + reconfig: Record, + state: DependentConfigurationState +): ForkDependentReconfig[] { + const actionable = new Set( + fields.filter((field) => isDependentConfigurationActionable(field, reconfig, state)) + ) + const providersByContextKey = new Map() + for (const field of fields) { + if (field.providesContextKey) providersByContextKey.set(field.providesContextKey, field) + } + + const pending = Array.from(actionable) + for (let index = 0; index < pending.length; index += 1) { + const field = pending[index] + if (!field) continue + for (const contextKey of field.consumesContextKeys) { + const provider = providersByContextKey.get(contextKey) + if (!provider || actionable.has(provider)) continue + actionable.add(provider) + pending.push(provider) + } + } + + return fields.filter((field) => actionable.has(field)) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index a88d06c0d84..2042ab03b61 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -34,9 +34,12 @@ import { import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation' import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector' import { + applyDependentRepick, + type DependentConfigurationState, dependentKey, effectiveCopyDependentValue, effectiveDependentValue, + getActionableDependentFields, } from '@/ee/workspace-forking/components/fork-sync/dependent-value' import type { ForkKindSummary, @@ -89,6 +92,7 @@ interface DependentBlock { targetBlockId: string blockName: string fields: ForkDependentReconfig[] + configurableFields: ForkDependentReconfig[] } interface WorkflowDependents { @@ -103,7 +107,9 @@ interface WorkflowDependents { */ function groupDependentsByWorkflow( workflows: ForkResourceUsage['workflows'], - dependents: ForkDependentReconfig[] + dependents: ForkDependentReconfig[], + reconfig: Record, + state: DependentConfigurationState ): WorkflowDependents[] { const byWorkflow = new Map() for (const dependent of dependents) { @@ -116,7 +122,12 @@ function groupDependentsByWorkflow( for (const field of byWorkflow.get(workflow.workflowId) ?? []) { let block = byBlock.get(field.targetBlockId) if (!block) { - block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] } + block = { + targetBlockId: field.targetBlockId, + blockName: field.blockName, + fields: [], + configurableFields: [], + } byBlock.set(field.targetBlockId, block) } block.fields.push(field) @@ -124,7 +135,13 @@ function groupDependentsByWorkflow( return { workflowId: workflow.workflowId, workflowName: workflow.workflowName, - blocks: Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName)), + blocks: Array.from(byBlock.values()) + .map((block) => ({ + ...block, + configurableFields: getActionableDependentFields(block.fields, reconfig, state), + })) + .filter((block) => block.configurableFields.length > 0) + .sort((a, b) => a.blockName.localeCompare(b.blockName)), } }) } @@ -146,28 +163,6 @@ function blockChainState( return { providedValues, providedContextKeys } } -/** Store a re-pick and invalidate in-block children chained off the changed field. */ -function applyDependentRepick( - setReconfig: Dispatch>>, - field: ForkDependentReconfig, - blockFields: ForkDependentReconfig[], - value: string -) { - setReconfig((prev) => { - const nextState = { ...prev, [dependentKey(field)]: value } - // A changed parent invalidates its children's stale re-picks. - const providedKey = field.providesContextKey - if (providedKey) { - for (const sibling of blockFields) { - if (sibling.consumesContextKeys.includes(providedKey)) { - delete nextState[dependentKey(sibling)] - } - } - } - return nextState - }) -} - interface DependentSelectorProps { field: ForkDependentReconfig block: DependentBlock @@ -225,7 +220,9 @@ function DependentSelector({ }} enabled={parentValue !== '' && ready} value={effectiveValue(field)} - onChange={(value) => applyDependentRepick(setReconfig, field, block.fields, value)} + onChange={(value) => + setReconfig((current) => applyDependentRepick(current, field, block.fields, value)) + } title={field.title} /> ) @@ -260,7 +257,7 @@ function DependentWorkflowCard({ setReconfig, }: DependentWorkflowCardProps) { const [collapsed, setCollapsed] = useState( - () => !workflow.blocks.some((block) => block.fields.some((field) => field.required)) + () => !workflow.blocks.some((block) => block.configurableFields.some((field) => field.required)) ) return (
{workflow.blocks.map((block) => { - const topLevel = block.fields.filter((field) => !field.toolName) + const topLevel = block.configurableFields.filter((field) => !field.toolName) const byTool = new Map() - for (const field of block.fields) { + for (const field of block.configurableFields) { if (!field.toolName) continue const list = byTool.get(field.toolName) if (list) list.push(field) @@ -357,11 +354,14 @@ function MappingEntry({ controller, group, entry }: MappingEntryProps) { const usages = controller.usagesForEntry(entry) const dependents = controller.dependentsForEntry(entry) - // Group once per (usages, dependents) change - both keep stable references from the - // controller's memoized maps, so this skips recompute across the page's frequent re-renders. const workflows = useMemo( - () => groupDependentsByWorkflow(usages, dependents), - [usages, dependents] + () => + groupDependentsByWorkflow(usages, dependents, controller.reconfig, { + parentResolved: target !== '' || copying, + parentChanged, + copying, + }), + [usages, dependents, controller.reconfig, target, parentChanged, copying] ) const configurable = workflows.filter((workflow) => workflow.blocks.length > 0) const usedOnly = workflows.filter((workflow) => workflow.blocks.length === 0) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts index 88a57454338..622c01cf582 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { folder as folderTable } from '@sim/db/schema' import { dbChainMockFns, resetDbChainMock, @@ -237,4 +238,95 @@ describe('planForkFileCopies', () => { }) expect(tx.insert).not.toHaveBeenCalled() }) + + it('mirrors the source file-folder subtree and places each copy inside it', async () => { + const sourceMeta = { + id: 'wf_src1', + key: 'workspace/src-ws/1-abc-a.txt', + userId: 'uploader-1', + workspaceId: 'src-ws', + folderId: 'child-folder', + context: 'workspace', + chatId: null, + originalName: 'a.txt', + displayName: null, + contentType: 'text/plain', + size: 4321, + deletedAt: null, + uploadedAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + contentUpdatedAt: new Date('2026-01-01'), + } + // A two-level source tree; only the branch holding the copied file is mirrored. + const sourceFolders = [ + { + id: 'root-folder', + name: 'Reports', + parentId: null, + workspaceId: 'src-ws', + resourceType: 'file', + deletedAt: null, + }, + { + id: 'child-folder', + name: 'Q1', + parentId: 'root-folder', + workspaceId: 'src-ws', + resourceType: 'file', + deletedAt: null, + }, + { + id: 'unrelated', + name: 'Archive', + parentId: null, + workspaceId: 'src-ws', + resourceType: 'file', + deletedAt: null, + }, + ] + const insertedFolders: Array> = [] + let folderSelectCall = 0 + const tx = { + select: vi.fn(() => ({ + from: (table: unknown) => ({ + where: () => { + if (table !== folderTable) return Promise.resolve([sourceMeta]) + // First folder read is the source tree; the second is the (empty) target tree. + return Promise.resolve(folderSelectCall++ === 0 ? sourceFolders : []) + }, + }), + })), + insert: vi.fn(() => ({ + values: (rows: Array>) => { + insertedFolders.push(...rows) + return Promise.resolve() + }, + })), + } as unknown as DbOrTx + + const result = await planForkFileCopies({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + fileIds: ['wf_src1'], + now: new Date('2026-02-01'), + }) + + // The file's folder and its ancestor are recreated; the unrelated branch is pruned. + expect(insertedFolders).toHaveLength(2) + const byName = new Map(insertedFolders.map((row) => [row.name, row])) + expect(byName.has('Archive')).toBe(false) + const newRoot = byName.get('Reports')! + const newChild = byName.get('Q1')! + expect(newRoot).toMatchObject({ parentId: null, workspaceId: 'child-ws' }) + // Nesting survives: the copied child points at the copied parent, not the source's. + expect(newChild.parentId).toBe(newRoot.id) + expect(newChild.id).not.toBe('child-folder') + + // The copied file lands in the mirrored folder rather than the target root. + expect(result.blobTasks[0].targetFolderId).toBe(newChild.id) + expect(result.folderIdMap.get('child-folder')).toBe(newChild.id) + expect(result.folderIdMap.get('root-folder')).toBe(newRoot.id) + }) }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts index 1a7dff31853..cfa4888170d 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts @@ -19,6 +19,7 @@ import { } from '@/lib/uploads/core/storage-service' import type { StorageContext } from '@/lib/uploads/shared/types' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows' import { type ForkContentRefMaps, rewriteForkContentRefs, @@ -55,6 +56,13 @@ export interface BlobCopyTask { displayName: string | null userId: string workspaceId: string + /** + * Target file-folder id, already created inside the copy transaction by + * {@link resolveForkFolderMapping}. Optional because tasks queued by an earlier deploy have + * no such field: those replay as `undefined` and finalize at the target root, exactly as + * they did before folder structure transited a fork edge. + */ + targetFolderId?: string | null } export interface PlanForkFileCopiesResult { @@ -74,6 +82,11 @@ export interface PlanForkFileCopiesResult { idMap: Map /** Blob duplications plus deferred metadata to finalize after the fork transaction commits. */ blobTasks: BlobCopyTask[] + /** + * source file-folder id -> target file-folder id for the mirrored subtree. Merged into the + * content-ref maps so `sim:folder/` mentions inside copied bodies resolve to the copy. + */ + folderIdMap: Map } async function getFinalizedFileCopies( @@ -124,7 +137,9 @@ export async function planForkFileCopies(params: { const keyMap = new Map() const idMap = new Map() const blobTasks: BlobCopyTask[] = [] - if (fileIds.length === 0 && fileKeys.length === 0) return { keyMap, idMap, blobTasks } + let folderIdMap = new Map() + if (fileIds.length === 0 && fileKeys.length === 0) + return { keyMap, idMap, blobTasks, folderIdMap } // Match by id and/or storage key (OR'd) so either selection shape resolves to the same // source rows. Batch the metadata read (one query for all selected files): non-deleted, @@ -148,6 +163,19 @@ export async function planForkFileCopies(params: { ) ) + // Mirror the file-folder subtree holding the selected files (plus ancestors) into the target + // and place each copy inside it. Scoped to `resourceType: 'file'`: file folders are a tree of + // their own, disjoint from the workflow folders the workflow copy mirrors. + folderIdMap = await resolveForkFolderMapping({ + tx, + sourceWorkspaceId, + targetWorkspaceId: childWorkspaceId, + userId, + now: params.now, + resourceType: 'file', + contentFolderIds: metas.map((meta) => meta.folderId), + }) + for (const meta of metas) { const childFileId = generateId() // Use the canonical workspace-file key (`workspace/{id}/...`) so the file-serve @@ -168,10 +196,13 @@ export async function planForkFileCopies(params: { displayName: meta.displayName, userId, workspaceId: childWorkspaceId, + // An unmapped folder (pruned, or archived mid-copy) re-roots the file, matching how a + // copied workflow falls back to the target root. + targetFolderId: meta.folderId ? (folderIdMap.get(meta.folderId) ?? null) : null, }) } - return { keyMap, idMap, blobTasks } + return { keyMap, idMap, blobTasks, folderIdMap } } /** @@ -269,7 +300,7 @@ export async function executeForkFileBlobCopies( key: task.targetKey, userId: task.userId, workspaceId: task.workspaceId, - folderId: null, + folderId: task.targetFolderId ?? null, context: task.context, chatId: null, originalName: task.fileName, @@ -312,7 +343,7 @@ export async function executeForkFileBlobCopies( .update(workspaceFiles) .set({ userId: task.userId, - folderId: null, + folderId: task.targetFolderId ?? null, context: task.context, chatId: null, originalName: task.fileName, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index e15e0055fdf..b965aa0654e 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { folder as folderTable } from '@sim/db/schema' import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, @@ -1343,12 +1344,31 @@ describe('copyForkResourceContainers skill copy', () => { describe('copyForkResourceContainers knowledge-base tag definitions', () => { /** Sequential tx mock: each select resolves the next queued row set; inserts are captured per call. */ - function makeKbTx(selects: Array>>) { + /** + * Sequential tx mock over the KB-copy selects, with the folder-mirroring reads served + * separately: the copy resolves the source KB folder subtree before inserting, and dispatching + * on the queried table keeps the queue positional over the KB selects alone instead of + * silently shifting whenever that mapping issues a query. + */ + function makeKbTx( + selects: Array>>, + sourceFolders: Array> = [] + ) { let call = 0 + // The mapper reads the source tree first, then the target's; serving the same rows to both + // would make every source folder look already-present and suppress the mirroring. + let folderCall = 0 const inserts: Array>> = [] const tx = { select: () => ({ - from: () => ({ where: () => Promise.resolve(selects[call++] ?? []) }), + from: (table: unknown) => ({ + where: () => { + if (table === folderTable) { + return Promise.resolve(folderCall++ === 0 ? sourceFolders : []) + } + return Promise.resolve(selects[call++] ?? []) + }, + }), }), insert: () => ({ values: (rows: Array>) => { @@ -1437,6 +1457,45 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => { // Only the KB row itself is inserted - no empty tag-definition insert. expect(inserts).toHaveLength(1) }) + + it('mirrors the source knowledge-base folder and copies the KB into it, not the target root', async () => { + const foldered = { ...sourceBase, folderId: 'kb-folder' } + const { tx, inserts } = makeKbTx( + [[foldered], []], + [ + { + id: 'kb-folder', + name: 'Policies', + parentId: null, + workspaceId: 'src-ws', + resourceType: 'knowledge_base', + deletedAt: null, + }, + ] + ) + + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: kbSelection, + workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + + // insert #0 is the mirrored folder, #1 the KB row placed inside it. + const newFolder = inserts[0][0] + expect(newFolder).toMatchObject({ + name: 'Policies', + workspaceId: 'child-ws', + resourceType: 'knowledge_base', + }) + // A fresh id: reusing the source's would point the child KB at a folder it cannot see. + expect(newFolder.id).not.toBe('kb-folder') + expect(inserts[1][0].folderId).toBe(newFolder.id) + }) }) describe('planForkMappedKbDocumentCopies', () => { diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 991b1fd7a1d..dc3c9dbcbdd 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -71,6 +71,7 @@ import { recordKnowledgeBaseFileOwnership, } from '@/lib/uploads/server/metadata' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows' import { deleteCopiedResourceMappingsByTargets, type ForkMappingUpsert, @@ -333,6 +334,11 @@ export interface CopyResourcesResult { contentPlan: ForkContentPlan /** Names of the copied resources, by kind, for the fork report breakdown. */ names: ForkCopiedResourceNames + /** + * source folder id -> target folder id for every family mirrored here (tables, knowledge + * bases). Merged by the caller with the workflow and file maps for content-ref rewriting. + */ + folderIdMap: Map } function setId(idMap: Map>, type: ForkResourceType) { @@ -371,6 +377,12 @@ export async function copyForkResourceContainers( const resolveEnvName = params.resolveEnvName const idMap = new Map>() const mappingEntries: ForkMappingUpsert[] = [] + /** + * Mirrored folder ids across every family copied here. Table and knowledge-base folders live + * in disjoint trees, and folder ids are globally unique, so merging them into one map is + * unambiguous and lets callers rewrite `sim:folder/` refs in a single pass. + */ + const folderIdMap = new Map() const contentPlan: ForkContentPlan = { sourceWorkspaceId, childWorkspaceId, @@ -618,6 +630,17 @@ export async function copyForkResourceContainers( isNull(userTableDefinitions.archivedAt) ) ) + const tableFolderIdMap = await resolveForkFolderMapping({ + tx, + sourceWorkspaceId, + targetWorkspaceId: childWorkspaceId, + userId, + now, + resourceType: 'table', + contentFolderIds: definitions.map((definition) => definition.folderId), + }) + for (const [source, target] of tableFolderIdMap) folderIdMap.set(source, target) + const inserts: (typeof userTableDefinitions.$inferInsert)[] = [] for (const definition of definitions) { const childTableId = generateId() @@ -631,13 +654,13 @@ export async function copyForkResourceContainers( id: childTableId, workspaceId: childWorkspaceId, /** - * Folders never transit a fork edge. `folder_id` is a global id with no workspace in - * it, so the spread above would leave the child's table pointing at a folder owned by - * the SOURCE workspace — invisible in the fork, and mutated from under it if the - * source later deletes that folder (`ON DELETE SET NULL`). Forked tables land at the - * root, like forked files already do. + * `folder_id` is a global id with no workspace in it, so the spread above would leave + * the child's table pointing at a folder owned by the SOURCE workspace — invisible in + * the fork, and mutated from under it if the source later deletes that folder + * (`ON DELETE SET NULL`). Remap it onto the mirrored target subtree instead; an + * unmapped folder re-roots the table. */ - folderId: null, + folderId: definition.folderId ? (tableFolderIdMap.get(definition.folderId) ?? null) : null, schema: remappedSchema, createdBy: userId, rowsVersion: 0, @@ -674,6 +697,17 @@ export async function copyForkResourceContainers( isNull(knowledgeBase.deletedAt) ) ) + const kbFolderIdMap = await resolveForkFolderMapping({ + tx, + sourceWorkspaceId, + targetWorkspaceId: childWorkspaceId, + userId, + now, + resourceType: 'knowledge_base', + contentFolderIds: bases.map((base) => base.folderId), + }) + for (const [source, target] of kbFolderIdMap) folderIdMap.set(source, target) + const inserts: (typeof knowledgeBase.$inferInsert)[] = [] const kbEntryBySourceId = new Map() for (const base of bases) { @@ -682,8 +716,8 @@ export async function copyForkResourceContainers( ...base, id: childKbId, workspaceId: childWorkspaceId, - /** Same reasoning as the table copy above: folders do not transit a fork edge. */ - folderId: null, + /** Same reasoning as the table copy above: remapped, never carried across verbatim. */ + folderId: base.folderId ? (kbFolderIdMap.get(base.folderId) ?? null) : null, userId, deletedAt: null, createdAt: now, @@ -741,7 +775,7 @@ export async function copyForkResourceContainers( }) } - return { idMap, mappingEntries, contentPlan, names } + return { idMap, mappingEntries, contentPlan, names, folderIdMap } } /** diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index cc8da0e3600..711f6db0215 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { and, eq, inArray, isNull } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' import type { DbOrTx } from '@/lib/db/types' import { assertFolderCollectionHasRoom } from '@/lib/folders/queries' import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' @@ -44,12 +45,17 @@ interface ResolveForkFolderMappingParams { userId: string now: Date /** - * Source folder ids that will directly hold copied content (workflows); null entries + * Which folder tree to mirror. `folder` rows are one table discriminated by this column, and + * the four folder-bearing families (`workflow`, `file`, `knowledge_base`, `table`) each own a + * disjoint tree, so a mapping run is always scoped to exactly one of them - reading across + * types would alias unrelated same-named folders onto each other. + */ + resourceType: FolderResourceType + /** + * Source folder ids that will directly hold copied content of `resourceType`; null entries * (root-placed content) are ignored. A source folder is copied into the target only when * its subtree contains at least one of these, so a fork/sync never creates folders that - * would end up empty. Copied workspace FILES never influence this set: their folders are a - * separate tree (`folder` rows with `resourceType = 'file'`, which this copy only ever reads - * as `'workflow'`) and are flattened to root by the copy. + * would end up empty. */ contentFolderIds: ReadonlyArray } @@ -61,8 +67,11 @@ interface ResolveForkFolderMappingParams { * parent are reused instead of duplicated. Folders whose subtree holds no copied content are * pruned - never created - though a pruned folder still maps onto an existing target folder * when one matches, so previously-synced content refs keep resolving. Returns a map from - * source folder id to target folder id; a copied workflow whose folder is absent from the + * source folder id to target folder id; copied content whose folder is absent from the * map is placed at the target's root (see {@link copyWorkflowStateIntoTarget}). + * + * Call once per folder-bearing family being copied; the returned maps are disjoint (folder ids + * are globally unique) and safe to merge for content-reference rewriting. */ export async function resolveForkFolderMapping({ tx, @@ -70,6 +79,7 @@ export async function resolveForkFolderMapping({ targetWorkspaceId, userId, now, + resourceType, contentFolderIds, }: ResolveForkFolderMappingParams): Promise> { const map = new Map() @@ -80,7 +90,7 @@ export async function resolveForkFolderMapping({ .where( and( eq(folderTable.workspaceId, sourceWorkspaceId), - eq(folderTable.resourceType, 'workflow'), + eq(folderTable.resourceType, resourceType), isNull(folderTable.deletedAt) ) ) @@ -107,7 +117,7 @@ export async function resolveForkFolderMapping({ .where( and( eq(folderTable.workspaceId, targetWorkspaceId), - eq(folderTable.resourceType, 'workflow'), + eq(folderTable.resourceType, resourceType), isNull(folderTable.deletedAt) ) ) @@ -172,7 +182,7 @@ export async function resolveForkFolderMapping({ * transaction's `lock_timeout`, which the fork sets deliberately, so an ordinary * concurrent `createFolder` can still slip a row in between the count and the insert. */ - await assertFolderCollectionHasRoom(targetWorkspaceId, 'workflow', tx, { + await assertFolderCollectionHasRoom(targetWorkspaceId, resourceType, tx, { additionalRows: newFolders.length, }) await tx.insert(folderTable).values(newFolders) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index 56278fa1b86..00ba481cc4c 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -134,10 +134,12 @@ describe('createFork storage headroom gate', () => { keyMap: new Map(), idMap: new Map(), blobTasks: [], + folderIdMap: new Map(), }) mockCopyForkResourceContainers.mockResolvedValue({ idMap: new Map(), mappingEntries: [], + folderIdMap: new Map(), contentPlan: { sourceWorkspaceId: 'src-ws', childWorkspaceId: 'child-ws', @@ -224,6 +226,7 @@ describe('createFork storage headroom gate', () => { keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]), idMap: new Map([['file-1', 'file-1-copy']]), blobTasks: [], + folderIdMap: new Map(), }) await createFork(forkParams({ files: ['file-1'] })) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 09821fafa5d..67755d8f1d9 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -228,14 +228,15 @@ export async function createFork(params: CreateForkParams): Promise` mentions in skill/file bodies). // Scoped to the folders that will actually receive a copied workflow (plus ancestors): a // fork copies only DEPLOYED workflows, so folders holding none would be created empty in - // the child and are pruned instead. Copied files don't extend this set - they use the - // separate workspace-file-folder entity and land at the child's root. - const folderIdMap = await resolveForkFolderMapping({ + // the child and are pruned instead. The file/table/knowledge-base trees are mirrored + // separately by their own copies and merged in below. + const workflowFolderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId: source.id, targetWorkspaceId: childWorkspaceId, userId, now, + resourceType: 'workflow', contentFolderIds: deployedWorkflows .filter((wf) => workflowIdMap.has(wf.id)) .map((wf) => wf.folderId), @@ -264,6 +265,17 @@ export async function createFork(params: CreateForkParams): Promise` ref in copied content + * resolves regardless of which family's folder it names. + */ + const folderIdMap = new Map([ + ...workflowFolderIdMap, + ...fileResult.folderIdMap, + ...resourceResult.folderIdMap, + ]) + const resolveCopied = (kind: ForkRemapKind, sourceId: string): string | null => { if (kind === 'file') return fileResult.keyMap.get(sourceId) ?? null const resourceType = FORK_KIND_TO_RESOURCE_TYPE[kind] diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts index 50accd71868..9895edff498 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts @@ -1,7 +1,20 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetToolInputParamConfigs } = vi.hoisted(() => ({ + mockGetToolInputParamConfigs: vi.fn(() => [] as unknown[]), +})) + +/** + * Mocked at the module boundary so these tests stay about the collector's own logic rather + * than the tool/block registries the real resolver reaches into. + */ +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: mockGetToolInputParamConfigs, +})) + import { getBlock } from '@/blocks/registry' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { @@ -20,10 +33,19 @@ const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => const sourceState = ( blockType: string, - subBlocks: Record + subBlocks: Record, + data?: Record ): WorkflowState => ({ - blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } }, + blocks: { + 'block-1': { + id: 'block-1', + type: blockType, + name: 'Block', + subBlocks, + ...(data && { data }), + }, + }, edges: [], loops: {}, parallels: {}, @@ -36,10 +58,22 @@ const replaceItem = { mode: 'replace' as const, } -// No persisted block map in these unit tests, so the resolver derives - matching the -// `deriveForkBlockId(...)` ids the expectations assert. +/** + * No persisted block map in these unit tests, so the resolver derives - matching the + * `deriveForkBlockId(...)` ids the expectations assert. + */ const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP) +/** + * The indexer mock is module-scoped, so a `mockReturnValue` from one test would otherwise + * leak into the next and make these order-dependent. Reset to the empty (no authoritative + * visibility) default before each. + */ +beforeEach(() => { + mockGetToolInputParamConfigs.mockReset() + mockGetToolInputParamConfigs.mockReturnValue([]) +}) + describe('collectForkDependentReconfigs', () => { it("emits the active operation's credential-dependent selector (condition-gated)", () => { vi.mocked(getBlock).mockReturnValue( @@ -325,6 +359,67 @@ describe('collectForkDependentReconfigs', () => { expect(sheet?.context.spreadsheetId).toBe('ss-src') }) + it('uses the persisted canonical mode when building a dependent selector context', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { id: 'domain', title: 'Domain', type: 'short-input' }, + { + id: 'projectId', + title: 'Project', + type: 'project-selector', + canonicalParamId: 'projectId', + mode: 'basic', + selectorKey: 'jira.projects', + dependsOn: ['credential', 'domain'], + }, + { + id: 'manualProjectId', + title: 'Project ID', + type: 'short-input', + canonicalParamId: 'projectId', + mode: 'advanced', + dependsOn: ['credential', 'domain'], + }, + { + id: 'issueKey', + title: 'Issue', + type: 'file-selector', + selectorKey: 'jira.issues', + dependsOn: ['credential', 'domain', 'projectId'], + required: true, + }, + ]) + ) + const states = new Map([ + [ + 'wf-src', + sourceState( + 'jira', + { + credential: { value: 'cred-src' }, + domain: { value: 'example.atlassian.net' }, + projectId: { value: 'project-basic-stale' }, + manualProjectId: { value: 'project-advanced' }, + issueKey: { value: 'ADV-1' }, + }, + { canonicalModes: { projectId: 'advanced' } } + ), + ], + ]) + + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + subBlockKey: 'issueKey', + context: { + domain: 'example.atlassian.net', + projectId: 'project-advanced', + }, + }) + }) + it('emits a credential-dependent selector nested inside a tool-input tool', () => { vi.mocked(getBlock).mockImplementation((type) => { if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) @@ -689,6 +784,373 @@ describe('collectForkDependentReconfigs', () => { }) }) +/** + * A sync carries the source's configuration across; it never invents configuration the + * source never had. A blank selector is still OFFERED (so it can be set in place during the + * swap) but must not gate the sync. + */ +describe('collectForkDependentReconfigs — blank source values never gate', () => { + const jiraProjectBlock = () => + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { id: 'operation', title: 'Operation', type: 'dropdown' }, + { + id: 'projectId', + title: 'Select Project', + type: 'project-selector', + canonicalParamId: 'projectId', + selectorKey: 'jira.projects', + dependsOn: ['credential'], + mode: 'basic', + required: { field: 'operation', value: ['write'] }, + }, + // The advanced twin carries the SAME `required` but no `selectorKey`, so it is never + // emitted. That asymmetry is what made an identical blank config gate or not gate + // purely on a display preference. + { + id: 'manualProjectId', + title: 'Project ID', + type: 'short-input', + canonicalParamId: 'projectId', + dependsOn: ['credential'], + mode: 'advanced', + required: { field: 'operation', value: ['write'] }, + }, + ]) + + it('offers a blank required selector but does not mark it required', () => { + vi.mocked(getBlock).mockReturnValue(jiraProjectBlock()) + const states = new Map([ + [ + 'wf-src', + sourceState('jira', { + credential: { value: 'cred-src' }, + operation: { value: 'write' }, + projectId: { value: '' }, + }), + ], + ]) + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ subBlockKey: 'projectId', required: false, sourceValue: '' }) + }) + + it('still marks a populated required selector as required', () => { + vi.mocked(getBlock).mockReturnValue(jiraProjectBlock()) + const states = new Map([ + [ + 'wf-src', + sourceState('jira', { + credential: { value: 'cred-src' }, + operation: { value: 'write' }, + projectId: { value: 'PROJ-1' }, + }), + ], + ]) + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + expect(result[0]).toMatchObject({ subBlockKey: 'projectId', required: true }) + }) + + it('still gates a dependent whose source value is a non-string', () => { + // A multi-select selector (e.g. zoho-desk `departmentIds`) stores an array. The wire + // `sourceValue` coerces non-strings to '' - if the emptiness check read that coerced + // value, a populated multi-select would report blank and silently stop gating. + vi.mocked(getBlock).mockReturnValue(jiraProjectBlock()) + const states = new Map([ + [ + 'wf-src', + sourceState('jira', { + credential: { value: 'cred-src' }, + operation: { value: 'write' }, + projectId: { value: ['PROJ-1'] as unknown as string }, + }), + ], + ]) + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + expect(result[0]).toMatchObject({ subBlockKey: 'projectId', required: true }) + }) + + it('does not gate a dependent whose source value is an empty array', () => { + vi.mocked(getBlock).mockReturnValue(jiraProjectBlock()) + const states = new Map([ + [ + 'wf-src', + sourceState('jira', { + credential: { value: 'cred-src' }, + operation: { value: 'write' }, + projectId: { value: [] as unknown as string }, + }), + ], + ]) + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + expect(result[0]).toMatchObject({ subBlockKey: 'projectId', required: false }) + }) + + it('reaches the same verdict in basic and advanced canonical mode', () => { + vi.mocked(getBlock).mockReturnValue(jiraProjectBlock()) + const blankSubBlocks = { + credential: { value: 'cred-src' }, + operation: { value: 'write' }, + projectId: { value: '' }, + manualProjectId: { value: '' }, + } + const basic = sourceState('jira', blankSubBlocks) + const advanced = sourceState('jira', blankSubBlocks) as unknown as WorkflowState & { + blocks: Record }> + } + advanced.blocks['block-1'].data = { canonicalModes: { projectId: 'advanced' } } + + const basicResult = collectForkDependentReconfigs( + [replaceItem], + new Map([['wf-src', basic]]), + resolve + ) + const advancedResult = collectForkDependentReconfigs( + [replaceItem], + new Map([['wf-src', advanced as unknown as WorkflowState]]), + resolve + ) + // Advanced drops the row entirely (the dormant-member guard), basic keeps it but + // non-blocking. Pin BOTH shapes, not just `.every(...)`: over an empty array `.every` + // is vacuously true, so an advanced path that regressed to emitting a required row + // would still pass. + expect(basicResult).toHaveLength(1) + expect(basicResult[0]).toMatchObject({ subBlockKey: 'projectId', required: false }) + expect(advancedResult).toHaveLength(0) + }) +}) + +/** + * Inside a `tool-input`, only a `user-only` param is the user's to supply. A `user-or-llm` + * or `llm-only` param is filled by the model at runtime (`createLLMToolSchema` keeps an empty + * one in the schema handed to the model), so a blank value there is a deliberate + * configuration state and must never gate a sync. + */ +describe('collectForkDependentReconfigs — nested tool params follow ParameterVisibility', () => { + const agentWithJiraTool = () => + vi.mocked(getBlock).mockImplementation((type) => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'jira') + return blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'issueKey', + title: 'Select Issue', + type: 'file-selector', + canonicalParamId: 'issueKey', + selectorKey: 'jira.issues', + dependsOn: ['credential'], + required: true, + }, + { + id: 'domain', + title: 'Domain', + type: 'short-input', + selectorKey: 'jira.domains', + dependsOn: ['credential'], + required: true, + }, + ]) + return undefined as unknown as BlockConfig + }) + + const stateWithIssueKey = (issueKey: string) => + new Map([ + [ + 'wf-src', + sourceState('agent', { + tools: { + value: [ + { + type: 'jira', + title: 'Jira', + operation: 'read', + params: { credential: 'cred-src', domain: 'acme.atlassian.net', issueKey }, + }, + ], + }, + }), + ], + ]) + + const resolvedParams = (visibilityByParam: Record) => + Object.entries(visibilityByParam).map(([paramId, paramVisibility]) => ({ + paramId, + authoritative: true, + config: { id: paramId, type: 'short-input', paramVisibility }, + value: undefined, + })) + + it('does not require a blank user-or-llm param the agent fills at runtime', () => { + agentWithJiraTool() + mockGetToolInputParamConfigs.mockReturnValue( + resolvedParams({ issueKey: 'user-or-llm', domain: 'user-only' }) + ) + const result = collectForkDependentReconfigs([replaceItem], stateWithIssueKey(''), resolve) + const issue = result.find((f) => f.subBlockKey === 'tools[0].issueKey') + expect(issue).toMatchObject({ required: false, toolName: 'Jira' }) + }) + + it('does not require a POPULATED user-or-llm param either', () => { + // Visibility, not emptiness, is the rule here. A parent swap blanks this field in the + // modal (`effectiveDependentValue`), so an emptiness-only guard would still gate it. + agentWithJiraTool() + mockGetToolInputParamConfigs.mockReturnValue( + resolvedParams({ issueKey: 'user-or-llm', domain: 'user-only' }) + ) + const result = collectForkDependentReconfigs( + [replaceItem], + stateWithIssueKey('ACME-999'), + resolve + ) + const issue = result.find((f) => f.subBlockKey === 'tools[0].issueKey') + expect(issue).toMatchObject({ required: false, sourceValue: 'ACME-999' }) + }) + + it('still requires a populated user-only param', () => { + agentWithJiraTool() + mockGetToolInputParamConfigs.mockReturnValue( + resolvedParams({ issueKey: 'user-or-llm', domain: 'user-only' }) + ) + const result = collectForkDependentReconfigs( + [replaceItem], + stateWithIssueKey('ACME-999'), + resolve + ) + const domain = result.find((f) => f.subBlockKey === 'tools[0].domain') + expect(domain).toMatchObject({ required: true }) + }) + + it('falls back to the block-level required when no authoritative visibility exists', () => { + // custom-tool / MCP / unresolvable tool id -> the resolver has no authoritative entry. + // Fail closed: keep the pre-existing gate rather than silently un-gating an unknown schema. + agentWithJiraTool() + mockGetToolInputParamConfigs.mockReturnValue([]) + const result = collectForkDependentReconfigs( + [replaceItem], + stateWithIssueKey('ACME-999'), + resolve + ) + const issue = result.find((f) => f.subBlockKey === 'tools[0].issueKey') + expect(issue).toMatchObject({ required: true }) + }) + + it('ignores a non-authoritative visibility rather than trusting it to un-gate', () => { + // A generic/inferred entry carries no reliable annotation, so it must not be able to + // turn a gating field into a non-gating one. + agentWithJiraTool() + mockGetToolInputParamConfigs.mockReturnValue([ + { + paramId: 'issueKey', + authoritative: false, + config: { id: 'issueKey', type: 'short-input', paramVisibility: 'user-or-llm' }, + value: undefined, + }, + ]) + const result = collectForkDependentReconfigs( + [replaceItem], + stateWithIssueKey('ACME-999'), + resolve + ) + expect(result.find((f) => f.subBlockKey === 'tools[0].issueKey')).toMatchObject({ + required: true, + }) + }) + + it('fails closed when an authoritative entry carries no visibility', () => { + // The real resolver's `uncoveredParams` branch builds its config via + // `buildToolInputSearchConfig`, which does NOT copy `paramVisibility` - so the map holds + // the key with an `undefined` value. That must fall back to the block-level `required`, + // not be read as "not user-only". + agentWithJiraTool() + mockGetToolInputParamConfigs.mockReturnValue([ + { + paramId: 'issueKey', + authoritative: true, + config: { id: 'issueKey', type: 'short-input' }, + value: undefined, + }, + ]) + const result = collectForkDependentReconfigs( + [replaceItem], + stateWithIssueKey('ACME-999'), + resolve + ) + expect(result.find((f) => f.subBlockKey === 'tools[0].issueKey')).toMatchObject({ + required: true, + }) + }) + + it('resolves visibility through the canonical param id when the sub-block id differs', () => { + // The resolver keys by its own paramId; a canonical pair's sub-block id can differ, so the + // map is double-keyed and the lookup falls back to `canonicalParamId`. + vi.mocked(getBlock).mockImplementation((type) => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'jira') + return blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'issueKeySelector', + title: 'Select Issue', + type: 'file-selector', + canonicalParamId: 'issueKey', + selectorKey: 'jira.issues', + dependsOn: ['credential'], + required: true, + }, + ]) + return undefined as unknown as BlockConfig + }) + mockGetToolInputParamConfigs.mockReturnValue([ + { + paramId: 'issueKey', + authoritative: true, + config: { id: 'issueKey', type: 'file-selector', paramVisibility: 'user-or-llm' }, + value: undefined, + }, + ]) + const result = collectForkDependentReconfigs( + [replaceItem], + stateWithIssueKey('ACME-999'), + resolve + ) + // Found via canonicalParamId -> user-or-llm -> not the user's to fill. + expect(result.find((f) => f.subBlockKey === 'tools[0].issueKeySelector')).toMatchObject({ + required: false, + }) + }) + + it('does not gate a blank user-only nested param', () => { + // Both invariants fire at once: user-only (so visibility would gate) but blank in the + // source (so there is nothing to carry across). + agentWithJiraTool() + mockGetToolInputParamConfigs.mockReturnValue( + resolvedParams({ issueKey: 'user-or-llm', domain: 'user-only' }) + ) + const states = new Map([ + [ + 'wf-src', + sourceState('agent', { + tools: { + value: [ + { + type: 'jira', + title: 'Jira', + operation: 'read', + params: { credential: 'cred-src', domain: '', issueKey: '' }, + }, + ], + }, + }), + ], + ]) + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + expect(result.find((f) => f.subBlockKey === 'tools[0].domain')).toMatchObject({ + required: false, + }) + }) +}) + describe('collectForkResourceUsages', () => { const usageItem = ( sourceWorkflowId: string, diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts index 4a4bbbf2a5e..8982e9b0ef1 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts @@ -2,6 +2,7 @@ import { isRecordLike } from '@sim/utils/object' import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork' import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-ids' import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies' +import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { buildSelectorContextFromBlock, SELECTOR_CONTEXT_FIELDS, @@ -14,6 +15,7 @@ import { isNonEmptyValue, scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' +import { resolveToolParamRequired } from '@/lib/workflows/tool-input/param-visibility' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' @@ -21,10 +23,10 @@ import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan' import { createCanonicalModeGates, - isSubBlockRequired, scanWorkflowReferences, } from '@/ee/workspace-forking/lib/remap/remap-references' import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { ParameterVisibility } from '@/tools/types' const isSelectorContextKey = ( key: string @@ -84,6 +86,18 @@ interface EmitAnchoredParams { * credential-anchored field). */ chaining: boolean + /** + * Present ONLY for the nested `tool-input` pass: each param's resolved + * {@link ParameterVisibility}, keyed by sub-block id and by canonical param id. Its presence + * is what marks a dependent as a tool param rather than a block sub-block, so `required` + * can apply the tool-row rule (see {@link resolveToolParamRequired}). + * + * Two cases fall back to the block-level `required`, failing closed: a param absent from + * the map (custom-tool / MCP generic fallback, or an unresolvable tool id), and a param + * present with an `undefined` value — the resolver's `buildToolInputSearchConfig` branch + * does not copy `paramVisibility`, so an authoritative entry can still carry none. + */ + paramVisibilityById?: Map out: ForkDependentReconfig[] } @@ -107,9 +121,12 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { makeTitle, toolName, chaining, + paramVisibilityById, out, } = params - const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks) + const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks, { + canonicalModes, + }) const canonicalIndex = buildCanonicalIndex(config.subBlocks) const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes) const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg])) @@ -196,6 +213,21 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { ? values[dependent.canonicalParamId] : undefined) const rawSourceValue = typeof rawDependentValue === 'string' ? rawDependentValue : '' + // Two independent invariants decide whether this row GATES the sync (it is always + // offered either way - see the comment above the `condition` skip): + // + // 1. A sync carries the source's configuration across; it never invents configuration + // the source never had. A field the source left blank has nothing to carry, so it + // cannot block. A genuinely missing value is still caught by the block's own + // required-field validation at run/deploy time. + // 2. Inside a `tool-input`, only a `user-only` param is the user's to supply; a + // `user-or-llm` / `llm-only` param is filled by the model at runtime, so a blank + // one is intentional. Applies to the nested pass only, where visibility is known. + // + // Testing `rawSourceValue` directly is sound: the dormant guard above has already + // returned for any pair in advanced mode, so the pair is basic-active here and + // `rawSourceValue` IS the group's active canonical value. + const configuredRequired = resolveToolParamRequired(dependent, values, paramVisibilityById) out.push({ parentKind: anchor.parentKind, parentSourceId, @@ -211,7 +243,11 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { // The diff route overlays the stored/target-draft value onto `currentValue`; // `sourceValue` stays the raw source reference (the copy-resolved parent's seed). currentValue: rawSourceValue, - required: isSubBlockRequired(dependent.required, values), + // Ask the emptiness question of the RAW value, not the string-coerced one: + // `rawSourceValue` flattens every non-string (a multi-select selector stores an + // array) to `''`, which would report a populated field as blank and silently + // un-gate it. `isNonEmptyValue` handles arrays and non-strings on purpose. + required: configuredRequired && isNonEmptyValue(rawDependentValue), providesContextKey, consumesContextKeys, context: dependentContext, @@ -313,6 +349,28 @@ export function collectForkDependentReconfigs( typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name const toolInputKey = cfg.id const toolIndex = index + // Resolved `ParameterVisibility` per param, from the same resolver the tool-row UI + // and the rest of fork remapping use - so "is this the user's to fill?" is answered + // identically in the editor and in the sync gate. Keyed by both the sub-block id and + // its canonical param id, since a nested tool stores picks under either. + const paramVisibilityById = new Map() + for (const resolved of getToolInputParamConfigs({ + tool: { ...tool, type: tool.type, params: toolParams }, + toolIndex, + parentCanonicalModes: block.data?.canonicalModes, + })) { + if (!resolved.authoritative) continue + const visibility = resolved.config.paramVisibility + paramVisibilityById.set(resolved.paramId, visibility) + // The canonical id is an ALIAS, so it must never clobber a param that owns that + // key as its own `paramId` - first (own-id) write wins. + if ( + resolved.config.canonicalParamId && + !paramVisibilityById.has(resolved.config.canonicalParamId) + ) { + paramVisibilityById.set(resolved.config.canonicalParamId, visibility) + } + } emitAnchoredDependents({ config: toolConfig, values: toolValues, @@ -330,6 +388,7 @@ export function collectForkDependentReconfigs( makeTitle: (dependent) => dependent.title ?? dependent.id ?? '', toolName: toolLabel, chaining: false, + paramVisibilityById, out, }) } diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts index 250c5fc85d6..c177ceee97d 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts @@ -244,6 +244,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { vi.clearAllMocks() mockCopyForkResourceContainers.mockResolvedValue({ idMap: new Map(), + folderIdMap: new Map(), mappingEntries: [], contentPlan: { sourceWorkspaceId: 'src-ws', @@ -313,6 +314,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => { mockPlanForkFileCopies.mockResolvedValue({ keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]), + folderIdMap: new Map(), idMap: new Map([['file-src', 'file-dst']]), blobTasks: [ { @@ -386,6 +388,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { // mapping row is what makes the next sync resolve the copy instead of re-offering it. mockCopyForkResourceContainers.mockResolvedValue({ idMap: new Map([['table', new Map([['tbl-unref', 'tbl-copy']])]]), + folderIdMap: new Map(), mappingEntries: [ { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, ], @@ -409,6 +412,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { }) mockPlanForkFileCopies.mockResolvedValue({ keyMap: new Map(), + folderIdMap: new Map(), idMap: new Map(), blobTasks: [], }) diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts index 4cd8f703258..19269023429 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts @@ -173,7 +173,11 @@ export async function copyPromoteUnmappedResources(params: { now: Date selection: PromoteCopySelection workflowIdMap: Map - /** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/`. */ + /** + * source workflow-folder id -> target folder id, so copied skill/markdown bodies rewrite + * `sim:folder/`. The file / table / knowledge-base trees are mirrored by the copies run + * here and unioned onto this map before the content rewrite. + */ folderIdMap: Map /** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */ resolver: ForkReferenceResolver @@ -251,6 +255,7 @@ export async function copyPromoteUnmappedResources(params: { keyMap: new Map(), idMap: new Map(), blobTasks: [] as BlobCopyTask[], + folderIdMap: new Map(), } // U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc @@ -304,7 +309,9 @@ export async function copyPromoteUnmappedResources(params: { const contentRefMaps = serializeContentRefMaps({ workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId }, workflows: workflowIdMap, - folders: folderIdMap, + // Workflow folders (mapped by the caller) unioned with the file / table / knowledge-base + // folders this copy mirrored, so a `sim:folder/` ref resolves whichever tree it names. + folders: new Map([...folderIdMap, ...fileResult.folderIdMap, ...result.folderIdMap]), fileKeys: fileResult.keyMap, fileIds: fileResult.idMap, skills: result.idMap.get('skill'), diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.ts index 22a25a9e42c..ea92f08c4e8 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.ts @@ -557,6 +557,7 @@ export async function promoteFork(params: PromoteForkParams): Promise item.sourceMeta.folderId), }) diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts index 26bd1bc0d55..4f050b71c5d 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts @@ -6,16 +6,25 @@ import type { BlockConfig, SubBlockConfig } from '@/blocks/types' // The indexer resolves a tool's params via the tool registry; stub it so the // injected blockConfigs subBlocks drive resolution deterministically in tests. +// Exposed as vi.fn()s (with the historical defaults) so a test that needs an +// AUTHORITATIVE resolution - i.e. one carrying `paramVisibility` - can opt in. +const { mockGetToolIdForOperation, mockGetSubBlocksForToolInput } = vi.hoisted(() => ({ + mockGetToolIdForOperation: vi.fn((): string | undefined => undefined), + mockGetSubBlocksForToolInput: vi.fn( + ( + _toolId: string, + _type: string, + _values: unknown, + _modes: unknown, + provided?: { subBlocks?: SubBlockConfig[] } + ) => ({ subBlocks: provided?.subBlocks ?? [] }) + ), +})) + vi.mock('@/tools/params', () => ({ - getToolIdForOperation: () => undefined, + getToolIdForOperation: mockGetToolIdForOperation, getToolParametersConfig: () => null, - getSubBlocksForToolInput: ( - _toolId: string, - _type: string, - _values: unknown, - _modes: unknown, - provided?: { subBlocks?: SubBlockConfig[] } - ) => ({ subBlocks: provided?.subBlocks ?? [] }), + getSubBlocksForToolInput: mockGetSubBlocksForToolInput, formatParameterLabel: (label: string) => label, })) @@ -1005,6 +1014,52 @@ describe('collectClearedDependents', () => { }, ]) }) + + it('does not mark a cleared model-supplied tool param as required', () => { + // The pre-sync modal treats a `user-or-llm` param as non-blocking (the agent fills it at + // runtime). This collector must agree: a `required` entry here makes promote SKIP the + // target's redeploy, so disagreeing would let a sync through and then silently withhold + // the deployment. + mockGetToolIdForOperation.mockReturnValueOnce('gmail_read') + vi.mocked(getBlock).mockImplementation((type) => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'gmail') + return blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'folder', + title: 'Label', + type: 'folder-selector', + dependsOn: ['credential'], + required: true, + paramVisibility: 'user-or-llm', + }, + ]) + return undefined as unknown as BlockConfig + }) + const targetDraft: SubBlockRecord = { + tools: entry('tools', 'tool-input', [ + { type: 'gmail', title: 'Gmail', params: { credential: 'c-target', folder: 'INBOX' } }, + ]), + } + const merged: SubBlockRecord = { + tools: entry('tools', 'tool-input', [ + { type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } }, + ]), + } + const result = collectClearedDependents('agent', 'b1', 'Agent', targetDraft, merged) + // Still surfaced (the value really was cleared), just not gating the redeploy. + expect(result).toEqual([ + { + blockId: 'b1', + blockName: 'Agent', + subBlockKey: 'tools[0].folder', + title: 'Label', + toolName: 'Gmail', + required: false, + }, + ]) + }) }) describe('applyDependentOverrides', () => { diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index f422496a4be..1a71e90e60b 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -29,6 +29,10 @@ import { resolveCanonicalMode, scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' +import { + isSubBlockRequired, + resolveToolParamRequired, +} from '@/lib/workflows/tool-input/param-visibility' import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' @@ -38,6 +42,7 @@ import { remapForkFileUploadValue, } from '@/ee/workspace-forking/lib/remap/remap-files' import { isEnvVarReference, isReference } from '@/executor/constants' +import type { ParameterVisibility } from '@/tools/types' /** * Resource kinds the fork remapper rewrites across workspaces, derived from the @@ -1191,20 +1196,6 @@ export interface NeedsConfigurationField { required: boolean } -/** Evaluate a subblock's `required` (boolean | condition | fn) against a value map. */ -export function isSubBlockRequired( - required: SubBlockConfig['required'], - values: Record -): boolean { - if (required === true) return true - if (!required) return false - // The object/function forms are structurally a SubBlockCondition. - return evaluateSubBlockCondition( - required as Parameters[0], - values - ) -} - /** Nested `tool-input` dependents (Agent/tool blocks) the TARGET configured that a remap cleared. */ function collectClearedToolParamDependents( toolInputKey: string, @@ -1245,6 +1236,27 @@ function collectClearedToolParamDependents( scopeCanonicalModesForTool(parentCanonicalModes, index, tool.type) ) const toolLabel = typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name + // Resolved visibility per param, so `required` here means the same thing it means in the + // pre-sync modal. Without this the two paths disagree: the modal would let a sync through + // (a model-supplied param is not the user's to fill) and then this collector would mark it + // required, which SKIPS the target's redeploy in `promote.ts` - leaving the fork silently + // running its previous deployed version. + const paramVisibilityById = new Map() + for (const resolved of getToolInputParamConfigs({ + tool: { ...tool, type: tool.type, params: mergedParams }, + toolIndex: index, + parentCanonicalModes, + })) { + if (!resolved.authoritative) continue + const visibility = resolved.config.paramVisibility + paramVisibilityById.set(resolved.paramId, visibility) + if ( + resolved.config.canonicalParamId && + !paramVisibilityById.has(resolved.config.canonicalParamId) + ) { + paramVisibilityById.set(resolved.config.canonicalParamId, visibility) + } + } for (const cfg of toolConfig.subBlocks) { if (!cfg.dependsOn || !cfg.id) continue // Only flag a param the TARGET tool had configured (not one the source carried in). @@ -1260,7 +1272,7 @@ function collectClearedToolParamDependents( subBlockKey: `${toolInputKey}[${index}].${cfg.id}`, title: cfg.title ?? cfg.id, toolName: toolLabel, - required: isSubBlockRequired(cfg.required, mergedValues), + required: resolveToolParamRequired(cfg, mergedValues, paramVisibilityById), }) } } diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index d45dbed396d..6aa7af64f29 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -6,10 +6,10 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { createCredentialGroupContract, deleteCredentialGroupContract, + deleteCredentialGroupEnrollmentContract, getCredentialGroupContract, inviteCredentialGroupEnrollmentsContract, resendCredentialGroupEnrollmentContract, - revokeCredentialGroupEnrollmentContract, startSlackCredentialGroupConfigurationContract, updateCredentialGroupContract, } from '@/lib/api/contracts/credential-groups' @@ -29,7 +29,7 @@ export function useCredentialGroups(workspaceId?: string) { return fetchCredentialGroupList(workspaceId, signal) }, enabled: Boolean(workspaceId), - staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -49,7 +49,10 @@ export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) getNextPageParam: (lastPage: ContractJsonResponse) => lastPage.nextCursor ?? undefined, enabled: Boolean(workspaceId && groupId), - staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + // An infinite staleTime never goes stale, so the app-wide `retryOnMount: false` + // would cache one transient failure for the life of the QueryClient. + retryOnMount: true, }) } @@ -78,6 +81,9 @@ export function useDeleteCredentialGroup() { }), onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + queryClient.removeQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) }, }) } @@ -98,12 +104,18 @@ export function useUpdateCredentialGroup() { params: { id: workspaceId, groupId }, body, }), - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }) - }, + // Returned so `mutateAsync` resolves only once the refetch has landed. Callers + // clear their edit buffer on success, which would otherwise fall back onto the + // pre-save cache and flash the old values. + onSettled: (_data, _error, variables) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(variables.workspaceId), + }), + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }), + ]), }) } @@ -172,7 +184,7 @@ export function useResendCredentialGroupEnrollment() { }) } -export function useRevokeCredentialGroupEnrollment() { +export function useDeleteCredentialGroupEnrollment() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ @@ -184,7 +196,7 @@ export function useRevokeCredentialGroupEnrollment() { groupId: string enrollmentId: string }) => - requestJson(revokeCredentialGroupEnrollmentContract, { + requestJson(deleteCredentialGroupEnrollmentContract, { params: { id: workspaceId, groupId, enrollmentId }, }), onSettled: (_data, _error, variables) => { diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index ca094a48c24..13a6e782422 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -20,9 +20,11 @@ import { type WorkspaceCredentialType, } from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' +import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { fetchWorkspaceCredentialList, + requireWorkspaceCredentialListResponse, WORKSPACE_CREDENTIAL_LIST_STALE_TIME, } from '@/hooks/queries/utils/fetch-workspace-credentials' @@ -74,7 +76,7 @@ export function useWorkspaceCredentials(params: { }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) }, enabled: Boolean(workspaceId) && enabled, staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, @@ -104,7 +106,7 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) { export function useCreateCredentialDraft() { return useMutation({ mutationFn: async (payload: ContractBodyInput) => { - await requestJson(createCredentialDraftContract, { body: payload }) + return requestJson(createCredentialDraftContract, { body: payload }) }, }) } @@ -210,6 +212,7 @@ export function useDeleteWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }) queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY }) queryClient.invalidateQueries({ queryKey: environmentKeys.all }) + queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) }, }) } diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx new file mode 100644 index 00000000000..6fe376384ba --- /dev/null +++ b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx @@ -0,0 +1,110 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubBlockConfig } from '@/blocks/types' +import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' + +interface HookHarness { + result: () => T + unmount: () => void +} + +function renderHookWithClient(useHook: () => T): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root: Root = createRoot(container) + let latest!: T + + function Probe() { + latest = useHook() + return null + } + + act(() => { + root.render( + + + + ) + }) + + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } +} + +async function waitForResult(assertion: () => void) { + await act(async () => { + await vi.waitFor(assertion, { interval: 1 }) + }) +} + +describe('useDynamicSubBlockOptionDisplayName', () => { + const mounted: Array<() => void> = [] + + afterEach(() => { + mounted.splice(0).forEach((unmount) => unmount()) + vi.clearAllMocks() + }) + + it('hydrates a stored dynamic dropdown id to its label', async () => { + const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ + id: optionId, + label: 'Customer support accounts', + })) + const subBlock = { + id: 'credentialGroup', + title: 'Credential Group', + type: 'dropdown', + options: [], + fetchOptionById, + } satisfies SubBlockConfig + + const hook = renderHookWithClient(() => + useDynamicSubBlockOptionDisplayName({ + workspaceId: 'workspace-1', + blockId: 'block-1', + subBlock, + value: 'group-uuid', + }) + ) + mounted.push(hook.unmount) + + await waitForResult(() => expect(hook.result()).toBe('Customer support accounts')) + + expect(fetchOptionById).toHaveBeenCalledWith('block-1', 'group-uuid', expect.any(AbortSignal)) + }) + + it('summarizes every selected dynamic option without dropping ids', async () => { + const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ + id: optionId, + label: optionId === 'gmail' ? 'Gmail' : 'Slack', + })) + const subBlock = { + id: 'providerFilter', + title: 'Provider', + type: 'dropdown', + options: [], + multiSelect: true, + fetchOptionById, + } satisfies SubBlockConfig + + const hook = renderHookWithClient(() => + useDynamicSubBlockOptionDisplayName({ + workspaceId: 'workspace-1', + blockId: 'block-1', + subBlock, + value: ['gmail', 'slack'], + }) + ) + mounted.push(hook.unmount) + + await waitForResult(() => expect(hook.result()).toBe('Gmail, Slack')) + }) +}) diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.ts b/apps/sim/hooks/queries/dynamic-subblock-options.ts new file mode 100644 index 00000000000..16f50005a8c --- /dev/null +++ b/apps/sim/hooks/queries/dynamic-subblock-options.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { summarizeNames } from '@/lib/workflows/subblocks/display' +import type { SubBlockConfig } from '@/blocks/types' + +export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000 + +export const dynamicSubBlockOptionKeys = { + all: ['dynamic-subblock-options'] as const, + details: () => [...dynamicSubBlockOptionKeys.all, 'detail'] as const, + detail: (workspaceId?: string, blockId?: string, subBlockId?: string, optionId?: string) => + [ + ...dynamicSubBlockOptionKeys.details(), + workspaceId ?? '', + blockId ?? '', + subBlockId ?? '', + optionId ?? '', + ] as const, +} + +interface UseDynamicSubBlockOptionDisplayNameArgs { + workspaceId?: string + blockId?: string + subBlock?: SubBlockConfig + value: unknown +} + +function getResolvableOptionIds(value: unknown): string[] { + const values = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [] + return values.filter( + (entry): entry is string => + typeof entry === 'string' && + entry.length > 0 && + !entry.startsWith('<') && + !entry.includes('{{') + ) +} + +/** Resolves labels for dropdown options whose choices are loaded dynamically. */ +export function useDynamicSubBlockOptionDisplayName({ + workspaceId, + blockId, + subBlock, + value, +}: UseDynamicSubBlockOptionDisplayNameArgs): string | null { + const optionIds = useMemo(() => getResolvableOptionIds(value), [value]) + const fetchOptionById = subBlock?.fetchOptionById + const canResolve = Boolean(blockId && fetchOptionById && optionIds.length > 0) + + const queries = useQueries({ + queries: canResolve + ? optionIds.map((optionId) => ({ + queryKey: dynamicSubBlockOptionKeys.detail(workspaceId, blockId, subBlock?.id, optionId), + queryFn: ({ signal }) => { + if (!blockId || !fetchOptionById) { + throw new Error('Dynamic subblock option resolver is required') + } + return fetchOptionById(blockId, optionId, signal) + }, + staleTime: DYNAMIC_SUBBLOCK_OPTION_STALE_TIME, + })) + : [], + }) + + return useMemo(() => { + if (!canResolve || queries.length !== optionIds.length) return null + const labels = queries.map((query) => query.data?.label) + if (!labels.every((label): label is string => Boolean(label))) return null + return summarizeNames(labels) + }, [canResolve, optionIds.length, queries]) +} diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index f46457282fc..5902e0d7be1 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -5,9 +5,13 @@ import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { type BulkChunkOperationData, + type BulkDeleteKnowledgeItemsBody, type BulkDocumentOperationData, + type BulkMoveKnowledgeItemsBody, + bulkDeleteKnowledgeItemsContract, bulkKnowledgeChunksContract, bulkKnowledgeDocumentsContract, + bulkMoveKnowledgeItemsContract, type ChunkData, type ChunksPagination, createKnowledgeBaseContract, @@ -47,6 +51,7 @@ import { } from '@/lib/api/contracts/knowledge' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { KNOWLEDGE_BASE_LIST_STALE_TIME, type KnowledgeQueryScope, @@ -1045,3 +1050,83 @@ export function useDeleteDocumentTagDefinitions() { }, }) } + +/** + * Move a mixed selection of knowledge bases and knowledge folders into one + * folder, or to the workspace root with `targetFolderId: null`. + * + * One request, one authorized operation: the Knowledge list interleaves folder + * and knowledge base rows in a single grid, so a selection is routinely mixed + * and must not be split into a resource call plus a per-folder fan-out. + * + * No optimistic patch, unlike the single-base move: a folder move re-parents + * rows the list renders at a different level, and the response reports per-item + * outcomes (`skipped`, `notFound`, `failed`) the client cannot predict. + */ +export function useBulkMoveKnowledgeBases(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + knowledgeBaseIds = [], + folderIds = [], + targetFolderId, + }: Omit) => { + const result = await requestJson(bulkMoveKnowledgeItemsContract, { + body: { workspaceId, knowledgeBaseIds, folderIds, targetFolderId }, + }) + return result.data + }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { knowledgeBaseIds = [] }) => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('knowledge_base') }) + /** + * `exact` because `detail` is the prefix for the base's documents, chunks, and tag + * queries — a move only re-parents the base record itself, so a prefix invalidation would + * refetch every cached document and chunk page for nothing. The sibling delete hook + * deliberately stays non-exact, since there the children really must go. + */ + for (const knowledgeBaseId of knowledgeBaseIds) { + queryClient.invalidateQueries({ + queryKey: knowledgeKeys.detail(knowledgeBaseId), + exact: true, + }) + } + }, + }) +} + +/** + * Delete a mixed selection of knowledge bases and knowledge folders. + * + * Deleting a folder cascades to every knowledge base and subfolder inside it, + * so the response's `deletedItems` totals exceed the explicitly selected count. + */ +export function useBulkDeleteKnowledgeBases(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + knowledgeBaseIds = [], + folderIds = [], + }: Omit) => { + const result = await requestJson(bulkDeleteKnowledgeItemsContract, { + body: { workspaceId, knowledgeBaseIds, folderIds }, + }) + return result.data + }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { knowledgeBaseIds = [] }) => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('knowledge_base') }) + for (const knowledgeBaseId of knowledgeBaseIds) { + queryClient.removeQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }) + } + }, + }) +} diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index e3a5becbc0b..119f71f83fd 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -3,12 +3,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ConnectedAccount, - disconnectOAuthContract, listOAuthConnectionsContract, type OAuthAccountSummary, type OAuthConnection, } from '@/lib/api/contracts/oauth-connections' import { client } from '@/lib/auth/auth-client' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { getDesktopBridge } from '@/lib/desktop' import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' @@ -139,6 +139,7 @@ export function useOAuthConnections() { interface ConnectServiceParams { providerId: string callbackURL: string + draftId?: string } /** @@ -149,22 +150,25 @@ export function useConnectOAuthService() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => { + mutationFn: async ({ providerId, callbackURL, draftId }: ConnectServiceParams) => { if (providerId === 'trello') { const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/trello/authorize?returnUrl=${returnUrl}` + const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : '' + window.location.href = `/api/auth/trello/authorize?returnUrl=${returnUrl}${draftQuery}` return { success: true } } if (providerId === 'instagram') { const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/instagram/authorize?returnUrl=${returnUrl}` + const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : '' + window.location.href = `/api/auth/instagram/authorize?returnUrl=${returnUrl}${draftQuery}` return { success: true } } if (providerId === 'shopify') { const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}` + const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : '' + window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}${draftQuery}` return { success: true } } @@ -176,16 +180,24 @@ export function useConnectOAuthService() { // which refreshes caches and shows the connected toast. const desktopBridge = getDesktopBridge() if (desktopBridge?.beginOAuthConnect) { - const opened = await desktopBridge.beginOAuthConnect(providerId) + const opened = await desktopBridge.beginOAuthConnect( + providerId, + draftId ? { draftId } : undefined + ) if (!opened) { throw new Error('Could not open your browser to connect this account.') } return { success: true } } + const stateCallbackUrl = new URL(callbackURL) + if (draftId) { + stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId) + } + await client.oauth2.link({ providerId, - callbackURL, + callbackURL: stateCallbackUrl.toString(), }) return { success: true } @@ -199,68 +211,5 @@ export function useConnectOAuthService() { }) } -interface DisconnectServiceParams { - provider: string - providerId?: string - serviceId: string - accountId?: string -} - -/** - * Disconnects an OAuth service account. - * Performs optimistic update and rolls back on failure. - */ -export function useDisconnectOAuthService() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => { - return requestJson(disconnectOAuthContract, { - body: { - provider, - providerId, - accountId, - }, - }) - }, - onMutate: async ({ serviceId, accountId }) => { - await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() }) - - const previousServices = queryClient.getQueryData( - oauthConnectionsKeys.connections() - ) - - if (previousServices) { - queryClient.setQueryData( - oauthConnectionsKeys.connections(), - previousServices.map((svc) => { - if (svc.id === serviceId) { - const updatedAccounts = - accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : [] - return { - ...svc, - accounts: updatedAccounts, - isConnected: updatedAccounts.length > 0, - } - } - return svc - }) - ) - } - - return { previousServices } - }, - onError: (_err, _variables, context) => { - if (context?.previousServices) { - queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices) - } - logger.error('Failed to disconnect service') - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) - }, - }) -} - /** Connected OAuth account for a specific provider. */ export type { ConnectedAccount } diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7423d6b09de..9fcb64d6119 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -38,8 +38,12 @@ import { addWorkflowGroupContract, type BatchInsertTableRowsBodyInput, type BatchUpdateTableRowsBodyInput, + type BulkDeleteTablesBody, + type BulkMoveTablesBody, batchCreateTableRowsContract, batchUpdateTableRowsContract, + bulkDeleteTablesContract, + bulkMoveTablesContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, cancelTableRunsContract, @@ -114,6 +118,7 @@ import { sanitizeName } from '@/lib/table/import' import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { useTimezone } from '@/hooks/queries/general-settings' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, TABLE_VIEWS_STALE_TIME, @@ -129,6 +134,14 @@ const logger = createLogger('TableQueries') export const TABLE_DETAIL_STALE_TIME = 30 * 1000 export const TABLE_RUN_STATE_STALE_TIME = 30 * 1000 export const TABLE_FIND_STALE_TIME = 30 * 1000 +/** + * Shorter than the 5-minute default: the grid searches as the user types, so + * each typing pause mints its own cache entry holding up to + * `TABLE_LIMITS.MAX_FIND_MATCHES` matches. Long enough that backspacing to a + * recent term is still instant, short enough that a typed-through term set + * doesn't sit resident. + */ +export const TABLE_FIND_GC_TIME = 60 * 1000 export const TABLE_ROWS_STALE_TIME = 30 * 1000 export const TABLE_EXPORT_JOBS_STALE_TIME = 5 * 1000 @@ -469,9 +482,11 @@ async function fetchTableRowMatches({ } /** - * Server-side find across all cells. `q` is the *submitted* term (search is - * Enter-triggered), so React Query caches each submitted term and re-searching - * a prior one is instant. Disabled while `q` is empty. + * Server-side find across all cells. `q` is the term the caller has settled on + * — the grid debounces the live input before passing it — so React Query caches + * each settled term and backspacing to a prior one is instant. Disabled while + * `q` is empty; `keepPreviousData` holds the last result set so the match count + * doesn't blank between terms. */ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: FindTableRowsParams) { const paramsKey = JSON.stringify({ q, filter: filter ?? null, sort: sort ?? null }) @@ -481,6 +496,7 @@ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: Find fetchTableRowMatches({ workspaceId, tableId, q, filter, sort, signal }), enabled: Boolean(workspaceId && tableId) && q.trim().length > 0, staleTime: TABLE_FIND_STALE_TIME, + gcTime: TABLE_FIND_GC_TIME, placeholderData: keepPreviousData, }) } @@ -2510,3 +2526,81 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont }, }) } + +/** + * Move a mixed selection of tables and table folders into one folder, or to the + * workspace root with `targetFolderId: null`. + * + * One request, one authorized operation: the Tables list interleaves folder and + * table rows in a single grid, so a selection is routinely mixed and must not be + * split into a resource call plus a per-folder fan-out. + * + * No optimistic patch. A folder move re-parents rows that the list renders at a + * different level, and the response reports per-item outcomes (`skipped`, + * `notFound`, `failed`) the client cannot predict, so the caller reads the + * result rather than guessing it. + */ +export function useBulkMoveTables(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + tableIds = [], + folderIds = [], + targetFolderId, + }: Omit) => { + const result = await requestJson(bulkMoveTablesContract, { + body: { workspaceId, tableIds, folderIds, targetFolderId }, + }) + return result.data + }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableIds = [] }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) + for (const tableId of tableIds) { + queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + } + }, + }) +} + +/** + * Archive a mixed selection of tables and table folders. + * + * Deleting a folder cascades to every table and subfolder inside it, so the + * response's `deletedItems` totals exceed the explicitly selected count. Cached + * detail and row entries are removed only for the tables named in the request — + * a cascaded table's detail cache is left to the list invalidation, since the + * request never named it. + */ +export function useBulkDeleteTables(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + tableIds = [], + folderIds = [], + }: Omit) => { + const result = await requestJson(bulkDeleteTablesContract, { + body: { workspaceId, tableIds, folderIds }, + }) + return result.data + }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableIds = [] }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) + for (const tableId of tableIds) { + queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) }) + queryClient.removeQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + } + }, + }) +} diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index bf1dccfe9d3..aae9ce81307 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,8 +1,21 @@ import { requestJson } from '@/lib/api/client/request' -import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +import { + type ContractJsonResponse, + listWorkspaceCredentialsContract, + type WorkspaceCredential, +} from '@/lib/api/contracts' export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 +export function requireWorkspaceCredentialListResponse( + data: ContractJsonResponse +): WorkspaceCredential[] { + if (!('credentials' in data)) { + throw new Error('Workspace credential list returned a lookup response') + } + return data.credentials +} + /** * Fetches the workspace credential list. * @@ -18,5 +31,5 @@ export async function fetchWorkspaceCredentialList( query: { workspaceId }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) } diff --git a/apps/sim/hooks/use-oauth-return.test.ts b/apps/sim/hooks/use-oauth-return.test.ts new file mode 100644 index 00000000000..08debcc1a28 --- /dev/null +++ b/apps/sim/hooks/use-oauth-return.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + requestJson: vi.fn(), + requireWorkspaceCredentialListResponse: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + useRouter: vi.fn(), +})) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) +vi.mock('@/hooks/queries/utils/fetch-workspace-credentials', () => ({ + requireWorkspaceCredentialListResponse: mocks.requireWorkspaceCredentialListResponse, +})) + +import type { OAuthReturnContext } from '@/lib/credentials/client-state' +import { resolveOAuthMessage } from '@/hooks/use-oauth-return' + +const context: OAuthReturnContext = { + origin: 'integrations', + displayName: 'New Gmail', + providerId: 'google-email', + preCount: 1, + baselineCredentials: [ + { + id: 'credential-existing', + accountId: 'account-1', + updatedAt: '2026-08-14T17:00:00.000Z', + }, + ], + workspaceId: 'workspace-1', + requestedAt: Date.now(), +} + +const existingCredential = { + id: 'credential-existing', + workspaceId: 'workspace-1', + type: 'oauth' as const, + displayName: 'Existing Gmail', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-14T18:00:00.000Z', +} + +describe('resolveOAuthMessage', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.requestJson.mockResolvedValue({}) + }) + + it('recognizes an idempotent already-connected account from its reconnect timestamp', async () => { + mocks.requireWorkspaceCredentialListResponse.mockReturnValue([existingCredential]) + + await expect(resolveOAuthMessage(context)).resolves.toEqual({ + kind: 'success', + text: 'This account is already connected as "Existing Gmail".', + }) + }) + + it('keeps explicit update-access flows on the reconnect success path without a baseline', async () => { + await expect( + resolveOAuthMessage({ + ...context, + baselineCredentials: undefined, + reconnect: true, + }) + ).resolves.toEqual({ + kind: 'success', + text: '"New Gmail" reconnected successfully.', + }) + expect(mocks.requestJson).not.toHaveBeenCalled() + }) + + it('does not report success when the credential list is unchanged', async () => { + mocks.requireWorkspaceCredentialListResponse.mockReturnValue([ + { + ...existingCredential, + updatedAt: context.baselineCredentials?.[0].updatedAt, + }, + ]) + + await expect(resolveOAuthMessage(context)).resolves.toEqual({ + kind: 'error', + text: 'We couldn’t verify the "New Gmail" connection. Try again.', + }) + }) +}) diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 5b2092c44cb..cd595b345b9 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -25,32 +25,72 @@ import { import { getDesktopBridge } from '@/lib/desktop' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { requireWorkspaceCredentialListResponse } from '@/hooks/queries/utils/fetch-workspace-credentials' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const SETTINGS_RETURN_URL_KEY = 'settings-return-url' const CONTEXT_MAX_AGE_MS = 15 * 60 * 1000 -async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { +export interface OAuthResultMessage { + kind: 'success' | 'error' + text: string +} + +export async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { if (ctx.reconnect) { - return `"${ctx.displayName}" reconnected successfully.` + return { kind: 'success', text: `"${ctx.displayName}" reconnected successfully.` } } try { const data = await requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: ctx.workspaceId, type: 'oauth' }, }) - const oauthCredentials = data.credentials ?? [] + const oauthCredentials = requireWorkspaceCredentialListResponse(data) const forProvider = oauthCredentials.filter((c) => c.providerId === ctx.providerId) if (forProvider.length > ctx.preCount) { - return `"${ctx.displayName}" credential connected successfully.` + return { + kind: 'success', + text: `"${ctx.displayName}" credential connected successfully.`, + } } - const existing = forProvider[0] - return `This account is already connected as "${existing?.displayName || ctx.displayName}".` + const baselineCredentials = new Map( + ctx.baselineCredentials?.map((credential) => [credential.id, credential]) ?? [] + ) + const reauthorizedCredential = forProvider.find((credential) => { + const baseline = baselineCredentials.get(credential.id) + return ( + baseline !== undefined && + (baseline.accountId !== credential.accountId || + (baseline.updatedAt !== undefined && baseline.updatedAt !== credential.updatedAt)) + ) + }) + if (reauthorizedCredential) { + return { + kind: 'success', + text: `This account is already connected as "${reauthorizedCredential.displayName}".`, + } + } } catch { - return `"${ctx.displayName}" credential connected successfully.` + return { + kind: 'error', + text: `We couldn’t verify the "${ctx.displayName}" connection. Try again.`, + } + } + + return { + kind: 'error', + text: `We couldn’t verify the "${ctx.displayName}" connection. Try again.`, + } +} + +function showOAuthResultMessage(result: OAuthResultMessage): void { + if (result.kind === 'success') { + toast.success(result.text) + return } + toast.error(result.text) } function dispatchCredentialUpdate(ctx: { providerId: string; workspaceId: string }) { @@ -97,7 +137,7 @@ async function verifyOAuthChatAttempt(queryClient: QueryClient, attemptId: strin requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: attempt.workspaceId, type: 'oauth' }, signal, - }).then((data) => data.credentials ?? []), + }).then(requireWorkspaceCredentialListResponse), staleTime: 0, }) @@ -170,7 +210,7 @@ export function useOAuthReturnRouter() { consumeOAuthReturnContext() void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() return @@ -212,7 +252,7 @@ export function useOAuthReturnForWorkflow(workflowId: string) { void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() }, [workflowId]) @@ -232,7 +272,7 @@ export function useOAuthReturnForKBConnectors(knowledgeBaseId: string) { void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() }, [knowledgeBaseId]) @@ -276,7 +316,7 @@ export function useDesktopOAuthConnectListener() { if (ctx) { void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() return diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index 9b03b378b3b..65586d58cca 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -100,13 +100,24 @@ export function useWebhookManagement({ useCallback((state) => state.getValue(blockId, 'triggerPath') as string | null, [blockId]) ) + /** + * Derived only when the caller actually renders the URL. `getBaseUrl()` throws + * when `NEXT_PUBLIC_APP_URL` is unset, and `sub-block.tsx` mounts this hook for + * every sub-block in the editor panel — deriving the URL unconditionally turns + * a missing deployment value into a render throw for fields that never display + * one, taking down the whole editor instead of the single webhook field. + * Consumers already gate their reads on `useWebhookUrl`. + */ const webhookUrl = useMemo(() => { + if (!useWebhookUrl) { + return '' + } const baseUrl = getBaseUrl() if (!webhookPath) { return `${baseUrl}/api/webhooks/trigger/${blockId}` } return `${baseUrl}/api/webhooks/trigger/${webhookPath}` - }, [webhookPath, blockId]) + }, [useWebhookUrl, webhookPath, blockId]) useEffect(() => { if (triggerId && !isPreview) { diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index f285bf0fe3f..fe7d2e40948 100644 --- a/apps/sim/lib/api-key/orchestration/index.ts +++ b/apps/sim/lib/api-key/orchestration/index.ts @@ -1,13 +1,25 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { createWorkspaceApiKey } from '@/lib/api-key/auth' +import { generateShortId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { createApiKey, createWorkspaceApiKey } from '@/lib/api-key/auth' +import { hashApiKey } from '@/lib/api-key/crypto' import { PlatformEvents } from '@/lib/core/telemetry' const logger = createLogger('ApiKeyOrchestration') export type ApiKeyOrchestrationErrorCode = 'conflict' | 'internal' +export interface CreatedApiKey { + id: string + name: string + key: string + createdAt: Date +} + export interface PerformCreateWorkspaceApiKeyParams { workspaceId: string userId: string @@ -23,11 +35,106 @@ export interface PerformCreateWorkspaceApiKeyResult { success: boolean error?: string errorCode?: ApiKeyOrchestrationErrorCode - key?: { - id: string - name: string - key: string - createdAt: Date + key?: CreatedApiKey +} + +export interface PerformCreatePersonalApiKeyParams { + userId: string + name: string + source?: string + actorName?: string | null + actorEmail?: string | null + /** Forwarded to the audit record so the entry carries the caller's IP/UA. */ + request?: Request +} + +export interface PerformCreatePersonalApiKeyResult { + success: boolean + error?: string + errorCode?: ApiKeyOrchestrationErrorCode + key?: CreatedApiKey +} + +/** + * Issues a personal API key for the given user. + * + * The single issuer for every caller — the settings route, which authenticates + * by session, and the CLI key exchange, which authenticates by a redeemed + * approval. Keeping name-collision handling, audit, and telemetry here means the + * two surfaces can never drift. + */ +export async function performCreatePersonalApiKey( + params: PerformCreatePersonalApiKeyParams +): Promise { + try { + const existing = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.userId, params.userId), + eq(apiKey.name, params.name), + eq(apiKey.type, 'personal') + ) + ) + .limit(1) + + if (existing.length > 0) { + return { + success: false, + errorCode: 'conflict', + error: `A personal API key named "${params.name}" already exists. Please choose a different name.`, + } + } + + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } + + const [created] = await db + .insert(apiKey) + .values({ + id: generateShortId(), + userId: params.userId, + workspaceId: null, + name: params.name, + key: encryptedKey, + keyHash: hashApiKey(plainKey), + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ + id: apiKey.id, + name: apiKey.name, + createdAt: apiKey.createdAt, + }) + + recordAudit({ + workspaceId: null, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.PERSONAL_API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: created.id, + resourceName: params.name, + description: `Created personal API key: ${params.name}`, + metadata: { + keyName: params.name, + keyType: 'personal', + source: params.source ?? 'settings', + }, + request: params.request, + }) + + logger.info('Created personal API key', { userId: params.userId, keyId: created.id }) + + return { success: true, key: { ...created, key: plainKey } } + } catch (error) { + logger.error('Failed to create personal API key', { error }) + return { success: false, errorCode: 'internal', error: toError(error).message } } } diff --git a/apps/sim/lib/api/contracts/cli-auth.ts b/apps/sim/lib/api/contracts/cli-auth.ts index 56b40ce5953..d37d7a597e0 100644 --- a/apps/sim/lib/api/contracts/cli-auth.ts +++ b/apps/sim/lib/api/contracts/cli-auth.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' /** @@ -14,9 +15,41 @@ import { defineRouteContract } from '@/lib/api/contracts/types' /** BASE64URL, 43 chars (a 32-byte token or SHA-256 digest), no padding. */ const base64Url43 = (message: string) => z.string().regex(/^[A-Za-z0-9\-_]{43}$/, message) +/** + * Which key space the exchange mints from. + * + * `copilot` — a Sim Agent key, for the conversational surface. + * `platform` — a Sim API key (`x-api-key`), the credential the public `/api/v1` + * and `/api/v2` endpoints accept. These are separate key spaces: a copilot key + * does not authenticate a platform request, or vice versa. + * + * Defaulted to `copilot` so terminals built against the original exchange keep + * working without sending the field. + */ +export const cliAuthScopeSchema = z.enum(['copilot', 'platform']).default('copilot') +export type CliAuthScope = z.output + export const approveCliAuthBodySchema = z.object({ request: base64Url43('request must be a base64url request id'), challenge: base64Url43('challenge must be a base64url-encoded SHA-256 digest'), + scope: cliAuthScopeSchema, + /** + * Platform scope only: the workspace the user picked in the browser. Returned + * to the terminal so it can store it as the profile's default — the user chose + * it by name, and asking them to go find its id afterwards would be absurd. + * + * Recorded whether or not the key ends up bound to it; see + * {@link bindKeyToWorkspace}. + */ + workspaceId: workspaceIdSchema.optional(), + /** + * Mint a key scoped to {@link workspaceId} rather than a personal key. Only a + * workspace admin may ask for this, and the approve route rejects anything + * less rather than silently downgrading — the browser has already told the + * user which kind of key they are about to get, so a mismatch here means the + * request did not come from that UI. + */ + bindKeyToWorkspace: z.boolean().optional().default(false), }) export type ApproveCliAuthBody = z.input @@ -49,6 +82,26 @@ export const pollCliAuthContract = defineRouteContract({ z.object({ status: z.literal('complete'), key: z.object({ id: z.string(), apiKey: z.string() }), + /** + * Echoes what the approving user actually consented to. The CLI asked + * for a scope in the browser URL, but the approval is what binds it — + * a client that assumed its own request was honored could file a + * copilot key under a platform profile and fail every later call with + * an opaque 401. + */ + scope: z.enum(['copilot', 'platform']), + /** + * The workspace the user picked, for the terminal to store as its + * default. Present for a personal key too — the choice is about which + * workspace the profile targets, not about what the key can reach. + */ + workspaceId: z.string().nullable(), + /** + * Whether the key itself is scoped to {@link workspaceId}. A bound key + * can reach nothing else, so the terminal must not offer to point the + * profile somewhere the credential cannot follow. + */ + workspaceBound: z.boolean(), }), ]), }, diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 37cc07a4fe9..fc9bf7dc522 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -351,7 +351,7 @@ export const resendCredentialGroupEnrollmentContract = defineRouteContract({ }, }) -export const revokeCredentialGroupEnrollmentContract = defineRouteContract({ +export const deleteCredentialGroupEnrollmentContract = defineRouteContract({ method: 'DELETE', path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]', params: credentialGroupEnrollmentParamsSchema, diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 944cbd54980..b0818ace2f7 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -47,23 +47,30 @@ export type WorkspaceCredentialRole = z.output export type WorkspaceCredential = z.output +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + +function trimmedOptionalQueryString>(schema: T) { + return firstQueryStringSchema + .transform((value) => value.trim() || undefined) + .pipe(schema.optional()) + .optional() +} + export const credentialsListQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), + workspaceId: firstQueryStringSchema + .transform((value) => value.trim()) + .pipe(z.string().uuid('Workspace ID must be a valid UUID')), + type: trimmedOptionalQueryString(workspaceCredentialTypeSchema), + providerId: trimmedOptionalQueryString(z.string()), + credentialId: trimmedOptionalQueryString(z.string()), }) export const credentialIdParamsSchema = z.object({ id: z.string().min(1), }) -export const credentialsListGetQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), - credentialId: z.string().optional(), -}) - export const serviceAccountJsonSchema = z .string() .min(1, 'Service account JSON key is required') @@ -260,6 +267,18 @@ export const leaveCredentialQuerySchema = z.object({ credentialId: z.string().min(1), }) +export const credentialMembershipSchema = z.object({ + membershipId: z.string(), + credentialId: z.string(), + workspaceId: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + providerId: z.string().nullable(), + role: workspaceCredentialRoleSchema, + status: workspaceCredentialMemberStatusSchema, + joinedAt: z.string().nullable(), +}) + export const workspaceCredentialMemberSchema = z.object({ id: z.string(), userId: z.string(), @@ -306,6 +325,14 @@ export const oauthCredentialSchema = z.object({ scopes: z.array(z.string()).optional(), }) +export const workspaceCredentialLookupSchema = workspaceCredentialSchema.pick({ + id: true, + displayName: true, + type: true, + providerId: true, +}) +export type WorkspaceCredentialLookup = z.output + export const oauthCredentialsQuerySchema = z .object({ provider: z.string().nullish(), @@ -324,9 +351,10 @@ export const listWorkspaceCredentialsContract = defineRouteContract({ query: credentialsListQuerySchema, response: { mode: 'json', - schema: z.object({ - credentials: z.array(workspaceCredentialSchema), - }), + schema: z.union([ + z.object({ credentials: z.array(workspaceCredentialSchema) }), + z.object({ credential: workspaceCredentialLookupSchema.nullable() }), + ]), }, }) @@ -374,6 +402,7 @@ export const createCredentialDraftContract = defineRouteContract({ mode: 'json', schema: z.object({ success: z.literal(true), + draftId: z.string().min(1), }), }, }) @@ -384,6 +413,7 @@ export const createWorkspaceCredentialContract = defineRouteContract({ body: createCredentialBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ credential: workspaceCredentialSchema, }), @@ -422,6 +452,7 @@ export const upsertWorkspaceCredentialMemberContract = defineRouteContract({ body: upsertWorkspaceCredentialMemberBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ success: z.literal(true), member: workspaceCredentialMemberSchema.optional(), @@ -441,3 +472,22 @@ export const removeWorkspaceCredentialMemberContract = defineRouteContract({ }), }, }) + +export const listCredentialMembershipsContract = defineRouteContract({ + method: 'GET', + path: '/api/credentials/memberships', + response: { + mode: 'json', + schema: z.object({ memberships: z.array(credentialMembershipSchema) }), + }, +}) + +export const leaveCredentialMembershipContract = defineRouteContract({ + method: 'DELETE', + path: '/api/credentials/memberships', + query: leaveCredentialQuerySchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) diff --git a/apps/sim/lib/api/contracts/knowledge/base.test.ts b/apps/sim/lib/api/contracts/knowledge/base.test.ts new file mode 100644 index 00000000000..6d9034bb6c6 --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/base.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + chunkingStrategyOptionsSchema, + createKnowledgeBaseBodySchema, + knowledgeBaseDataSchema, +} from '@/lib/api/contracts/knowledge/base' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' + +const separators = (count: number) => Array.from({ length: count }, (_, i) => `@@sep${i}@@`) + +describe('chunkingStrategyOptionsSchema.separators', () => { + it('accepts a separator list at the bound', () => { + const parsed = chunkingStrategyOptionsSchema.parse({ + separators: separators(MAX_CHUNKING_SEPARATORS), + }) + expect(parsed.separators).toHaveLength(MAX_CHUNKING_SEPARATORS) + }) + + it('rejects more separators than the bound', () => { + const result = chunkingStrategyOptionsSchema.safeParse({ + separators: separators(MAX_CHUNKING_SEPARATORS + 1), + }) + expect(result.success).toBe(false) + }) + + it('rejects a separator longer than the per-item bound', () => { + const result = chunkingStrategyOptionsSchema.safeParse({ + separators: ['|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1)], + }) + expect(result.success).toBe(false) + }) + + it('rejects an oversized list on the knowledge base create body', () => { + const result = createKnowledgeBaseBodySchema.safeParse({ + name: 'kb', + workspaceId: 'ws', + chunkingConfig: { + maxSize: 1024, + minSize: 100, + overlap: 200, + strategy: 'recursive', + strategyOptions: { separators: separators(5000) }, + }, + }) + expect(result.success).toBe(false) + }) +}) + +describe('knowledgeBaseDataSchema.chunkingConfig', () => { + it('still reads a stored config written before the separator bound', () => { + const result = knowledgeBaseDataSchema.safeParse({ + id: 'kb-1', + userId: 'u-1', + name: 'kb', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { + maxSize: 1024, + minSize: 100, + overlap: 200, + strategy: 'recursive', + strategyOptions: { separators: separators(5000) }, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + workspaceId: 'ws', + folderId: null, + }) + expect(result.success).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 475c31e737e..8d6c83288ad 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -5,11 +5,18 @@ import { successResponseSchema, wireDateSchema, } from '@/lib/api/contracts/knowledge/shared' +import { + folderIdSchema, + requiredFieldSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' import type { StrategyOptions } from '@/lib/chunkers/types' import { DEFAULT_CHUNKING_CONFIG, KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, + MAX_KNOWLEDGE_BATCH_ITEMS, } from '@/lib/knowledge/constants' export const knowledgeScopeSchema = z.enum(['active', 'archived', 'all']) @@ -20,7 +27,13 @@ export const listKnowledgeBasesQuerySchema = z.object({ scope: knowledgeScopeSchema.default('active'), }) -export const chunkingStrategyOptionsSchema = z +/** + * Strategy options as they are stored. Reads stay tolerant of a `separators` + * list written before {@link chunkingStrategyOptionsSchema} bounded it, so an + * oversized legacy config lists instead of failing response validation. The + * chunker clamps such a list at construction, so nothing reprocesses unbounded. + */ +export const storedChunkingStrategyOptionsSchema = z .object({ pattern: z .string() @@ -42,6 +55,29 @@ export const chunkingStrategyOptionsSchema = z }) .strict() satisfies z.ZodType +/** + * Strategy options accepted on writes. `separators` is bounded in both length + * and item size: the recursive chunker rescans the whole document once per + * separator, synchronously, so an unbounded list turns one persisted config + * into seconds of uninterruptible CPU on every later document upload. + */ +export const chunkingStrategyOptionsSchema = storedChunkingStrategyOptionsSchema + .extend({ + separators: z + .array( + z + .string() + .max( + MAX_CHUNKING_SEPARATOR_LENGTH, + `Each separator must be ${MAX_CHUNKING_SEPARATOR_LENGTH} characters or less` + ) + ) + .max(MAX_CHUNKING_SEPARATORS, `At most ${MAX_CHUNKING_SEPARATORS} separators are allowed`) + .optional() + .describe('Ordered separators used to split content into chunks.'), + }) + .strict() satisfies z.ZodType + export const chunkingConfigSchema = z .object({ maxSize: z.number().min(100).max(4000), @@ -110,7 +146,7 @@ const knowledgeChunkingConfigSchema = z minSize: z.number(), overlap: z.number(), strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).optional(), - strategyOptions: chunkingStrategyOptionsSchema.optional(), + strategyOptions: storedChunkingStrategyOptionsSchema.optional(), }) .passthrough() @@ -195,3 +231,127 @@ export const restoreKnowledgeBaseContract = defineRouteContract({ schema: z.object({ success: z.literal(true) }).passthrough(), }, }) + +const bulkKnowledgeIdListSchema = z + .array(requiredFieldSchema('id entries cannot be empty')) + .max(MAX_KNOWLEDGE_BATCH_ITEMS, `cannot contain more than ${MAX_KNOWLEDGE_BATCH_ITEMS} ids`) + .default([]) + +/** + * Bounds a mixed selection from the Knowledge list, which interleaves folder + * rows and knowledge base rows in one grid. Both lists travel in one request so + * a mixed selection commits as one authorized operation instead of a + * client-sequenced fan-out. + * + * The cap is on the combined count: each list is individually bounded first so + * an oversized array is rejected before the combined arithmetic, and folders + * cost more than bases because they cascade. + */ +function refineBoundedKnowledgeSelection( + selection: { knowledgeBaseIds: string[]; folderIds: string[] }, + ctx: z.RefinementCtx +): void { + const total = selection.knowledgeBaseIds.length + selection.folderIds.length + if (total === 0) { + ctx.addIssue({ + code: 'custom', + path: ['knowledgeBaseIds'], + message: 'At least one knowledge base or folder must be selected', + }) + return + } + if (total > MAX_KNOWLEDGE_BATCH_ITEMS) { + ctx.addIssue({ + code: 'custom', + path: ['knowledgeBaseIds'], + message: `knowledgeBaseIds and folderIds cannot contain more than ${MAX_KNOWLEDGE_BATCH_ITEMS} ids combined`, + }) + } +} + +export const bulkMoveKnowledgeItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + knowledgeBaseIds: bulkKnowledgeIdListSchema, + folderIds: bulkKnowledgeIdListSchema, + /** Destination folder in the `knowledge_base` tree. `null` is the workspace root. */ + targetFolderId: folderIdSchema.nullable(), + }) + .superRefine(refineBoundedKnowledgeSelection) +export type BulkMoveKnowledgeItemsBody = z.input + +export const bulkDeleteKnowledgeItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + knowledgeBaseIds: bulkKnowledgeIdListSchema, + /** Folders to delete. Each cascades to every knowledge base and subfolder inside it. */ + folderIds: bulkKnowledgeIdListSchema, + }) + .superRefine(refineBoundedKnowledgeSelection) +export type BulkDeleteKnowledgeItemsBody = z.input + +const bulkKnowledgeItemKindSchema = z.enum(['knowledgeBase', 'folder']) + +const bulkKnowledgeItemSchema = z.object({ + kind: bulkKnowledgeItemKindSchema, + id: z.string(), + name: z.string(), +}) + +/** An id nothing active resolved to. Carries no name, because nothing was found to name. */ +const bulkKnowledgeMissingSchema = z.object({ + kind: bulkKnowledgeItemKindSchema, + id: z.string(), +}) + +/** + * An item the batch reached but could not act on for a reason the caller can + * act on in turn. Distinct from `notFound`, which also absorbs the items the + * caller may not write to. + */ +const bulkKnowledgeFailureSchema = bulkKnowledgeItemSchema.extend({ reason: z.string() }) + +/** + * Items dropped because a selected folder already carries them: a knowledge + * base filed inside a selected folder, or a subfolder of another selected one. + */ +const bulkKnowledgeSkippedSchema = z.array(bulkKnowledgeItemSchema) + +export const bulkMoveKnowledgeItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/bulk-move', + body: bulkMoveKnowledgeItemsBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + moved: z.array(bulkKnowledgeItemSchema), + skipped: bulkKnowledgeSkippedSchema, + notFound: z.array(bulkKnowledgeMissingSchema), + failed: z.array(bulkKnowledgeFailureSchema), + }) + ), + }, +}) + +export const bulkDeleteKnowledgeItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/bulk-delete', + body: bulkDeleteKnowledgeItemsBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + deleted: z.array(bulkKnowledgeItemSchema), + skipped: bulkKnowledgeSkippedSchema, + notFound: z.array(bulkKnowledgeMissingSchema), + failed: z.array(bulkKnowledgeFailureSchema), + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: z.object({ + knowledgeBases: z.number().int(), + folders: z.number().int(), + }), + }) + ), + }, +}) diff --git a/apps/sim/lib/api/contracts/oauth-connections.test.ts b/apps/sim/lib/api/contracts/oauth-connections.test.ts index db44ebfb81f..8e607b307fc 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.test.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.test.ts @@ -3,11 +3,23 @@ */ import { describe, expect, it } from 'vitest' import { + connectedAccountsQuerySchema, instagramAuthorizeQuerySchema, instagramCallbackQuerySchema, trelloAuthorizeQuerySchema, } from '@/lib/api/contracts/oauth-connections' +describe('Connected account query contracts', () => { + it('preserves first-value and blank-provider normalization', () => { + expect(connectedAccountsQuerySchema.parse({ provider: '' })).toEqual({ + provider: undefined, + }) + expect(connectedAccountsQuerySchema.parse({ provider: ['google', 'slack'] })).toEqual({ + provider: 'google', + }) + }) +}) + describe('Instagram OAuth query contracts', () => { it('accepts bounded authorize and callback values', () => { expect( diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 234f86419ea..89d31c2fac4 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -32,8 +32,15 @@ export const disconnectOAuthBodySchema = z.object({ accountId: z.string().optional(), }) +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + export const connectedAccountsQuerySchema = z.object({ - provider: z.string().min(1).optional(), + provider: firstQueryStringSchema + .transform((value) => value || undefined) + .pipe(z.string().min(1).optional()) + .optional(), }) export const connectedAccountSchema = z.object({ @@ -49,12 +56,18 @@ export const trelloTokenBodySchema = z.object({ state: z.string().min(1, 'state is required'), }) +const oauthCredentialDraftIdSchema = z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + export const trelloAuthorizeQuerySchema = z.object({ returnUrl: z .string() .min(1, 'Return URL cannot be empty') .max(2048, 'Return URL is too long') .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) const trelloCallbackQuerySchema = z @@ -137,6 +150,7 @@ export const oauthTokenPostContract = defineRouteContract({ export const shopifyAuthorizeQuerySchema = z.object({ shop: z.string().optional(), returnUrl: z.string().optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const shopifyCallbackQuerySchema = z.object({ @@ -226,6 +240,7 @@ export const instagramAuthorizeQuerySchema = z.object({ .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') .optional(), workspaceId: workspaceIdSchema.optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const authorizeInstagramContract = defineRouteContract({ @@ -270,12 +285,42 @@ export const instagramCallbackContract = defineRouteContract({ response: { mode: 'redirect' }, }) -export const authorizeOAuth2QuerySchema = z.object({ - providerId: z.string().min(1, 'providerId is required'), - workspaceId: workspaceIdSchema, - callbackURL: z.string().min(1).optional(), - credentialId: z.string().min(1).optional(), -}) +export const authorizeOAuth2QuerySchema = z + .object({ + draftId: oauthCredentialDraftIdSchema.optional(), + providerId: z.string().min(1, 'providerId is required').optional(), + workspaceId: workspaceIdSchema.optional(), + callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.draftId) { + for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} cannot be combined with draftId`, + }) + } + } + return + } + if (!data.providerId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerId'], + message: 'providerId is required', + }) + } + if (!data.workspaceId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['workspaceId'], + message: 'workspaceId is required', + }) + } + }) export const authorizeOAuth2Contract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index f93a6d5438a..0ed44ed4543 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -32,6 +32,7 @@ import { FILTER_OPS, MAX_RUN_TARGET_ROW_IDS, MAX_SELECT_OPTIONS, + MAX_TABLE_BATCH_ITEMS, NAME_PATTERN, SORT_DIRECTIONS, TABLE_LIMITS, @@ -2212,3 +2213,125 @@ export type TableViewWire = z.output export type TableViewConfigInput = z.input export type CreateTableViewBody = z.input export type UpdateTableViewBody = z.input + +const bulkTableIdListSchema = z + .array(requiredFieldSchema('id entries cannot be empty')) + .max(MAX_TABLE_BATCH_ITEMS, `cannot contain more than ${MAX_TABLE_BATCH_ITEMS} ids`) + .default([]) + +/** + * Bounds a mixed selection from the Tables list, which interleaves folder rows + * and table rows in one grid. Both lists travel in one request so a mixed + * selection commits as one authorized operation instead of a client-sequenced + * fan-out. + * + * The cap is on the combined count: each list is individually bounded first so + * a 10,000-entry array is rejected before the combined arithmetic, and folders + * cost more than tables because they cascade. + */ +function refineBoundedTableSelection( + selection: { tableIds: string[]; folderIds: string[] }, + ctx: z.RefinementCtx +): void { + const total = selection.tableIds.length + selection.folderIds.length + if (total === 0) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: 'At least one table or folder must be selected', + }) + return + } + if (total > MAX_TABLE_BATCH_ITEMS) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: `tableIds and folderIds cannot contain more than ${MAX_TABLE_BATCH_ITEMS} ids combined`, + }) + } +} + +export const bulkMoveTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: bulkTableIdListSchema.describe('Tables to move, by identifier.'), + folderIds: bulkTableIdListSchema.describe('Table folders to re-parent, by identifier.'), + targetFolderId: folderIdSchema + .nullable() + .describe('Destination folder in the table folder tree. `null` is the workspace root.'), + }) + .superRefine(refineBoundedTableSelection) +export type BulkMoveTablesBody = z.input + +export const bulkDeleteTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: bulkTableIdListSchema.describe('Tables to archive, by identifier.'), + folderIds: bulkTableIdListSchema.describe( + 'Table folders to delete, by identifier. Each cascades to everything inside it.' + ), + }) + .superRefine(refineBoundedTableSelection) +export type BulkDeleteTablesBody = z.input + +const bulkTableItemKindSchema = z.enum(['table', 'folder']) + +const bulkTableItemSchema = z.object({ + kind: bulkTableItemKindSchema, + id: z.string(), + name: z.string(), +}) + +/** An id nothing active resolved to. Carries no name, because nothing was found to name. */ +const bulkTableMissingSchema = z.object({ kind: bulkTableItemKindSchema, id: z.string() }) + +/** + * An item the batch reached but could not act on for a reason the caller can + * act on in turn — a delete lock, a folder cycle. Distinct from `notFound`, + * which also absorbs the items the caller may not write to. + */ +const bulkTableFailureSchema = bulkTableItemSchema.extend({ reason: z.string() }) + +/** + * Items dropped because a selected folder already carries them: a table filed + * inside a selected folder, or a subfolder of another selected folder. + */ +const bulkTableSkippedSchema = z.array(bulkTableItemSchema) + +export const bulkMoveTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/table/bulk-move', + body: bulkMoveTablesBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + moved: z.array(bulkTableItemSchema), + skipped: bulkTableSkippedSchema, + notFound: z.array(bulkTableMissingSchema), + failed: z.array(bulkTableFailureSchema), + }) + ), + }, +}) +export const bulkDeleteTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/table/bulk-delete', + body: bulkDeleteTablesBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + deleted: z.array(bulkTableItemSchema), + skipped: bulkTableSkippedSchema, + notFound: z.array(bulkTableMissingSchema), + failed: z.array(bulkTableFailureSchema), + /** Totals across the explicit archives and every folder cascade they triggered. */ + deletedItems: z.object({ + tables: z.number().int(), + folders: z.number().int(), + }), + }) + ), + }, +}) diff --git a/apps/sim/lib/api/contracts/tools/crowdstrike.ts b/apps/sim/lib/api/contracts/tools/crowdstrike.ts index 4fedf74fcc3..3a9a295d42d 100644 --- a/apps/sim/lib/api/contracts/tools/crowdstrike.ts +++ b/apps/sim/lib/api/contracts/tools/crowdstrike.ts @@ -5,11 +5,23 @@ import type { ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import type { CrowdStrikeAggregateQuery } from '@/tools/crowdstrike/types' +import type { + CrowdStrikeAggregateQuery, + CrowdStrikeSensorAggregateBucket, + CrowdStrikeSensorAggregateResult, +} from '@/tools/crowdstrike/types' const crowdstrikeNullableStringSchema = z.string().nullable() const crowdstrikeNullableNumberSchema = z.number().nullable() +const crowdstrikeErrorsSchema = z.array( + z.object({ + code: crowdstrikeNullableNumberSchema, + id: crowdstrikeNullableStringSchema, + message: crowdstrikeNullableStringSchema, + }) +) + const crowdstrikePaginationSchema = z .object({ limit: crowdstrikeNullableNumberSchema, @@ -40,30 +52,41 @@ const crowdstrikeSensorSchema = z.object({ tiEnabled: crowdstrikeNullableStringSchema, }) -const crowdstrikeAggregateBucketSchema = z.object({ - count: crowdstrikeNullableNumberSchema, - from: crowdstrikeNullableNumberSchema, - keyAsString: crowdstrikeNullableStringSchema, - label: z.unknown().nullable(), - stringFrom: crowdstrikeNullableStringSchema, - stringTo: crowdstrikeNullableStringSchema, - subAggregates: z.array(z.unknown()), - to: crowdstrikeNullableNumberSchema, - value: crowdstrikeNullableNumberSchema, - valueAsString: crowdstrikeNullableStringSchema, -}) +/** + * Buckets nest recursively through `subAggregates`, so the two schemas reference + * each other through `z.lazy`. `label` is `interface{}` in CrowdStrike's own spec — + * a terms aggregation returns a scalar and a range aggregation an object — so the + * route forwards it unchanged. + */ +const crowdstrikeAggregateBucketSchema: z.ZodType = z.lazy(() => + z.object({ + count: crowdstrikeNullableNumberSchema, + from: crowdstrikeNullableNumberSchema, + keyAsString: crowdstrikeNullableStringSchema, + label: z.unknown(), + stringFrom: crowdstrikeNullableStringSchema, + stringTo: crowdstrikeNullableStringSchema, + subAggregates: z.array(crowdstrikeAggregateResultSchema), + to: crowdstrikeNullableNumberSchema, + value: crowdstrikeNullableNumberSchema, + valueAsString: crowdstrikeNullableStringSchema, + }) +) -const crowdstrikeAggregateResultSchema = z.object({ - buckets: z.array(crowdstrikeAggregateBucketSchema), - docCountErrorUpperBound: crowdstrikeNullableNumberSchema, - name: crowdstrikeNullableStringSchema, - sumOtherDocCount: crowdstrikeNullableNumberSchema, -}) +const crowdstrikeAggregateResultSchema: z.ZodType = z.lazy(() => + z.object({ + buckets: z.array(crowdstrikeAggregateBucketSchema), + docCountErrorUpperBound: crowdstrikeNullableNumberSchema, + name: crowdstrikeNullableStringSchema, + sumOtherDocCount: crowdstrikeNullableNumberSchema, + }) +) const crowdstrikeSensorsResponseSchema = z.object({ success: z.literal(true), output: z.object({ count: z.number(), + errors: crowdstrikeErrorsSchema, pagination: crowdstrikePaginationSchema, sensors: z.array(crowdstrikeSensorSchema), }), @@ -74,10 +97,11 @@ const crowdstrikeAggregatesResponseSchema = z.object({ output: z.object({ aggregates: z.array(crowdstrikeAggregateResultSchema), count: z.number(), + errors: crowdstrikeErrorsSchema, }), }) -const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'eu-1', 'us-gov-1', 'us-gov-2'] as const +const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'us-3', 'eu-1', 'us-gov-1', 'us-gov-2'] as const const baseRequestSchema = z.object({ clientId: z.string().min(1, 'Client ID is required'), @@ -95,38 +119,67 @@ const extendedBoundsSchema = z.object({ min: z.string(), }) +/** CrowdStrike's `MsaRangeSpec` serializes its bounds capitalized, unlike every sibling spec. */ const rangeSpecSchema = z.object({ - from: z.number(), - to: z.number(), + From: z.number(), + To: z.number(), }) +/** `MsaAPIFiltersSpec` — an FQL-per-bucket map plus the catch-all bucket controls. */ +const filtersSpecSchema = z.object({ + filters: z.record(z.string(), z.string()), + other_bucket: z.boolean().optional(), + other_bucket_key: z.string().optional(), +}) + +/** + * `MsaAggregateQueryRequest` marks nearly every property `Required: true`, which + * cannot be literally true of an aggregation body, so the fields stay optional + * here. Demanding at least a `field` or a `type` is strictly weaker than the + * published spec, so it cannot reject an aggregation CrowdStrike would accept, + * and it turns "aggregate query says nothing" into a message the caller can act + * on instead of an opaque 400. + */ const aggregateQuerySchema: z.ZodType = z.lazy(() => - z.object({ - date_ranges: z.array(dateRangeSchema).optional(), - exclude: z.string().optional(), - extended_bounds: extendedBoundsSchema.optional(), - field: z.string().optional(), - filter: z.string().optional(), - from: z.number().int().nonnegative().optional(), - include: z.string().optional(), - interval: z.string().optional(), - max_doc_count: z.number().int().nonnegative().optional(), - min_doc_count: z.number().int().nonnegative().optional(), - missing: z.string().optional(), - name: z.string().optional(), - q: z.string().optional(), - ranges: z.array(rangeSpecSchema).optional(), - size: z.number().int().nonnegative().optional(), - sort: z.string().optional(), - sub_aggregates: z.array(aggregateQuerySchema).optional(), - time_zone: z.string().optional(), - type: z.string().optional(), - }) + z + .object({ + date_ranges: z.array(dateRangeSchema).optional(), + exclude: z.string().optional(), + extended_bounds: extendedBoundsSchema.optional(), + field: z.string().optional(), + filter: z.string().optional(), + filters_spec: filtersSpecSchema.optional(), + from: z.number().int().nonnegative().optional(), + include: z.string().optional(), + interval: z.string().optional(), + max_doc_count: z.number().int().nonnegative().optional(), + min_doc_count: z.number().int().nonnegative().optional(), + missing: z.string().optional(), + name: z.string().optional(), + percents: z.array(z.number()).optional(), + q: z.string().optional(), + ranges: z.array(rangeSpecSchema).optional(), + size: z.number().int().nonnegative().optional(), + sort: z.string().optional(), + sub_aggregates: z.array(aggregateQuerySchema).optional(), + time_zone: z.string().optional(), + type: z.string().optional(), + }) + .refine((value) => Boolean(value.field ?? value.type), { + message: 'aggregateQuery must set at least a field or a type', + }) ) +/** + * Falcon treats an empty query param as an empty FQL expression rather than an + * absent one, so a blank string must be rejected instead of forwarded. + */ +const nonBlankQuerySchema = (label: string) => + z.string().trim().min(1, `${label} must not be empty`).optional() + const querySensorsSchema = baseRequestSchema.extend({ operation: z.literal('crowdstrike_query_sensors'), - filter: z.string().optional(), + filter: nonBlankQuerySchema('Filter'), limit: z .number() .int() @@ -134,7 +187,7 @@ const querySensorsSchema = baseRequestSchema.extend({ .max(200, 'Limit must be at most 200') .optional(), offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), - sort: z.string().optional(), + sort: nonBlankQuerySchema('Sort'), }) const getSensorDetailsSchema = baseRequestSchema.extend({ @@ -142,7 +195,7 @@ const getSensorDetailsSchema = baseRequestSchema.extend({ ids: z .array(z.string().trim().min(1, 'Sensor IDs must not be empty')) .min(1, 'At least one sensor ID is required') - .max(5000, 'CrowdStrike supports up to 5000 sensor IDs per request'), + .max(5000, 'CrowdStrike accepts at most 5000 sensor IDs per request'), }) const getSensorAggregatesSchema = baseRequestSchema.extend({ @@ -150,10 +203,823 @@ const getSensorAggregatesSchema = baseRequestSchema.extend({ aggregateQuery: aggregateQuerySchema, }) +/** + * Falcon agent IDs are 32-character hex AIDs. Constraining them keeps a crafted + * value out of the FQL `device_id` filter the host-group action builds, and bounds + * the length of that generated filter. + */ +const agentIdsSchema = (max: number, limitReason: string) => + z + .array( + z + .string() + .trim() + .regex(/^[0-9a-fA-F]{32}$/, 'Host agent IDs must be 32 hexadecimal characters') + ) + .min(1, 'At least one host agent ID is required') + .max(max, `${limitReason} (limit ${max})`) + +/** + * `documented` distinguishes a cap CrowdStrike publishes from one Sim imposes so + * a single request cannot balloon without bound. Only the published caps may + * claim CrowdStrike as their source. + */ +const idsSchema = (max: number, label: string, documented = false) => + z + .array(z.string().trim().min(1, `${label} must not be empty`)) + .min(1, `At least one ${label} is required`) + .max( + max, + documented + ? `CrowdStrike accepts at most ${max} ${label} values per request` + : `Sim caps this request at ${max} ${label} values; CrowdStrike publishes no limit for this endpoint` + ) + +const crowdstrikeCursorPaginationSchema = z + .object({ + after: crowdstrikeNullableStringSchema, + limit: crowdstrikeNullableNumberSchema, + offset: crowdstrikeNullableNumberSchema, + total: crowdstrikeNullableNumberSchema, + }) + .nullable() + +const crowdstrikeSpotlightPaginationSchema = z + .object({ + after: crowdstrikeNullableStringSchema, + limit: crowdstrikeNullableNumberSchema, + total: crowdstrikeNullableNumberSchema, + }) + .nullable() + +const crowdstrikeAlertSchema = z.object({ + compositeId: crowdstrikeNullableStringSchema, + id: crowdstrikeNullableStringSchema, + cid: crowdstrikeNullableStringSchema, + aggregateId: crowdstrikeNullableStringSchema, + agentId: crowdstrikeNullableStringSchema, + deviceId: crowdstrikeNullableStringSchema, + hostname: crowdstrikeNullableStringSchema, + name: crowdstrikeNullableStringSchema, + displayName: crowdstrikeNullableStringSchema, + description: crowdstrikeNullableStringSchema, + type: crowdstrikeNullableStringSchema, + product: crowdstrikeNullableStringSchema, + platform: crowdstrikeNullableStringSchema, + severity: crowdstrikeNullableNumberSchema, + severityName: crowdstrikeNullableStringSchema, + confidence: crowdstrikeNullableNumberSchema, + status: crowdstrikeNullableStringSchema, + assignedToName: crowdstrikeNullableStringSchema, + assignedToUid: crowdstrikeNullableStringSchema, + assignedToUuid: crowdstrikeNullableStringSchema, + tactic: crowdstrikeNullableStringSchema, + tacticId: crowdstrikeNullableStringSchema, + technique: crowdstrikeNullableStringSchema, + techniqueId: crowdstrikeNullableStringSchema, + scenario: crowdstrikeNullableStringSchema, + objective: crowdstrikeNullableStringSchema, + resolution: crowdstrikeNullableStringSchema, + showInUi: z.boolean().nullable(), + tags: z.array(z.string()), + filename: crowdstrikeNullableStringSchema, + filepath: crowdstrikeNullableStringSchema, + cmdline: crowdstrikeNullableStringSchema, + sha256: crowdstrikeNullableStringSchema, + sha1: crowdstrikeNullableStringSchema, + md5: crowdstrikeNullableStringSchema, + userName: crowdstrikeNullableStringSchema, + userId: crowdstrikeNullableStringSchema, + patternId: crowdstrikeNullableNumberSchema, + falconHostLink: crowdstrikeNullableStringSchema, + controlGraphId: crowdstrikeNullableStringSchema, + external: z.boolean().nullable(), + emailSent: z.boolean().nullable(), + isAggregated: z.boolean().nullable(), + isFalconPlatformIoa: z.boolean().nullable(), + dataDomains: z.array(z.string()), + iocValues: z.array(z.string()), + linkedCaseIds: z.array(z.string()), + linkedBehavioralDetections: z.array(z.string()), + timestamp: crowdstrikeNullableStringSchema, + createdTimestamp: crowdstrikeNullableStringSchema, + updatedTimestamp: crowdstrikeNullableStringSchema, + crawledTimestamp: crowdstrikeNullableStringSchema, + contextTimestamp: crowdstrikeNullableStringSchema, +}) + +const crowdstrikeHostGroupSchema = z.object({ + id: crowdstrikeNullableStringSchema, + name: crowdstrikeNullableStringSchema, + description: crowdstrikeNullableStringSchema, + groupType: crowdstrikeNullableStringSchema, + assignmentRule: crowdstrikeNullableStringSchema, + createdBy: crowdstrikeNullableStringSchema, + createdTimestamp: crowdstrikeNullableStringSchema, + modifiedBy: crowdstrikeNullableStringSchema, + modifiedTimestamp: crowdstrikeNullableStringSchema, +}) + +const crowdstrikeIndicatorSchema = z.object({ + id: crowdstrikeNullableStringSchema, + type: crowdstrikeNullableStringSchema, + value: crowdstrikeNullableStringSchema, + action: crowdstrikeNullableStringSchema, + mobileAction: crowdstrikeNullableStringSchema, + severity: crowdstrikeNullableStringSchema, + description: crowdstrikeNullableStringSchema, + source: crowdstrikeNullableStringSchema, + appliedGlobally: z.boolean().nullable(), + platforms: z.array(z.string()), + hostGroups: z.array(z.string()), + tags: z.array(z.string()), + expiration: crowdstrikeNullableStringSchema, + expired: z.boolean().nullable(), + deleted: z.boolean().nullable(), + fromParent: z.boolean().nullable(), + parentCidName: crowdstrikeNullableStringSchema, + createdBy: crowdstrikeNullableStringSchema, + createdOn: crowdstrikeNullableStringSchema, + modifiedBy: crowdstrikeNullableStringSchema, + modifiedOn: crowdstrikeNullableStringSchema, + metadata: z + .object({ + avHits: crowdstrikeNullableNumberSchema, + companyName: crowdstrikeNullableStringSchema, + fileDescription: crowdstrikeNullableStringSchema, + fileVersion: crowdstrikeNullableStringSchema, + filename: crowdstrikeNullableStringSchema, + originalFilename: crowdstrikeNullableStringSchema, + productName: crowdstrikeNullableStringSchema, + productVersion: crowdstrikeNullableStringSchema, + signed: z.boolean().nullable(), + }) + .nullable(), +}) + +const crowdstrikeVulnerabilitySchema = z.object({ + id: crowdstrikeNullableStringSchema, + aid: crowdstrikeNullableStringSchema, + cid: crowdstrikeNullableStringSchema, + status: crowdstrikeNullableStringSchema, + confidence: crowdstrikeNullableStringSchema, + vulnerabilityId: crowdstrikeNullableStringSchema, + createdTimestamp: crowdstrikeNullableStringSchema, + updatedTimestamp: crowdstrikeNullableStringSchema, + closedTimestamp: crowdstrikeNullableStringSchema, + cve: z + .object({ + id: crowdstrikeNullableStringSchema, + baseScore: crowdstrikeNullableNumberSchema, + severity: crowdstrikeNullableStringSchema, + exprtRating: crowdstrikeNullableStringSchema, + exploitStatus: crowdstrikeNullableNumberSchema, + exploitabilityScore: crowdstrikeNullableNumberSchema, + impactScore: crowdstrikeNullableNumberSchema, + remediationLevel: crowdstrikeNullableStringSchema, + description: crowdstrikeNullableStringSchema, + publishedDate: crowdstrikeNullableStringSchema, + vector: crowdstrikeNullableStringSchema, + types: z.array(z.string()), + isCisaKev: z.boolean().nullable(), + cisaDueDate: crowdstrikeNullableStringSchema, + }) + .nullable(), + app: z + .object({ + productNameNormalized: crowdstrikeNullableStringSchema, + productNameVersion: crowdstrikeNullableStringSchema, + vendorNormalized: crowdstrikeNullableStringSchema, + }) + .nullable(), + hostInfo: z + .object({ + hostname: crowdstrikeNullableStringSchema, + localIp: crowdstrikeNullableStringSchema, + machineDomain: crowdstrikeNullableStringSchema, + osVersion: crowdstrikeNullableStringSchema, + platform: crowdstrikeNullableStringSchema, + productTypeDesc: crowdstrikeNullableStringSchema, + assetCriticality: crowdstrikeNullableStringSchema, + internetExposure: crowdstrikeNullableStringSchema, + tags: z.array(z.string()), + groups: z.array(z.string()), + }) + .nullable(), + remediationIds: z.array(z.string()), + remediations: z.array( + z.object({ + id: crowdstrikeNullableStringSchema, + title: crowdstrikeNullableStringSchema, + action: crowdstrikeNullableStringSchema, + type: crowdstrikeNullableStringSchema, + link: crowdstrikeNullableStringSchema, + reference: crowdstrikeNullableStringSchema, + vendorUrl: crowdstrikeNullableStringSchema, + }) + ), + suppressionInfo: z + .object({ + isSuppressed: z.boolean().nullable(), + reason: crowdstrikeNullableStringSchema, + }) + .nullable(), +}) + +const crowdstrikeFalconUserSchema = z + .object({ + uuid: crowdstrikeNullableStringSchema, + email: crowdstrikeNullableStringSchema, + fullName: crowdstrikeNullableStringSchema, + }) + .nullable() + +const crowdstrikeCaseSchema = z.object({ + id: crowdstrikeNullableStringSchema, + cid: crowdstrikeNullableStringSchema, + name: crowdstrikeNullableStringSchema, + description: crowdstrikeNullableStringSchema, + descriptionFormat: crowdstrikeNullableStringSchema, + status: crowdstrikeNullableStringSchema, + severity: crowdstrikeNullableNumberSchema, + severityLevel: crowdstrikeNullableStringSchema, + referenceId: crowdstrikeNullableStringSchema, + version: crowdstrikeNullableNumberSchema, + tags: z.array(z.string()), + assignedTo: crowdstrikeFalconUserSchema, + createdBy: crowdstrikeFalconUserSchema, + lastUpdatedBy: crowdstrikeFalconUserSchema, + createdTimestamp: crowdstrikeNullableStringSchema, + updatedTimestamp: crowdstrikeNullableStringSchema, + startTimestamp: crowdstrikeNullableStringSchema, + endTimestamp: crowdstrikeNullableStringSchema, + templateId: crowdstrikeNullableStringSchema, + templateName: crowdstrikeNullableStringSchema, + slaId: crowdstrikeNullableStringSchema, + slaName: crowdstrikeNullableStringSchema, + isReadOnly: z.boolean().nullable(), +}) + +const successOutput = (output: T) => + z.object({ success: z.literal(true), output }) + +const crowdstrikeAlertIdsResponseSchema = successOutput( + z.object({ + alertIds: z.array(z.string()), + count: z.number(), + pagination: crowdstrikePaginationSchema, + }) +) + +const crowdstrikeHostGroupIdsResponseSchema = successOutput( + z.object({ + hostGroupIds: z.array(z.string()), + count: z.number(), + pagination: crowdstrikePaginationSchema, + }) +) + +const crowdstrikeCaseIdsResponseSchema = successOutput( + z.object({ + caseIds: z.array(z.string()), + count: z.number(), + pagination: crowdstrikePaginationSchema, + }) +) + +const crowdstrikeIndicatorIdsResponseSchema = successOutput( + z.object({ + indicatorIds: z.array(z.string()), + count: z.number(), + pagination: crowdstrikeCursorPaginationSchema, + }) +) + +const crowdstrikeVulnerabilityIdsResponseSchema = successOutput( + z.object({ + vulnerabilityIds: z.array(z.string()), + count: z.number(), + pagination: crowdstrikeSpotlightPaginationSchema, + }) +) + +const crowdstrikeAlertsResponseSchema = successOutput( + z.object({ + alerts: z.array(crowdstrikeAlertSchema), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeUpdatedAlertsResponseSchema = successOutput( + z.object({ + updatedIds: z.array(z.string()), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeAffectedEntitiesResponseSchema = successOutput( + z.object({ + affected: z.array( + z.object({ + id: crowdstrikeNullableStringSchema, + path: crowdstrikeNullableStringSchema, + }) + ), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeHostGroupsResponseSchema = successOutput( + z.object({ + hostGroups: z.array(crowdstrikeHostGroupSchema), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeIndicatorsResponseSchema = successOutput( + z.object({ + indicators: z.array(crowdstrikeIndicatorSchema), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeDeletedIndicatorsResponseSchema = successOutput( + z.object({ + deletedIds: z.array(z.string()), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeVulnerabilitiesResponseSchema = successOutput( + z.object({ + vulnerabilities: z.array(crowdstrikeVulnerabilitySchema), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeRtrSessionResponseSchema = successOutput( + z.object({ + sessionId: crowdstrikeNullableStringSchema, + deviceId: crowdstrikeNullableStringSchema, + platform: crowdstrikeNullableStringSchema, + pwd: crowdstrikeNullableStringSchema, + offlineQueued: z.boolean().nullable(), + existingAidSessions: crowdstrikeNullableNumberSchema, + createdAt: crowdstrikeNullableStringSchema, + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeRtrCommandResponseSchema = successOutput( + z.object({ + cloudRequestId: crowdstrikeNullableStringSchema, + sessionId: crowdstrikeNullableStringSchema, + queuedCommandOffline: z.boolean().nullable(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeRtrCommandStatusResponseSchema = successOutput( + z.object({ + complete: z.boolean().nullable(), + stdout: crowdstrikeNullableStringSchema, + stderr: crowdstrikeNullableStringSchema, + baseCommand: crowdstrikeNullableStringSchema, + sessionId: crowdstrikeNullableStringSchema, + taskId: crowdstrikeNullableStringSchema, + sequenceId: crowdstrikeNullableNumberSchema, + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeRtrDeleteSessionResponseSchema = successOutput( + z.object({ + sessionId: z.string(), + deleted: z.boolean(), + errors: crowdstrikeErrorsSchema, + }) +) + +const crowdstrikeCasesResponseSchema = successOutput( + z.object({ + cases: z.array(crowdstrikeCaseSchema), + count: z.number(), + errors: crowdstrikeErrorsSchema, + }) +) + +const queryAlertsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_query_alerts'), + filter: nonBlankQuerySchema('Filter'), + q: nonBlankQuerySchema('Query'), + limit: z + .number() + .int() + .min(1, 'Limit must be at least 1') + .max(10000, 'Limit must be at most 10000') + .optional(), + offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), + sort: nonBlankQuerySchema('Sort'), + includeHidden: z.boolean().optional(), +}) + +const getAlertDetailsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_alert_details'), + compositeIds: idsSchema(1000, 'composite alert ID'), + includeHidden: z.boolean().optional(), +}) + +const ALERT_STATUSES = ['new', 'in_progress', 'reopened', 'closed'] as const + +const updateAlertsSchema = baseRequestSchema + .extend({ + operation: z.literal('crowdstrike_update_alerts'), + compositeIds: idsSchema(1000, 'composite alert ID'), + updateStatus: z + .enum(ALERT_STATUSES, { + message: 'updateStatus must be new, in_progress, reopened, or closed', + }) + .optional(), + assignToUuid: z.string().trim().min(1, 'assignToUuid cannot be empty').optional(), + assignToUserId: z.string().trim().min(1, 'assignToUserId cannot be empty').optional(), + assignToName: z.string().trim().min(1, 'assignToName cannot be empty').optional(), + unassign: z.boolean().optional(), + appendComment: z.string().trim().min(1, 'appendComment cannot be empty').optional(), + addTag: z.string().trim().min(1, 'addTag cannot be empty').optional(), + removeTag: z.string().trim().min(1, 'removeTag cannot be empty').optional(), + removeTagsByPrefix: z.string().trim().min(1, 'removeTagsByPrefix cannot be empty').optional(), + showInUi: z.boolean().optional(), + actionParameters: z + .array( + z.object({ + name: z.string().trim().min(1, 'Action parameter name cannot be empty'), + value: z.string(), + }) + ) + .max(50, 'At most 50 action parameters are supported') + .optional(), + includeHidden: z.boolean().optional(), + }) + .superRefine((value, ctx) => { + const assignsSomeone = + value.assignToUuid !== undefined || + value.assignToUserId !== undefined || + value.assignToName !== undefined + if (value.unassign === true && assignsSomeone) { + ctx.addIssue({ + code: 'custom', + path: ['unassign'], + message: + 'unassign cannot be combined with assignToUuid, assignToUserId, or assignToName; CrowdStrike does not define which wins', + }) + } + + const hasAction = + value.updateStatus !== undefined || + value.assignToUuid !== undefined || + value.assignToUserId !== undefined || + value.assignToName !== undefined || + value.unassign === true || + value.appendComment !== undefined || + value.addTag !== undefined || + value.removeTag !== undefined || + value.removeTagsByPrefix !== undefined || + value.showInUi !== undefined || + (value.actionParameters?.length ?? 0) > 0 + + if (!hasAction) { + ctx.addIssue({ + code: 'custom', + path: ['actionParameters'], + message: + 'Supply at least one alert update: updateStatus, assignToUuid, assignToUserId, assignToName, unassign, appendComment, addTag, removeTag, removeTagsByPrefix, showInUi, or actionParameters', + }) + } + }) + +const HOST_ACTIONS = [ + 'contain', + 'lift_containment', + 'hide_host', + 'unhide_host', + 'detection_suppress', + 'detection_unsuppress', +] as const + +const performHostActionSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_perform_host_action'), + actionName: z.enum(HOST_ACTIONS, { + message: `actionName must be one of ${HOST_ACTIONS.join(', ')}`, + }), + deviceIds: agentIdsSchema(100, 'CrowdStrike accepts at most 100 host agent IDs per host action'), +}) + +const queryHostGroupsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_query_host_groups'), + filter: nonBlankQuerySchema('Filter'), + limit: z + .number() + .int() + .min(1, 'Limit must be at least 1') + .max(5000, 'Limit must be at most 5000') + .optional(), + offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), + sort: nonBlankQuerySchema('Sort'), +}) + +const getHostGroupDetailsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_host_group_details'), + hostGroupIds: idsSchema(500, 'host group ID'), +}) + +const HOST_GROUP_ACTIONS = ['add-hosts', 'remove-hosts'] as const + +/** + * `RTR-ExecuteCommand` is the Read-scoped tier of the three RTR command endpoints, + * and it accepts only these base commands. Subcommands ride in `command_string` + * (`eventlog view ...`, `reg query ...`), never in `base_command`; the write-tier + * variants such as `eventlog backup` belong to `/entities/active-responder-command/v1` + * and would fail here on scope. `reg` is read-tier only as `reg query` — `reg set` + * and `reg delete` are Active Responder commands on that same write-tier endpoint. + * `ifconfig` and `users` are the macOS/Linux counterparts to `ipconfig` and are + * read-tier on this endpoint. + */ +export const RTR_READ_ONLY_BASE_COMMANDS = [ + 'cat', + 'cd', + 'clear', + 'csrutil', + 'env', + 'eventlog', + 'filehash', + 'getsid', + 'help', + 'history', + 'ifconfig', + 'ipconfig', + 'ls', + 'mount', + 'netstat', + 'ps', + 'reg', + 'users', +] as const + +const performHostGroupActionSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_perform_host_group_action'), + actionName: z.enum(HOST_GROUP_ACTIONS, { + message: 'actionName must be add-hosts or remove-hosts', + }), + hostGroupId: z.string().trim().min(1, 'hostGroupId is required'), + deviceIds: agentIdsSchema( + 500, + 'CrowdStrike documents no host cap for this endpoint, so Sim bounds the generated device_id FQL filter' + ), +}) + +const queryIndicatorsSchema = baseRequestSchema + .extend({ + operation: z.literal('crowdstrike_query_indicators'), + filter: nonBlankQuerySchema('Filter'), + limit: z + .number() + .int() + .min(1, 'Limit must be at least 1') + .max(500, 'Limit must be at most 500') + .optional(), + offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), + after: nonBlankQuerySchema('After cursor'), + sort: nonBlankQuerySchema('Sort'), + }) + .superRefine((value, ctx) => { + if (value.offset !== undefined && value.after !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['after'], + message: 'after cannot be combined with offset; pick one pagination mode', + }) + } + }) + +const getIndicatorDetailsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_indicator_details'), + indicatorIds: idsSchema(1000, 'indicator ID'), +}) + +/** + * `PATCH /iocs/entities/indicators/v1` treats a supplied empty string as a real + * value and clears the stored field, so the fields a blank would clobber are + * constrained here. Unknown keys pass through so newly documented IOC fields keep + * working without a contract change. + * + * This guards blanks only. Omitted fields have also been observed to come back + * cleared after a PATCH — CrowdStrike publishes no statement either way — and no + * schema can detect an absent key, so that hazard is carried in the + * `crowdstrike_update_indicators` tool and parameter descriptions instead. + */ +const indicatorPayloadSchema = z + .object({ + id: nonBlankQuerySchema('Indicator id'), + type: nonBlankQuerySchema('Indicator type'), + value: nonBlankQuerySchema('Indicator value'), + action: nonBlankQuerySchema('Indicator action'), + mobile_action: nonBlankQuerySchema('Indicator mobile_action'), + severity: nonBlankQuerySchema('Indicator severity'), + description: nonBlankQuerySchema('Indicator description'), + source: nonBlankQuerySchema('Indicator source'), + expiration: nonBlankQuerySchema('Indicator expiration'), + applied_globally: z.boolean().optional(), + platforms: z.array(z.string().trim().min(1, 'Platform must not be empty')).optional(), + host_groups: z.array(z.string().trim().min(1, 'Host group ID must not be empty')).optional(), + tags: z.array(z.string().trim().min(1, 'Tag must not be empty')).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .catchall(z.unknown()) + +/** + * `api.IndicatorCreateReqV1` carries no `id` — the ID is assigned by CrowdStrike — + * and identifies the indicator by `type` plus `value`. `applied_globally` is the + * one property the spec marks `Required: true`, and it decides whether the + * indicator applies to the whole fleet, so it must be stated rather than defaulted. + */ +const createIndicatorPayloadSchema = indicatorPayloadSchema.extend({ + type: z.string().trim().min(1, 'Indicator type is required to create an indicator'), + value: z.string().trim().min(1, 'Indicator value is required to create an indicator'), + applied_globally: z.boolean({ + message: 'applied_globally is required; set it to false to scope the indicator to host_groups', + }), +}) + +/** + * `api.IndicatorUpdateReqV1` identifies the record by `id` and exposes no `type` + * or `value` — those are immutable — so an update missing an `id` cannot name a + * record and must be rejected before it reaches Falcon. + */ +const updateIndicatorPayloadSchema = indicatorPayloadSchema + .extend({ + id: z.string().trim().min(1, 'Each indicator to update requires an id'), + }) + .superRefine((value, ctx) => { + for (const immutable of ['type', 'value'] as const) { + if (value[immutable] !== undefined) { + ctx.addIssue({ + code: 'custom', + path: [immutable], + message: `${immutable} cannot be changed on an existing indicator; delete and recreate it instead`, + }) + } + } + }) + +const createIndicatorsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_create_indicators'), + indicators: z + .array(createIndicatorPayloadSchema) + .min(1, 'At least one indicator is required') + .max( + 200, + 'Sim caps this request at 200 indicators; CrowdStrike publishes no limit for this endpoint' + ), + comment: nonBlankQuerySchema('Comment'), + retrodetects: z.boolean().optional(), + ignoreWarnings: z.boolean().optional(), +}) + +const updateIndicatorsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_update_indicators'), + indicators: z + .array(updateIndicatorPayloadSchema) + .min(1, 'At least one indicator is required') + .max( + 200, + 'Sim caps this request at 200 indicators; CrowdStrike publishes no limit for this endpoint' + ), + comment: nonBlankQuerySchema('Comment'), + retrodetects: z.boolean().optional(), + ignoreWarnings: z.boolean().optional(), +}) + +const deleteIndicatorsSchema = baseRequestSchema + .extend({ + operation: z.literal('crowdstrike_delete_indicators'), + indicatorIds: idsSchema(1000, 'indicator ID').optional(), + filter: z.string().trim().min(1, 'filter cannot be empty').optional(), + comment: nonBlankQuerySchema('Comment'), + }) + .superRefine((value, ctx) => { + if (!value.filter && !value.indicatorIds?.length) { + ctx.addIssue({ + code: 'custom', + path: ['indicatorIds'], + message: 'Supply indicatorIds or a filter selecting the indicators to delete', + }) + } + + if (value.filter && value.indicatorIds?.length) { + ctx.addIssue({ + code: 'custom', + path: ['filter'], + message: + 'Supply indicatorIds or a filter, not both; CrowdStrike ignores the ID list whenever a filter is present', + }) + } + }) + +const queryVulnerabilitiesSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_query_vulnerabilities'), + filter: z.string().trim().min(1, 'Spotlight requires a filter expression'), + limit: z + .number() + .int() + .min(1, 'Limit must be at least 1') + .max(400, 'Limit must be at most 400') + .optional(), + after: nonBlankQuerySchema('After cursor'), + sort: nonBlankQuerySchema('Sort'), +}) + +const getVulnerabilityDetailsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_vulnerability_details'), + vulnerabilityIds: idsSchema(400, 'vulnerability ID', true), +}) + +const initRtrSessionSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_init_rtr_session'), + deviceId: z.string().trim().min(1, 'deviceId is required'), + queueOffline: z.boolean().optional(), + origin: nonBlankQuerySchema('Origin'), +}) + +const executeRtrCommandSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_execute_rtr_command'), + sessionId: z.string().trim().min(1, 'sessionId is required'), + baseCommand: z.enum(RTR_READ_ONLY_BASE_COMMANDS, { + message: `baseCommand must be one of ${RTR_READ_ONLY_BASE_COMMANDS.join(', ')}`, + }), + commandString: z.string().trim().min(1, 'commandString is required'), +}) + +const getRtrCommandStatusSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_rtr_command_status'), + cloudRequestId: z.string().trim().min(1, 'cloudRequestId is required'), + sequenceId: z.number().int().nonnegative('sequenceId must be 0 or greater').optional(), +}) + +const deleteRtrSessionSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_delete_rtr_session'), + sessionId: z.string().trim().min(1, 'sessionId is required'), +}) + +const queryCasesSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_query_cases'), + filter: nonBlankQuerySchema('Filter'), + q: nonBlankQuerySchema('Query'), + limit: z + .number() + .int() + .min(1, 'Limit must be at least 1') + .max(10000, 'Limit must be at most 10000') + .optional(), + offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), + sort: nonBlankQuerySchema('Sort'), +}) + +const getCaseDetailsSchema = baseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_case_details'), + caseIds: idsSchema(1000, 'case ID'), +}) + export const crowdstrikeQueryBodySchema = z.discriminatedUnion('operation', [ querySensorsSchema, getSensorDetailsSchema, getSensorAggregatesSchema, + queryAlertsSchema, + getAlertDetailsSchema, + updateAlertsSchema, + performHostActionSchema, + queryHostGroupsSchema, + getHostGroupDetailsSchema, + performHostGroupActionSchema, + queryIndicatorsSchema, + getIndicatorDetailsSchema, + createIndicatorsSchema, + updateIndicatorsSchema, + deleteIndicatorsSchema, + queryVulnerabilitiesSchema, + getVulnerabilityDetailsSchema, + initRtrSessionSchema, + executeRtrCommandSchema, + getRtrCommandStatusSchema, + deleteRtrSessionSchema, + queryCasesSchema, + getCaseDetailsSchema, ]) export const crowdstrikeQueryContract = defineRouteContract({ @@ -162,7 +1028,27 @@ export const crowdstrikeQueryContract = defineRouteContract({ body: crowdstrikeQueryBodySchema, response: { mode: 'json', - schema: z.union([crowdstrikeSensorsResponseSchema, crowdstrikeAggregatesResponseSchema]), + schema: z.union([ + crowdstrikeSensorsResponseSchema, + crowdstrikeAggregatesResponseSchema, + crowdstrikeAlertIdsResponseSchema, + crowdstrikeAlertsResponseSchema, + crowdstrikeUpdatedAlertsResponseSchema, + crowdstrikeAffectedEntitiesResponseSchema, + crowdstrikeHostGroupIdsResponseSchema, + crowdstrikeHostGroupsResponseSchema, + crowdstrikeIndicatorIdsResponseSchema, + crowdstrikeIndicatorsResponseSchema, + crowdstrikeDeletedIndicatorsResponseSchema, + crowdstrikeVulnerabilityIdsResponseSchema, + crowdstrikeVulnerabilitiesResponseSchema, + crowdstrikeRtrSessionResponseSchema, + crowdstrikeRtrCommandResponseSchema, + crowdstrikeRtrCommandStatusResponseSchema, + crowdstrikeRtrDeleteSessionResponseSchema, + crowdstrikeCaseIdsResponseSchema, + crowdstrikeCasesResponseSchema, + ]), }, }) diff --git a/apps/sim/lib/api/contracts/tools/databases/index.ts b/apps/sim/lib/api/contracts/tools/databases/index.ts index 9dc20790da1..4d5eb0e40ac 100644 --- a/apps/sim/lib/api/contracts/tools/databases/index.ts +++ b/apps/sim/lib/api/contracts/tools/databases/index.ts @@ -1,4 +1,5 @@ export * from '@/lib/api/contracts/tools/databases/mongodb' +export * from '@/lib/api/contracts/tools/databases/mssql' export * from '@/lib/api/contracts/tools/databases/mysql' export * from '@/lib/api/contracts/tools/databases/neo4j' export * from '@/lib/api/contracts/tools/databases/postgresql' diff --git a/apps/sim/lib/api/contracts/tools/databases/mssql.ts b/apps/sim/lib/api/contracts/tools/databases/mssql.ts new file mode 100644 index 00000000000..a16af9f4a90 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/databases/mssql.ts @@ -0,0 +1,130 @@ +import { z } from 'zod' +import { + introspectionResponseSchema, + sqlInsertDataSchema, + sqlRowsResponseSchema, + sqlUpdateDataSchema, +} from '@/lib/api/contracts/tools/databases/shared' +import { + type ContractBodyInput, + type ContractJsonResponse, + defineRouteContract, +} from '@/lib/api/contracts/types' + +/** + * Toggle schema for the two TLS switches the `mssql` (Tedious) driver exposes. + * Modelled as an enum rather than a boolean because block dropdowns serialize + * strings, and `z.coerce.boolean()` would turn the string `'false'` into `true`. + * @see https://github.com/tediousjs/node-mssql#tedious + */ +export const mssqlToggleSchema = z.enum(['enabled', 'disabled']) + +/** + * Connection fields shared by every Microsoft SQL Server tool route. + * Field placement mirrors the driver: `connectionTimeout` and `requestTimeout` + * are top-level, while `encrypt` and `trustServerCertificate` live under + * `options`. Named instances are unsupported because the driver resolves them + * with an unpinnable SQL Server Browser lookup; use a static TCP port instead. + * @see https://github.com/tediousjs/node-mssql#general-same-for-all-drivers + */ +export const mssqlConnectionBodySchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce + .number() + .int() + .min(1, 'Port must be between 1 and 65535') + .max(65535, 'Port must be between 1 and 65535') + .default(1433), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + encrypt: mssqlToggleSchema.default('enabled'), + trustServerCertificate: mssqlToggleSchema.default('disabled'), + connectionTimeout: z.coerce + .number() + .int() + .min(1000, 'connectionTimeout must be at least 1000 ms') + .max(120000, 'connectionTimeout must be at most 120000 ms') + .default(15000), +}) + +export const mssqlQueryBodySchema = mssqlConnectionBodySchema.extend({ + query: z.string().min(1, 'Query is required'), +}) + +export const mssqlExecuteBodySchema = mssqlQueryBodySchema + +export const mssqlInsertBodySchema = mssqlConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + data: sqlInsertDataSchema, +}) + +export const mssqlUpdateBodySchema = mssqlConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + data: sqlUpdateDataSchema, + where: z.string().min(1, 'WHERE clause is required'), +}) + +export const mssqlDeleteBodySchema = mssqlConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export const mssqlIntrospectBodySchema = mssqlConnectionBodySchema.extend({ + schema: z.string().min(1, 'Schema name cannot be empty').default('dbo'), +}) + +export const mssqlQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/mssql/query', + body: mssqlQueryBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const mssqlExecuteContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/mssql/execute', + body: mssqlExecuteBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const mssqlInsertContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/mssql/insert', + body: mssqlInsertBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const mssqlUpdateContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/mssql/update', + body: mssqlUpdateBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const mssqlDeleteContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/mssql/delete', + body: mssqlDeleteBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const mssqlIntrospectContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/mssql/introspect', + body: mssqlIntrospectBodySchema, + response: { mode: 'json', schema: introspectionResponseSchema }, +}) + +export type MSSQLQueryRequest = ContractBodyInput +export type MSSQLQueryResponse = ContractJsonResponse +export type MSSQLExecuteRequest = ContractBodyInput +export type MSSQLExecuteResponse = ContractJsonResponse +export type MSSQLInsertRequest = ContractBodyInput +export type MSSQLInsertResponse = ContractJsonResponse +export type MSSQLUpdateRequest = ContractBodyInput +export type MSSQLUpdateResponse = ContractJsonResponse +export type MSSQLDeleteRequest = ContractBodyInput +export type MSSQLDeleteResponse = ContractJsonResponse +export type MSSQLIntrospectRequest = ContractBodyInput +export type MSSQLIntrospectResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/databases/shared.ts b/apps/sim/lib/api/contracts/tools/databases/shared.ts index de85eadd6a4..bf906e114ce 100644 --- a/apps/sim/lib/api/contracts/tools/databases/shared.ts +++ b/apps/sim/lib/api/contracts/tools/databases/shared.ts @@ -41,12 +41,12 @@ export const sqlQueryBodySchema = sqlConnectionBodySchema.extend({ query: z.string().min(1, 'Query is required'), }) -const sqlInsertDataSchema = z.union([ +export const sqlInsertDataSchema = z.union([ nonEmptyRecordSchema('Data object cannot be empty'), jsonObjectStringSchema('Invalid JSON format in data field', true), ]) -const sqlUpdateDataSchema = z.union([ +export const sqlUpdateDataSchema = z.union([ nonEmptyRecordSchema('Data object cannot be empty'), jsonObjectStringSchema('Invalid JSON format in data field'), ]) diff --git a/apps/sim/lib/api/contracts/v1/tables/index.test.ts b/apps/sim/lib/api/contracts/v1/tables/index.test.ts new file mode 100644 index 00000000000..78eaca5f422 --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/tables/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, +} from '@/lib/api/contracts/v1/tables' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' + +describe('v1 public table row contracts', () => { + it('never expose private secret provenance', () => { + for (const contract of [ + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, + ]) { + expect( + JSON.stringify(z.toJSONSchema(contract.body, { unrepresentable: 'any' })) + ).not.toContain(PRIVATE_SECRET_PROVENANCE_FIELD) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index 4491b8840be..aa4490889f7 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -18,6 +18,7 @@ import { upsertTableRowBodySchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import type { Filter, Sort } from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' @@ -61,7 +62,7 @@ export const v1CreateTableBodySchema = createTableBodySchema.omit({ * new rows at the tail; ordering by index is an in-app affordance only. */ export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema - .omit({ position: true }) + .omit({ position: true, [PRIVATE_SECRET_PROVENANCE_FIELD]: true }) .refine(...rowAnchorMutexRefine) /** @@ -83,6 +84,18 @@ export const v1CreateTableRowsBodySchema = z.union([ v1InsertTableRowBodySchema, ]) +export const v1UpdateRowsByFilterBodySchema = updateRowsByFilterBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpdateTableRowBodySchema = updateTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpsertTableRowBodySchema = upsertTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + export type V1ListTablesQuery = z.output export type V1TableRowsQuery = z.output export type V1InsertTableRowBody = z.output @@ -209,7 +222,7 @@ export const v1UpdateRowsByFilterContract = defineRouteContract({ method: 'PUT', path: '/api/v1/tables/[tableId]/rows', params: tableIdParamsSchema, - body: updateRowsByFilterBodySchema, + body: v1UpdateRowsByFilterBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -242,7 +255,7 @@ export const v1UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v1/tables/[tableId]/rows/[rowId]', params: tableRowParamsSchema, - body: updateTableRowBodySchema, + body: v1UpdateTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -264,7 +277,7 @@ export const v1UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v1/tables/[tableId]/rows/upsert', params: tableIdParamsSchema, - body: upsertTableRowBodySchema, + body: v1UpsertTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 5004b64c806..248c4069cc4 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -78,11 +78,14 @@ const PAGED_LISTS = [ * server reports. The MCP *server* list is not bounded that way — nothing caps * how many servers a workspace registers — which is why it is paged and does * not appear here. + * - The credential-provider catalog is bounded by the code-defined OAuth and + * service-account registries. * - A knowledge base has a fixed number of tag slots, so its tag vocabulary * cannot grow past them. * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ + 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 6694a626a3c..3f4c3b05bb0 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,14 +1,20 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, + v2DataResponse, v2PaginationFields, v2SearchSchema, v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { + getServiceAccountRequiredFields, + SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/service-account-fields' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' /** Public credentials are authenticated connections, never raw environment secrets. */ export const v2CredentialTypeSchema = z @@ -44,6 +50,114 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output +export const v2CredentialProviderAuthorizationOptionSchema = z + .object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z + .string() + .min(1, 'label cannot be empty') + .max(255, 'label must be at most 255 characters') + .describe('Human-readable authorization-server label.'), + }) + .strict() +export type V2CredentialProviderAuthorizationOption = z.output< + typeof v2CredentialProviderAuthorizationOptionSchema +> + +export const v2CredentialProviderFieldOptionSchema = z + .object({ + value: z.string().min(1).max(255).describe('Submitted option value.'), + label: z.string().min(1).max(255).describe('Human-readable option label.'), + }) + .strict() + +export const v2CredentialProviderFieldSchema = z + .object({ + id: z.string().min(1).max(255).describe('Exact create-body field name.'), + label: z.string().min(1).max(255).describe('Human-readable field label.'), + placeholder: z.string().min(1).max(1000).describe('Suggested input placeholder.'), + required: z.boolean().describe('Whether the field is required for the selected flow.'), + secret: z.boolean().describe('Whether the submitted field is write-only secret material.'), + multiline: z.boolean().describe('Whether the field is intended for multi-line input.'), + requiredForAuthMethods: z + .array(z.string().min(1).max(64)) + .min(1) + .max(10) + .optional() + .describe('Authentication methods for which this field is required.'), + options: z + .array(v2CredentialProviderFieldOptionSchema) + .min(1) + .max(20) + .optional() + .describe('Fixed values accepted by a selector field.'), + hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + }) + .strict() + +const v2CredentialProviderBaseShape = { + serviceId: z.string().min(1).max(255).describe('Stable credential-provider identifier.'), + name: z.string().min(1).max(255).describe('Credential provider display name.'), + description: z.string().min(1).max(1000).describe('Credential provider description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can connect the provider in the current deployment.'), +} as const + +export const v2OAuthCredentialProviderSchema = z + .object({ + type: z.literal('oauth').describe('Browser-based OAuth connection method.'), + ...v2CredentialProviderBaseShape, + supportsReconnect: z + .boolean() + .describe('Whether existing credentials for this service can be reconnected.'), + authorizationOptions: z + .array(v2CredentialProviderAuthorizationOptionSchema) + .min(1) + .max(10) + .describe('Authorization servers available for this OAuth service.'), + }) + .strict() + +export const v2ServiceAccountCredentialProviderSchema = z + .object({ + type: z.literal('service_account').describe('Direct service-account credential method.'), + ...v2CredentialProviderBaseShape, + providerId: z + .string() + .min(1) + .max(255) + .describe('Exact service-account provider ID accepted by credential creation.'), + docsUrl: z.string().url().describe('Setup guide for the provider.'), + helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + requiresClientGeneratedCredentialId: z + .boolean() + .describe('Whether the caller must generate and submit the credential ID before setup.'), + fields: z + .array(v2CredentialProviderFieldSchema) + .min(1) + .max(20) + .describe('Create-body fields accepted by this provider. Secret fields are write-only.'), + }) + .strict() + +export const v2CredentialProviderSchema = z + .discriminatedUnion('type', [ + v2OAuthCredentialProviderSchema, + v2ServiceAccountCredentialProviderSchema, + ]) + .meta({ + id: 'V2CredentialProvider', + title: 'Credential Provider', + description: 'An OAuth or service-account connection method available to a workspace.', + }) +export type V2CredentialProvider = z.output + /** A credential's natural name field is `displayName`, so that is what `search` matches. */ export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] @@ -66,11 +180,6 @@ export const v2ListCredentialsQuerySchema = z .strict() export type V2ListCredentialsQuery = z.output -/** - * Lists OAuth and service-account connections, keyset-paginated over the active - * sort. Credential mutations are intentionally absent. Nothing capped the - * per-workspace set before pagination, so the response grew without bound. - */ export const v2ListCredentialsContract = defineRouteContract({ method: 'GET', path: '/api/v2/credentials', @@ -80,3 +189,274 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) + +export const v2ListCredentialProvidersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace used to evaluate credential-provider availability and integration policy.' + ), + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the credential provider name.' + ), + }) + .strict() +export type V2ListCredentialProvidersQuery = z.output + +export const v2ListCredentialProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/providers', + query: v2ListCredentialProvidersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialProviderSchema, { paged: false }), + }, +}) + +const v2CreateCredentialConnectionByProviderSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider ID returned by credential-provider discovery.'), + displayName: z + .string({ error: 'displayName is required' }) + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .describe('Name shown for the new credential in Sim.'), + }) + .strict() + +const v2CreateCredentialConnectionByCredentialSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + credentialId: z + .string({ error: 'credentialId is required' }) + .trim() + .min(1, 'credentialId cannot be empty') + .max(255, 'credentialId must be at most 255 characters') + .describe('Existing OAuth credential to reconnect in place.'), + }) + .strict() + +export const v2CreateCredentialConnectionBodySchema = z.union([ + v2CreateCredentialConnectionByProviderSchema, + v2CreateCredentialConnectionByCredentialSchema, +]) +export type V2CreateCredentialConnectionBody = z.output< + typeof v2CreateCredentialConnectionBodySchema +> + +export const v2CredentialConnectionAuthorizationSchema = z + .object({ + authorizationUrl: z + .string() + .url('authorizationUrl must be an absolute URL') + .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'), + expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'), + }) + .meta({ + id: 'V2CredentialConnectionAuthorization', + title: 'Credential Connection Authorization', + description: 'A short-lived browser entrypoint for an OAuth connection flow.', + }) +export type V2CredentialConnectionAuthorization = z.output< + typeof v2CredentialConnectionAuthorizationSchema +> + +export const v2CreateCredentialConnectionResponseSchema = v2DataResponse( + v2CredentialConnectionAuthorizationSchema +) +export type V2CreateCredentialConnectionResponse = z.output< + typeof v2CreateCredentialConnectionResponseSchema +> + +export const v2CreateCredentialConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials/connections', + query: noInputSchema, + body: v2CreateCredentialConnectionBodySchema, + response: { + mode: 'json', + schema: v2CreateCredentialConnectionResponseSchema, + }, +}) + +const v2ServiceAccountSecretFieldsShape = { + serviceAccountJson: z + .string() + .min(1) + .max(65_536) + .optional() + .describe('Write-only Google service-account JSON key.') + .meta({ writeOnly: true }), + apiToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider API token.') + .meta({ writeOnly: true }), + domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + signingSecret: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only webhook signing secret.') + .meta({ writeOnly: true }), + botToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only bot token.') + .meta({ writeOnly: true }), + clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), + clientSecret: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe('Write-only OAuth client secret.') + .meta({ writeOnly: true }), + certificateId: z + .string() + .trim() + .min(1) + .max(512) + .optional() + .describe('Provider certificate mapping identifier.'), + orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), + dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), + authMethod: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe('Provider authentication method.'), + privateKey: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only PEM private key.') + .meta({ writeOnly: true }), + username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), +} as const + +export const v2CreateServiceAccountCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + type: z.literal('service_account').describe('Service-account credential discriminator.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact service-account provider ID returned by provider discovery.'), + displayName: z + .string() + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .optional() + .describe('Optional name; providers may derive one from the verified account identity.'), + description: z + .string() + .trim() + .max(500, 'description must be at most 500 characters') + .optional() + .describe('Optional credential description.'), + id: z + .string() + .uuid('id must be a valid UUID') + .optional() + .describe('Required only when provider discovery requests a client-generated ID.'), + ...v2ServiceAccountSecretFieldsShape, + }) + .strict() + .superRefine((body, ctx) => { + if (!Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, body.providerId)) { + ctx.addIssue({ + code: 'custom', + path: ['providerId'], + message: `Unknown service-account provider: ${body.providerId}`, + }) + return + } + if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) { + ctx.addIssue({ + code: 'custom', + path: ['id'], + message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, + }) + } + for (const field of getServiceAccountRequiredFields(body.providerId)) { + if (!body[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${body.providerId} credentials`, + }) + } + } + }) +export type V2CreateServiceAccountCredentialBody = z.input< + typeof v2CreateServiceAccountCredentialBodySchema +> + +export const v2CreateServiceAccountCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + query: noInputSchema, + body: v2CreateServiceAccountCredentialBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse(v2CredentialSchema), + }, +}) + +export const v2CredentialParamsSchema = z + .object({ + credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'), + }) + .strict() + +export const v2DeleteCredentialQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() + +export const v2CredentialDeleteDataSchema = z + .object({ + id: nonEmptyIdSchema.describe('Disconnected credential identifier.'), + deleted: z.literal(true).describe('Whether the credential was disconnected.'), + }) + .meta({ + id: 'V2CredentialDeleteData', + title: 'Delete credential data', + description: 'Credential disconnection acknowledgement.', + }) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + params: v2CredentialParamsSchema, + query: v2DeleteCredentialQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a87fa3f3294..23db1528093 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,4 +1,10 @@ -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, v2DeleteCustomToolContract, @@ -166,6 +172,63 @@ const CREDENTIAL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +const CREDENTIAL_PROVIDER_EXAMPLE = { + type: 'oauth', + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect to Salesforce CRM data and operations.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} as const + +const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Paste the account ID', + required: true, + secret: false, + multiline: false, + }, + ], +} as const + +const CREDENTIAL_CONNECTION_EXAMPLE = { + authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', + expiresAt: '2026-06-20T14:17:11.000Z', +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -784,7 +847,7 @@ const declaredRoutes = [ operationId: 'listCredentials', summary: 'List Credentials', description: - 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.', + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.', errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), @@ -804,6 +867,135 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListCredentialProvidersContract, + resourceOperation('Credentials', { + operationId: 'listCredentialProviders', + summary: 'List Credential Providers', + description: `List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'Credential provider catalog with caller-specific availability.' }, + }), + { + query: documentedSchema( + v2ListCredentialProvidersContract.query, + 'ListCredentialProvidersQuery', + 'List credential providers query', + 'Workspace and optional provider-name search used to filter caller-specific availability.' + ), + response: documentedSchema( + v2ListCredentialProvidersContract.response.schema, + 'ListCredentialProvidersResponse', + 'List credential providers response', + 'OAuth and service-account connection methods.', + [ + { + data: [CREDENTIAL_PROVIDER_EXAMPLE, SERVICE_ACCOUNT_PROVIDER_EXAMPLE], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2CreateServiceAccountCredentialContract, + resourceOperation('Credentials', { + operationId: 'createServiceAccountCredential', + summary: 'Create Service-Account Credential', + description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + byStatus: { + 200: { description: 'An existing credential matched the verified source.' }, + 201: { description: 'The service-account credential was created.' }, + }, + }, + }), + { + query: v2CreateServiceAccountCredentialContract.query, + body: documentedSchema( + v2CreateServiceAccountCredentialContract.body, + 'CreateServiceAccountCredentialRequest', + 'Create service-account credential request', + 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.', + [ + { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom automation', + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + orgId: 'YOUR_ACCOUNT_ID', + }, + ] + ), + response: documentedSchema( + v2CreateServiceAccountCredentialContract.response.schema, + 'CreateServiceAccountCredentialResponse', + 'Create service-account credential response', + 'Verified credential metadata without secret material.', + [{ data: CREDENTIAL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2CreateCredentialConnectionContract, + 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}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'A short-lived browser authorization URL.' }, + }), + { + query: v2CreateCredentialConnectionContract.query, + body: documentedSchema( + v2CreateCredentialConnectionContract.body, + 'CreateCredentialConnectionBody', + 'Create credential connection body', + 'For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.' + ), + response: documentedSchema( + v2CreateCredentialConnectionContract.response.schema, + 'CreateCredentialConnectionResponse', + 'Create credential connection response', + 'Short-lived Sim browser entrypoint and its expiry.', + [{ data: CREDENTIAL_CONNECTION_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2DeleteCredentialContract, + resourceOperation('Credentials', { + 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. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The credential was disconnected.' }, + }), + { + params: documentedSchema( + v2DeleteCredentialContract.params, + 'DeleteCredentialParams', + 'Disconnect credential path parameters', + 'Credential selected for disconnection.' + ), + query: documentedSchema( + v2DeleteCredentialContract.query, + 'DeleteCredentialQuery', + 'Disconnect credential query', + 'Workspace expected to own the credential.' + ), + response: documentedSchema( + v2DeleteCredentialContract.response.schema, + 'DeleteCredentialResponse', + 'Disconnect credential response', + 'Acknowledgement that the credential was disconnected.', + [{ data: { id: CREDENTIAL_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -953,7 +1145,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, { name: 'Credentials', - description: 'List OAuth and service-account connections without secret material.', + description: + 'Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 5b8ff796ddf..df65bdfb1c3 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -65,12 +65,12 @@ import { * - **`search`** ({@link v2SearchSchema}) — a case-insensitive substring match * against the resource's *single* natural name field, and nothing else: * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ - * skills, `title` for custom tools, `filename` for knowledge documents - * (`GET /knowledge/{id}/documents`), and `displayName` for both credentials - * and secrets (`GET /secrets`, where the secret's name *is* the credential - * `displayName`). It never matches ids, descriptions, or content. `%` and `_` in the term are matched - * literally, not as wildcards. Empty is rejected rather than silently - * ignored — omit the param instead. + * skills/credential providers, `title` for custom tools, `filename` for + * knowledge documents (`GET /knowledge/{id}/documents`), and `displayName` + * for both credentials and secrets (`GET /secrets`, where the secret's name + * *is* the credential `displayName`). It never matches ids, descriptions, or + * content. `%` and `_` in the term are matched literally, not as wildcards. + * Empty is rejected rather than silently ignored — omit the param instead. * - **`sortBy` + `sortOrder`** ({@link v2SortFields}) — `sortBy` is a * per-resource enum, never a free string, because the value selects a column * in the query. `sortOrder` is `asc`/`desc`. Both always have a default, so @@ -93,9 +93,12 @@ import { * * Every one of these is pushed into SQL, except on `GET /skills` (which narrows the * static builtin registry with the same search term, merges it into the DB rows, - * then re-sorts the merged array) and `GET /files/folders` (which applies `parentPath` and `search` - * in JS; its sort is pushed into SQL like every other folder list). Both read a - * full result set to produce a page; neither is a pattern to copy. + * then re-sorts the merged array), `GET /files/folders` (which applies + * `parentPath` and `search` in JS; its sort is pushed into SQL like every other + * folder list), and `GET /credentials/providers` (whose bounded catalog is + * assembled from code-defined registries before its caller-specific + * availability is projected). These read a full result set to produce a page; + * none is a pattern to copy. * * ## Which lists are paged * @@ -111,7 +114,8 @@ import { * load; `GET /knowledge/{id}/tags`, capped by the fixed tag-slot table; * `GET /mcp-servers/{id}/tools`, capped by tool discovery itself; and * `GET /tables/{tableId}/views` and `GET /tables/{tableId}/groups`, capped per - * table. + * table; and the credential-provider catalog, bounded by code-defined OAuth + * and service-account registries. * * Adding `limit`/`cursor` to a full-set list is additive, but giving it a * *default* `limit` truncates callers reading the whole set today, so once v2 is diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 53c0e054622..7f10e36b703 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -383,4 +383,62 @@ describe('defineInternalJsonRoute', () => { __privateMetadata: { value: 'ok' }, }) }) + + it('selects a declared success status from the application result', async () => { + const replayableContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + status: [200, 201], + }, + }) + const handler = defineInternalJsonRoute({ + contract: replayableContract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'created', created: true } + }, + }, + present: ({ value }) => ({ value }), + statusForResult: ({ created }) => (created ? 201 : 200), + }) + + const response = await handler( + new NextRequest('http://localhost/api/test/internal-json-route', { method: 'POST' }) + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ value: 'created' }) + }) + + it('fails closed when the application selects an undeclared success status', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + statusForResult: () => 201, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) + }) }) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index f9102d57217..8e362c35f66 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -230,6 +230,7 @@ type InternalJsonRouteOptions< params: Record }): void | Promise onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise + statusForResult?(result: NoInfer): number responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit finalizeResponse?(args: { request: NextRequest @@ -287,12 +288,6 @@ export function defineInternalJsonRoute< options.operation, options.useCase.operation ) - if (successStatuses.length !== 1) { - throw new Error( - `${options.contract.method} ${options.contract.path} internal JSON route requires one success status` - ) - } - const wrapped = withRouteHandler( async (request, context) => { if (!methodMatchesContract(request.method, options.contract.method)) { @@ -344,6 +339,12 @@ export function defineInternalJsonRoute< throw new Error('Internal JSON route response mode changed after initialization') } const validatedBody = responseSchema.schema.parse(body) as ContractJsonResponse + const responseStatus = options.statusForResult?.(result) ?? successStatus + if (!successStatuses.includes(responseStatus)) { + throw new Error( + `Internal JSON route produced undeclared success status ${responseStatus}; expected ${successStatuses.join(', ')}` + ) + } const headers = options.responseHeaders?.({ principal, input, result }) const finalization = options.finalizeResponse ? await options.finalizeResponse({ @@ -357,7 +358,7 @@ export function defineInternalJsonRoute< return NextResponse.json( appendFinalizedBodyFields(validatedBody, finalization?.bodyFields), { - status: successStatus, + status: responseStatus, headers: appendFinalizedHeaders(headers, finalization?.headers), } ) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index cdcbcd32903..b012ce3732b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' +import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, @@ -96,7 +96,11 @@ import { } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { + captureOAuthCredentialDraftBinding, + consumeOAuthCredentialDraftBinding, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -401,9 +405,22 @@ export const auth = betterAuth({ }, account: { create: { - before: async (account) => { + before: async (account, context) => { const modifiedAccount = { ...account } + if (context?.path.startsWith('/oauth2/callback/')) { + try { + await captureOAuthCredentialDraftBinding(context, () => getOAuthState()) + } catch (error) { + logger.error('[account.create.before] Failed to read OAuth credential draft state', { + userId: account.userId, + providerId: account.providerId, + error, + }) + throw error + } + } + if (account.accessToken && isSalesforceOAuthProviderId(account.providerId)) { const instanceUrl = await fetchSalesforceInstanceUrl( account.providerId, @@ -430,7 +447,7 @@ export const auth = betterAuth({ return { data: modifiedAccount } }, - after: async (account) => { + after: async (account, context) => { /** * Migrate credentials from stale account rows to the newly created one. * @@ -523,18 +540,33 @@ export const auth = betterAuth({ } } - try { - await processCredentialDraft({ - userId: account.userId, - providerId: account.providerId, - accountId: account.id, - }) - } catch (error) { - logger.error('[account.create.after] Failed to process credential draft', { - userId: account.userId, - providerId: account.providerId, - error, - }) + const isOAuth2Callback = context?.path.startsWith('/oauth2/callback/') === true + const credentialDraftBinding = context + ? consumeOAuthCredentialDraftBinding(context) + : undefined + + if (isOAuth2Callback && !credentialDraftBinding) { + throw new Error( + 'OAuth credential draft binding was not captured before account creation' + ) + } + + if (credentialDraftBinding) { + try { + await processCredentialDraft({ + draftId: credentialDraftBinding.draftId, + userId: account.userId, + providerId: account.providerId, + accountId: account.id, + }) + } catch (error) { + logger.error('[account.create.after] Failed to process credential draft', { + userId: account.userId, + providerId: account.providerId, + error, + }) + if (credentialDraftBinding.draftId) throw error + } } try { @@ -1151,14 +1183,24 @@ export const auth = betterAuth({ ? [ sso({ /** - * Honor the IdP's `email_verified` claim so the local account is - * verified rather than forced to false. + * MUST stay false. Better Auth's link gate is + * `!isTrustedProvider && !userInfo.emailVerified`, so a true + * `email_verified` claim substitutes for the domain binding + * entirely: an IdP could assert any address — including one from a + * domain it does not own — and auto-link into that user's existing + * account. Since a provider row can be registered by any Enterprise + * org admin (and by any signed-in user when self-hosted), trusting + * the claim makes every account reachable from any tenant's IdP. * - * This is not what enables linking — Entra omits the claim entirely, - * and SAML ignores it without an explicit `mapping.emailVerified`. - * `domainVerification` below establishes linking trust. + * Turning it on only ever set `emailVerified` on the local row; it + * was never what made linking work. Entra omits the claim, and SAML + * ignores it without an explicit `mapping.emailVerified` that the + * register contract does not accept — so SSO users are created + * unverified either way, and `domainVerification` below is the sole + * linking trust source, which is what `trustProviderByName: false` + * already assumes. */ - trustEmailVerified: true, + trustEmailVerified: false, /** * Marks a provider authoritative for its domain, which is what lets an * SSO sign-in auto-link to an existing same-email account. Without it @@ -1169,9 +1211,10 @@ export const auth = betterAuth({ * proven by the `sso_domain` flow before registration, and the register * route mirrors that decision onto this flag. * - * It narrows nothing on its own — an IdP asserting `email_verified` - * links regardless of domain (see `trustEmailVerified` above). It - * exists so linking survives IdPs that omit the claim. + * With `trustEmailVerified` off this is the only path to linking, and + * it is domain-scoped: `isTrustedProvider` additionally requires + * `validateEmailDomain(userInfo.email, provider.domain)`, so a + * provider can only ever claim identities inside the domain it proved. */ domainVerification: { enabled: true }, organizationProvisioning: { diff --git a/apps/sim/lib/auth/sso-trust.test.ts b/apps/sim/lib/auth/sso-trust.test.ts new file mode 100644 index 00000000000..1b620d4529c --- /dev/null +++ b/apps/sim/lib/auth/sso-trust.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + * + * Locks the SSO linking trust model. Better Auth's account-link gate is + * `!isTrustedProvider && !userInfo.emailVerified`, so a truthy + * `trustEmailVerified` lets any registered IdP assert an out-of-domain address + * as verified and auto-link into that user's account, bypassing the + * domain-verification proof entirely. + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, expect, it, vi } from 'vitest' + +const { ssoOptions } = vi.hoisted(() => ({ + ssoOptions: { current: undefined as Record | undefined }, +})) + +vi.mock('@better-auth/sso', () => ({ + sso: (options: Record) => { + ssoOptions.current = options + return { id: 'sso' } + }, +})) + +setEnvFlags({ isSsoEnabled: true }) + +afterAll(resetEnvFlagsMock) + +it('never trusts the IdP-supplied email_verified claim for SSO linking', async () => { + await import('@/lib/auth/auth') + + expect(ssoOptions.current).toBeDefined() + expect(ssoOptions.current?.trustEmailVerified).toBe(false) +}) + +it('keeps domain verification as the sole SSO linking trust source', async () => { + await import('@/lib/auth/auth') + + expect(ssoOptions.current?.domainVerification).toEqual({ enabled: true }) +}) diff --git a/apps/sim/lib/chunkers/constants.ts b/apps/sim/lib/chunkers/constants.ts new file mode 100644 index 00000000000..0e67a927407 --- /dev/null +++ b/apps/sim/lib/chunkers/constants.ts @@ -0,0 +1,17 @@ +/** + * Bounds on the separator list a recursive chunking config may carry. + * + * `RecursiveChunker` scans the whole document once per separator and walks the + * list from the top for every oversized fragment, so the separator count is a + * direct multiplier on synchronous CPU per document. The work happens inside a + * split loop, which neither the processing `Promise.race` timeout nor the + * after-the-fact chunk-count cap can interrupt — the list has to be bounded on + * the way in instead. + * + * The largest built-in recipe (`markdown`) uses 16 separators, so 32 leaves room + * for a hand-tuned list without letting one config stall the processing tier. + */ +export const MAX_CHUNKING_SEPARATORS = 32 + +/** Max characters in a single chunking separator. Real delimiters are a few characters. */ +export const MAX_CHUNKING_SEPARATOR_LENGTH = 100 diff --git a/apps/sim/lib/chunkers/recursive-chunker.test.ts b/apps/sim/lib/chunkers/recursive-chunker.test.ts index 345da36aaf3..441666ad5d3 100644 --- a/apps/sim/lib/chunkers/recursive-chunker.test.ts +++ b/apps/sim/lib/chunkers/recursive-chunker.test.ts @@ -3,7 +3,8 @@ */ import { describe, expect, it } from 'vitest' -import { RecursiveChunker } from './recursive-chunker' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' +import { RecursiveChunker } from '@/lib/chunkers/recursive-chunker' describe('RecursiveChunker', () => { describe('empty and whitespace input', () => { @@ -101,6 +102,63 @@ describe('RecursiveChunker', () => { }) }) + describe('separator bounds', () => { + it.concurrent('ignores separators past the list bound', async () => { + const separators = [ + ...Array.from({ length: MAX_CHUNKING_SEPARATORS }, (_, i) => `@@nomatch${i}@@`), + '---', + ] + const chunker = new RecursiveChunker({ chunkSize: 15, separators }) + const text = + 'Section one content here with words.---Section two content here with words.---Section three content here.' + + const chunks = await chunker.chunk(text) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.some((chunk) => chunk.text.includes('---'))).toBe(true) + }) + + it.concurrent('splits on a separator that survives the clamp', async () => { + const separators = [ + '---', + ...Array.from({ length: MAX_CHUNKING_SEPARATORS }, (_, i) => `@@nomatch${i}@@`), + ] + const chunker = new RecursiveChunker({ chunkSize: 15, separators }) + const text = + 'Section one content here with words.---Section two content here with words.---Section three content here.' + + const chunks = await chunker.chunk(text) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.every((chunk) => !chunk.text.includes('---'))).toBe(true) + }) + + it.concurrent('drops a separator longer than the per-item bound', async () => { + const oversized = '|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1) + const chunker = new RecursiveChunker({ chunkSize: 15, separators: [oversized, '---'] }) + const text = `Section one content here.${oversized}Section two content.---Section three content.` + + const chunks = await chunker.chunk(text) + + expect(chunks.some((chunk) => chunk.text.includes('|'))).toBe(true) + expect(chunks.every((chunk) => !chunk.text.includes('---'))).toBe(true) + }) + + it.concurrent('falls back to the recipe when every separator is over the bound', async () => { + const oversized = '|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1) + const text = + 'Section one content here with words.\n\nSection two content here with words.\n\nSection three content.' + + const chunks = await new RecursiveChunker({ chunkSize: 15, separators: [oversized] }).chunk( + text + ) + const defaultChunks = await new RecursiveChunker({ chunkSize: 15 }).chunk(text) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks).toEqual(defaultChunks) + }) + }) + describe('recipe: plain', () => { it.concurrent('should use plain recipe by default', async () => { const chunker = new RecursiveChunker({ chunkSize: 20 }) diff --git a/apps/sim/lib/chunkers/recursive-chunker.ts b/apps/sim/lib/chunkers/recursive-chunker.ts index 0dba2240987..c60933787a6 100644 --- a/apps/sim/lib/chunkers/recursive-chunker.ts +++ b/apps/sim/lib/chunkers/recursive-chunker.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants' import type { Chunk, RecursiveChunkerOptions } from '@/lib/chunkers/types' import { addOverlap, @@ -62,8 +63,29 @@ export class RecursiveChunker { this.chunkSize = resolved.chunkSize this.chunkOverlap = resolved.chunkOverlap - if (options.separators && options.separators.length > 0) { - this.separators = options.separators + /** + * Bounded here as well as at the API boundary: a config persisted before the + * boundary bound existed would otherwise still cost one full document scan + * per separator, synchronously, on every document it processes. + * + * An over-long separator is dropped rather than truncated — a truncated + * separator matches where the configured one never did, silently re-cutting + * the document, whereas dropping it behaves like a separator that finds no + * match, which the split already handles. + */ + const requested = options.separators ?? [] + const usable = requested + .filter((separator) => separator.length <= MAX_CHUNKING_SEPARATOR_LENGTH) + .slice(0, MAX_CHUNKING_SEPARATORS) + + if (usable.length < requested.length) { + logger.warn( + `Chunking config carries ${requested.length} separators; using ${usable.length} within the ${MAX_CHUNKING_SEPARATORS} × ${MAX_CHUNKING_SEPARATOR_LENGTH}-character bound` + ) + } + + if (usable.length > 0) { + this.separators = usable } else { const recipe = options.recipe ?? 'plain' this.separators = [...RECIPES[recipe]] @@ -77,21 +99,34 @@ export class RecursiveChunker { return text.trim() ? [text] : [] } - if (separatorIndex >= this.separators.length) { - const chunkSizeChars = tokensToChars(this.chunkSize) - return splitAtWordBoundaries(text, chunkSizeChars) - } + /** + * Advance past separators that do not split this text. Iterating rather + * than recursing keeps stack depth independent of the separator count. + */ + let index = separatorIndex + let separator = '' + let parts: string[] = [] - const separator = this.separators[separatorIndex] + while (index < this.separators.length) { + separator = this.separators[index] - if (separator === '') { - return this.splitRecursively(text, this.separators.length) - } + if (separator === '') { + index = this.separators.length + break + } - const parts = text.split(separator).filter((part) => part.trim()) + parts = text.split(separator).filter((part) => part.trim()) - if (parts.length <= 1) { - return this.splitRecursively(text, separatorIndex + 1) + if (parts.length > 1) { + break + } + + index++ + } + + if (index >= this.separators.length) { + const chunkSizeChars = tokensToChars(this.chunkSize) + return splitAtWordBoundaries(text, chunkSizeChars) } const chunks: string[] = [] @@ -108,7 +143,7 @@ export class RecursiveChunker { } if (estimateTokens(part) > this.chunkSize) { - const subChunks = this.splitRecursively(part, separatorIndex + 1) + const subChunks = this.splitRecursively(part, index + 1) for (const subChunk of subChunks) { chunks.push(subChunk) } diff --git a/apps/sim/lib/cli-auth/approval-store.test.ts b/apps/sim/lib/cli-auth/approval-store.test.ts index 47d3bd06e9b..b9edabfb582 100644 --- a/apps/sim/lib/cli-auth/approval-store.test.ts +++ b/apps/sim/lib/cli-auth/approval-store.test.ts @@ -46,15 +46,53 @@ describe('cli-auth approval store', () => { expect(JSON.parse(value)).toEqual({ challenge: CHALLENGE, userId: 'user-1', + scope: 'copilot', createdAt: expect.any(Number), }) expect([px, ttl]).toEqual(['PX', 120_000]) }) + + it('records the consented scope and workspace', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('omits the workspace fields entirely when no workspace was picked', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { scope: 'platform' }) + const record = JSON.parse(mockSet.mock.calls[0][1]) + expect(record).not.toHaveProperty('workspaceId') + expect(record).not.toHaveProperty('workspaceBound') + }) + + it('records a picked workspace as unbound unless binding was asked for', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) }) describe('pollApproval', () => { - const storedApproval = () => - JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + const storedApproval = (overrides: Record = {}) => + JSON.stringify({ + challenge: CHALLENGE, + userId: 'user-1', + scope: 'copilot', + createdAt: Date.now(), + ...overrides, + }) it('returns pending when no approval exists yet', async () => { mockGet.mockResolvedValue(null) @@ -68,6 +106,9 @@ describe('cli-auth approval store', () => { await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ status: 'approved', userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // NX lock on the mint key; TTL matches the approval so they expire together // and a failed cleanup can't leave a re-mintable window. Record not deleted here. @@ -77,6 +118,37 @@ describe('cli-auth approval store', () => { expect(mockDel).not.toHaveBeenCalled() }) + it('returns the recorded scope and workspace binding', async () => { + mockGet.mockResolvedValue( + storedApproval({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ + status: 'approved', + userId: 'user-1', + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('reports an unbound workspace pick as a default, not a key scope', async () => { + mockGet.mockResolvedValue(storedApproval({ scope: 'platform', workspaceId: 'ws-1' })) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('treats a record written before scopes existed as a copilot approval', async () => { + mockGet.mockResolvedValue( + JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ scope: 'copilot' }) + }) + it('does NOT touch the record when the secret is wrong', async () => { mockGet.mockResolvedValue(storedApproval()) await expect(pollApproval(REQUEST, 'c'.repeat(43))).resolves.toEqual({ status: 'pending' }) diff --git a/apps/sim/lib/cli-auth/approval-store.ts b/apps/sim/lib/cli-auth/approval-store.ts index 607f6b38b73..7b07fe8cd19 100644 --- a/apps/sim/lib/cli-auth/approval-store.ts +++ b/apps/sim/lib/cli-auth/approval-store.ts @@ -1,5 +1,6 @@ import { safeCompare } from '@sim/security/compare' import { sha256Base64Url, sha256Hex } from '@sim/security/hash' +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' import { getRedisClient } from '@/lib/core/config/redis' /** @@ -29,10 +30,29 @@ interface ApprovalRecord { challenge: string /** Always taken from the approving user's session, never from a request body. */ userId: string + /** + * Which key space to mint from, fixed at approval time. Recording it here + * rather than reading it from the poll body is what makes the browser consent + * binding: the poll carries only a secret, so it cannot widen what the user + * agreed to. Absent on records written before the field existed — those are + * copilot approvals. + */ + scope?: CliAuthScope + /** Platform scope only: the workspace the user picked, for the terminal's default. */ + workspaceId?: string + /** Whether to mint a key scoped to `workspaceId`. Admin-verified at approval. */ + workspaceBound?: boolean createdAt: number } -export type PollResult = { status: 'pending' } | { status: 'approved'; userId: string } +export interface ApprovalGrant { + userId: string + scope: CliAuthScope + workspaceId: string | null + workspaceBound: boolean +} + +export type PollResult = { status: 'pending' } | ({ status: 'approved' } & ApprovalGrant) function requireRedis() { const redis = getRedisClient() @@ -61,10 +81,21 @@ function mintLockKey(requestId: string): string { export async function createApproval( userId: string, requestId: string, - challenge: string + challenge: string, + grant: { scope: CliAuthScope; workspaceId?: string; workspaceBound?: boolean } = { + scope: 'copilot', + } ): Promise { const redis = requireRedis() - const record: ApprovalRecord = { challenge, userId, createdAt: Date.now() } + const record: ApprovalRecord = { + challenge, + userId, + scope: grant.scope, + ...(grant.workspaceId + ? { workspaceId: grant.workspaceId, workspaceBound: grant.workspaceBound === true } + : {}), + createdAt: Date.now(), + } await redis.set(approvalKey(requestId), JSON.stringify(record), 'PX', APPROVAL_TTL_MS) } @@ -97,7 +128,13 @@ export async function pollApproval(requestId: string, pollSecret: string): Promi const reserved = await redis.set(mintLockKey(requestId), '1', 'PX', MINT_LOCK_TTL_MS, 'NX') if (reserved !== 'OK') return { status: 'pending' } - return { status: 'approved', userId: record.userId } + return { + status: 'approved', + userId: record.userId, + scope: record.scope ?? 'copilot', + workspaceId: record.workspaceId ?? null, + workspaceBound: record.workspaceBound === true, + } } /** Consumes the approval after a successful mint — single-use from here on. */ diff --git a/apps/sim/lib/copilot/application/execute-credential-use-case.ts b/apps/sim/lib/copilot/application/execute-credential-use-case.ts new file mode 100644 index 00000000000..cbebe99402a --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-credential-use-case.ts @@ -0,0 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({ + domain: 'credential', + delegation: { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: credentialOperations, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts index e7ea463273b..9b9ebb41da5 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts @@ -8,6 +8,7 @@ const { mocks, useCases } = vi.hoisted(() => ({ custom: vi.fn(), mcp: vi.fn(), skill: vi.fn(), + credential: vi.fn(), capture: vi.fn(), }, useCases: { @@ -23,6 +24,8 @@ const { mocks, useCases } = vi.hoisted(() => ({ deleteSkill: { operation: { id: 'skills.delete' } }, listSkill: { operation: { id: 'skills.list_available' } }, updateSkill: { operation: { id: 'skills.update' } }, + updateCredential: { operation: { id: 'credentials.update' } }, + deleteManyCredentials: { operation: { id: 'credentials.delete_many' } }, }, })) @@ -35,6 +38,9 @@ vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ vi.mock('@/lib/copilot/application/execute-skill-use-case', () => ({ executeCopilotSkillUseCase: mocks.skill, })) +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.credential, +})) vi.mock('@/lib/custom-tools/application/use-cases', () => ({ deleteAvailableCustomToolUseCase: useCases.deleteCustom, listAvailableCustomToolsUseCase: useCases.listCustom, @@ -53,9 +59,16 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ listAvailableSkillsUseCase: useCases.listSkill, updateSkillUseCase: useCases.updateSkill, })) +vi.mock('@/lib/credentials/application/credential-crud', () => ({ + updateWorkspaceCredentialUseCase: useCases.updateCredential, +})) +vi.mock('@/lib/credentials/application/delete-many-credentials', () => ({ + deleteManyCredentialsUseCase: useCases.deleteManyCredentials, +})) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeManageCredential } from '@/lib/copilot/tools/handlers/management/manage-credential' import { executeManageCustomTool } from '@/lib/copilot/tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '@/lib/copilot/tools/handlers/management/manage-mcp-tool' import { executeManageSkill } from '@/lib/copilot/tools/handlers/management/manage-skill' @@ -157,4 +170,43 @@ describe('Copilot management application boundaries', () => { } ) }) + + it('renames credentials through the shared credential use case', async () => { + mocks.credential.mockResolvedValue({ + credential: { id: 'credential-1', displayName: 'Renamed' }, + previousDisplayName: 'Original', + }) + + const result = await executeManageCredential( + { operation: 'rename', credentialId: 'credential-1', displayName: 'Renamed' }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { previousDisplayName: 'Original', displayName: 'Renamed' }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.updateCredential, { + credentialId: 'credential-1', + displayName: 'Renamed', + }) + }) + + it('keeps best-effort batch deletion inside one semantic application command', async () => { + mocks.credential.mockResolvedValue({ deleted: ['credential-1'], failed: ['credential-2'] }) + + const result = await executeManageCredential( + { operation: 'delete', credentialIds: ['credential-1', 'credential-2'] }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { deleted: ['credential-1'], failed: ['credential-2'] }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.deleteManyCredentials, { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index fb307feb7a4..07dc5d0bc44 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -1,84 +1,82 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/credential-crud' +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' -export function executeManageCredential( +export async function executeManageCredential( rawParams: Record, context: ExecutionContext ): Promise { - const params = rawParams as { - operation: string - credentialId?: string - credentialIds?: string[] - displayName?: string + const operation = typeof rawParams.operation === 'string' ? rawParams.operation : '' + const credentialId = + typeof rawParams.credentialId === 'string' ? rawParams.credentialId : undefined + const displayName = typeof rawParams.displayName === 'string' ? rawParams.displayName : undefined + const rawCredentialIds = rawParams.credentialIds + if ( + rawCredentialIds !== undefined && + (!Array.isArray(rawCredentialIds) || rawCredentialIds.some((id) => typeof id !== 'string')) + ) { + return { success: false, error: 'credentialIds must be an array of strings' } } - const { operation, displayName } = params - return (async () => { - try { - if (!context?.userId) { - return { success: false, error: 'Authentication required' } - } - - switch (operation) { - case 'rename': { - const credentialId = params.credentialId - if (!credentialId) return { success: false, error: 'credentialId is required for rename' } - if (!displayName) return { success: false, error: 'displayName is required for rename' } + const credentialIds = rawCredentialIds as string[] | undefined + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const result = await performUpdateCredential({ + try { + switch (operation) { + case 'rename': { + if (!credentialId) { + return { success: false, error: 'credentialId is required for rename' } + } + if (!displayName) { + return { success: false, error: 'displayName is required for rename' } + } + const result = await executeCopilotCredentialUseCase( + context, + updateWorkspaceCredentialUseCase, + { credentialId, - userId: context.userId, displayName, - allowedTypes: ['oauth'], - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename credential' } - } - return { - success: true, - output: { - credentialId, - previousDisplayName: result.previousDisplayName, - displayName, - }, } + ) + return { + success: true, + output: { + credentialId: result.credential.id, + previousDisplayName: result.previousDisplayName, + displayName: result.credential.displayName, + }, } - case 'delete': { - const ids: string[] = - params.credentialIds ?? (params.credentialId ? [params.credentialId] : []) - if (ids.length === 0) - return { success: false, error: 'credentialId or credentialIds is required for delete' } - - const deleted: string[] = [] - const failed: string[] = [] - - for (const id of ids) { - const result = await performDeleteCredential({ - credentialId: id, - userId: context.userId, - allowedTypes: ['oauth'], - reason: 'copilot_delete', - }) - if (!result.success) { - failed.push(id) - continue - } - deleted.push(id) - } - - return { - success: deleted.length > 0, - output: { deleted, failed }, - } - } - default: + } + case 'delete': { + const ids = credentialIds ?? (credentialId ? [credentialId] : []) + if (ids.length === 0) { return { success: false, - error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + error: 'credentialId or credentialIds is required for delete', } + } + const result = await executeCopilotCredentialUseCase( + context, + deleteManyCredentialsUseCase, + { workspaceId, credentialIds: ids } + ) + return { + success: result.deleted.length > 0, + output: { deleted: result.deleted, failed: result.failed }, + } } - } catch (error) { - return { success: false, error: toError(error).message } + default: + return { + success: false, + error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + } } - })() + } catch (error) { + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to manage credential'), + } + } } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 77ff5a9922d..f3926a3ed11 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -2,324 +2,114 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { - mockEnsureWorkspaceAccess, - mockGetCredentialActorContext, - mockIsOAuthServiceDeploymentAvailable, - mockGetUserPermissionConfig, -} = vi.hoisted(() => ({ - mockEnsureWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockIsOAuthServiceDeploymentAvailable: vi.fn(() => true), - mockGetUserPermissionConfig: vi.fn(), +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getBaseUrl: vi.fn(), })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +const useCases = vi.hoisted(() => ({ + prepare: { operation: { id: 'credentials.connections.prepare' } }, })) -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.execute, })) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - getAllowedIntegrationsFromEnv: vi.fn(() => null), -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [ - { serviceId: 'gmail', providerId: 'google-email', name: 'Gmail', authType: 'oauth' }, - { serviceId: 'slack', providerId: 'slack', name: 'Slack', authType: 'oauth' }, - { serviceId: 'trello', providerId: 'trello', name: 'Trello', authType: 'oauth' }, - { serviceId: 'shopify', providerId: 'shopify', name: 'Shopify', authType: 'oauth' }, - { - serviceId: 'claude-platform', - providerId: 'claude-platform', - name: 'Claude Platform', - authType: 'service_account', - }, - ]), +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl })) +vi.mock('@/lib/credentials/application/prepare-credential-connection', () => ({ + prepareCredentialConnection: useCases.prepare, })) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' -const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' - -const context = { - workspaceId: WORKSPACE_ID, - userId: USER_ID, +const context: ExecutionContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', chatId: 'chat-1', -} as unknown as ExecutionContext - -const WORKSPACE_ACCESS = { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, -} - -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } + toolCallId: 'call-1', + copilotToolExecution: true, + userPermission: 'write', } describe('executeOAuthGetAuthLink', () => { beforeEach(() => { vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) - }) - - describe('connect (no credentialId)', () => { - it('returns an authorize URL without a credentialId param', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(true) - const url = new URL((result.output as { oauth_url: string }).oauth_url) - expect(url.pathname).toBe('/api/auth/oauth2/authorize') - expect(url.searchParams.get('providerId')).toBe('google-email') - expect(url.searchParams.get('credentialId')).toBeNull() - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - - it('rejects a provider whose OAuth client is not configured', async () => { - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(false) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not configured for this deployment') - }) - - it('rejects a provider disallowed for the workspace member', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not allowed for this workspace member') - }) - - it('does not treat service-account-only metadata as OAuth', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'Claude Platform' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') + mocks.getBaseUrl.mockReturnValue('https://sim.test') + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', }) }) - describe('reconnect (credentialId passed)', () => { - it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) + it('uses the credential application adapter for a new connection', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'gmail' }, context) - expect(result.success).toBe(true) - const output = result.output as { oauth_url: string; message: string } - const url = new URL(output.oauth_url) - expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) - expect(output.message).toContain('Reconnect') - expect(output.message).toContain(CREDENTIAL_ID) + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledWith(context, useCases.prepare, { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: undefined, }) + const url = new URL((result.output as { oauth_url: string }).oauth_url) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('providerId')).toBe('google-email') + expect(url.searchParams.get('workspaceId')).toBe('workspace-1') + expect(url.searchParams.has('credentialId')).toBe(false) + }) - it('reuses the already-resolved workspace access for the credential lookup', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { - workspaceAccess: WORKSPACE_ACCESS, - }) - }) - - it('fails with an agent-visible error for a nonexistent credential', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, - }) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: 'cred-hallucinated' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential belongs to another workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential is not an OAuth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not an OAuth credential') - }) - - it('fails naming the actual provider when providerName does not match the credential', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'slack', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('google-email') + it('preserves the canonical credential ID for reconnect', async () => { + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', + credentialId: 'credential-1', }) - it('fails when the caller is not a credential admin', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Admin access') - }) + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail', credentialId: 'credential-1' }, + context + ) - it('rejects reconnect for Trello and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'trello', credentialId: CREDENTIAL_ID }, - context - ) + const output = result.output as { oauth_url: string; message: string } + expect(new URL(output.oauth_url).searchParams.get('credentialId')).toBe('credential-1') + expect(output.message).toContain('re-authorizes credential credential-1 in place') + }) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + it('returns application validation errors without exposing infrastructure failures', async () => { + mocks.execute.mockRejectedValue(new OrchestrationError('not_found', 'Provider not found')) - it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'shopify', credentialId: CREDENTIAL_ID }, - context - ) + const result = await executeOAuthGetAuthLink({ providerName: 'missing' }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + expect(result.success).toBe(false) + expect(result.error).toBe('Provider not found') }) -}) -describe('executeOAuthGetAuthLink service account rejection', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) + it('fails fast without trusted workspace context', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail' }, + { ...context, workspaceId: undefined } + ) + + expect(result).toEqual({ success: false, error: 'workspaceId is required' }) + expect(mocks.execute).not.toHaveBeenCalled() }) - /** - * Regression: a user asked for a "new custom bot", the agent correctly - * resolved that to `slack-custom-bot` and passed it here, and the fuzzy - * substring pass matched it to the Slack OAuth service — `slack-custom-bot` - * contains `slack`. The tool returned a personal-OAuth authorize URL and - * reported success, so the user connected their own account instead of a - * shared bot. Failing loudly is the point: a wrong link that looks right is - * worse than an error the agent can recover from. - */ - it('rejects a service account id with a coherent recovery message, not a workspace link', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context) + it('rejects service-account providers before OAuth resolution', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack custom bot' }, context) expect(result.success).toBe(false) - expect(result.error).toContain('service account') - expect(result.error).toContain('service_account credential tag') - const output = result.output as { setup_url?: string; oauth_url?: string; message: string } - // The rejection must not fall into the generic catch, which would attach a - // contradicting workspace oauth_url and a "connect manually" message — the - // agent would then surface a workspace link instead of the tag. - expect(output.setup_url).toBeUndefined() - expect(output.oauth_url).toBeUndefined() - expect(output.message).toContain('service_account credential tag') - expect(output.message).not.toContain('Connect manually') + expect(result.error).toContain('service account, not an OAuth provider') + expect(mocks.execute).not.toHaveBeenCalled() }) - it.each([ - 'notion-service-account', - 'salesforce-service-account', - 'google-service-account', - 'atlassian-service-account', - 'SLACK-CUSTOM-BOT', - // Readable forms must be normalized (spaces/underscores → hyphens) so they - // are caught too, not passed to the fuzzy OAuth resolver. - 'slack custom bot', - 'google service account', - 'notion_service_account', - ])('rejects %s', async (providerName) => { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('service_account credential tag') - }) + it('does not confuse integrations that also offer service accounts', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack' }, context) - it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { - // `slack` and `notion` must keep working — the guard keys off the id being - // a service-account id, not off the integration having a service-account flow. - for (const providerName of ['slack', 'google-email']) { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(true) - expect((result.output as { oauth_url: string }).oauth_url).toContain( - '/api/auth/oauth2/authorize' - ) - } + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 5549efacd10..eb24cb86ab6 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,16 +1,9 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' -import { isOAuthServiceAllowedByIntegrationTypes } from '@/lib/integrations/availability' -import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' -import { getAllOAuthServices } from '@/lib/oauth/utils' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export async function executeOAuthGetAuthLink( rawParams: Record, @@ -38,33 +31,26 @@ export async function executeOAuthGetAuthLink( `value instead (e.g. "slack") — it opens the service account setup form in chat.` return { success: false, error: message, output: { message } } } + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } try { - if (!context.workspaceId || !context.userId) { - throw new Error('workspaceId and userId are required to generate an OAuth link') - } - const workspaceAccess = await ensureWorkspaceAccess( - context.workspaceId, - context.userId, - 'write' - ) - const permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId) - const configuredAllowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const allowedIntegrationTypes = configuredAllowedIntegrations - ? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase())) - : null - const result = await generateOAuthLink( - context.workspaceId, - context.workflowId, - context.chatId, + const result = await executeCopilotCredentialUseCase(context, prepareCredentialConnection, { + workspaceId, providerName, - baseUrl, - allowedIntegrationTypes, - credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined - ) + credentialId, + }) + const callbackURL = context.workflowId + ? `${baseUrl}/workspace/${workspaceId}/w/${context.workflowId}` + : context.chatId + ? `${baseUrl}/workspace/${workspaceId}/chat/${context.chatId}` + : `${baseUrl}/workspace/${workspaceId}` + const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) + authorizeUrl.searchParams.set('providerId', result.providerId) + authorizeUrl.searchParams.set('workspaceId', workspaceId) + authorizeUrl.searchParams.set('callbackURL', callbackURL) + if (result.credentialId) authorizeUrl.searchParams.set('credentialId', result.credentialId) + const action = credentialId ? 'reconnect' : 'connect' return { success: true, @@ -72,23 +58,24 @@ export async function executeOAuthGetAuthLink( message: credentialId ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` : `Authorization URL generated for ${result.serviceName}.`, - oauth_url: result.url, - instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, + oauth_url: authorizeUrl.toString(), + instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${authorizeUrl.toString()}`, provider: result.serviceName, providerId: result.providerId, }, } } catch (err) { + const message = messageForCopilotApplicationError(err) const workspaceUrl = context.workspaceId ? `${baseUrl}/workspace/${context.workspaceId}` : `${baseUrl}/workspace` return { success: false, - error: toError(err).message, + error: message, output: { message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`, oauth_url: workspaceUrl, - error: toError(err).message, + error: message, }, } } @@ -109,140 +96,3 @@ export async function executeOAuthRequestAccess( }, } } - -/** - * Resolves a human-friendly provider name to a providerId and returns a - * browser-initiated authorize URL the user opens to connect the service. - * - * Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL. - * That endpoint (not this server-side handler) creates the credential draft and - * calls Better Auth, so the draft's TTL starts at click and the signed `state` - * cookie is planted in the user's browser and the OAuth callback's state check - * passes. - * - * When `reconnect` is set, the URL carries the existing credential id so the - * authorize endpoint creates a reconnect draft and the OAuth callback rebinds - * the credential in place instead of creating a new one. Validation happens - * here too (not just at click time) so a bad id fails in the tool result where - * the agent can see it, rather than as a silent browser redirect. - */ -async function generateOAuthLink( - workspaceId: string | undefined, - workflowId: string | undefined, - chatId: string | undefined, - providerName: string, - baseUrl: string, - allowedIntegrationTypes: ReadonlySet | null, - reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } -): Promise<{ url: string; providerId: string; serviceName: string }> { - if (!workspaceId) { - throw new Error('workspaceId is required to generate an OAuth link') - } - - const allServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') - const normalizedInput = providerName.toLowerCase().trim() - - const matched = - allServices.find((s) => s.providerId === normalizedInput) || - allServices.find((s) => s.name.toLowerCase() === normalizedInput) || - allServices.find( - (s) => - s.name.toLowerCase().includes(normalizedInput) || - normalizedInput.includes(s.name.toLowerCase()) - ) || - allServices.find( - (s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId) - ) - - if (!matched) { - const available = allServices.map((s) => s.name).join(', ') - throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`) - } - - const { providerId, name: serviceName } = matched - if (!isOAuthServiceAllowedByIntegrationTypes(matched.serviceId, allowedIntegrationTypes)) { - throw new Error(`${serviceName} is not allowed for this workspace member`) - } - if (!isOAuthServiceDeploymentAvailable(providerId)) { - throw new Error(`${serviceName} OAuth is not configured for this deployment`) - } - - if (reconnect) { - if (providerId === 'trello' || providerId === 'shopify') { - throw new Error( - `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + - `integrations page and press Reconnect on the credential there.` - ) - } - const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { - workspaceAccess: reconnect.workspaceAccess, - }) - if (!actor.credential || actor.credential.workspaceId !== workspaceId) { - throw new Error( - `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + - `environment/credentials.json for valid credential ids.` - ) - } - if (actor.credential.type !== 'oauth') { - throw new Error( - `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` - ) - } - if (actor.credential.providerId !== providerId) { - throw new Error( - `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + - `not "${providerId}". Pass the matching providerName.` - ) - } - if (!actor.isAdmin) { - throw new Error('Admin access on the credential is required to reconnect it.') - } - } - - const callbackURL = - workflowId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` - : chatId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}` - : `${baseUrl}/workspace/${workspaceId}` - - if (providerId === 'trello') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/trello/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'instagram') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/instagram/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'shopify') { - const returnUrl = encodeURIComponent(callbackURL) - return { - url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`, - providerId, - serviceName, - } - } - - // Hand back a browser-initiated authorize URL rather than calling - // oAuth2LinkAccount here. Generating the link server-side would set Better - // Auth's signed `state` cookie on this server-to-server response instead of the - // user's browser, so the OAuth callback would fail with `state_mismatch`. The - // authorize endpoint runs the link inside the user's browser, planting the - // cookie correctly while keeping the callback's state check enabled. - // - // The pending credential draft is created by that authorize endpoint at click - // time (not here), so the draft's TTL starts when the user actually initiates - // the connect and reliably outlives the OAuth round-trip. - const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) - authorizeUrl.searchParams.set('providerId', providerId) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - authorizeUrl.searchParams.set('callbackURL', callbackURL) - if (reconnect) { - authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) - } - - return { url: authorizeUrl.toString(), providerId, serviceName } -} diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 90115a49ce0..1695c5aa3e1 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -20,7 +20,6 @@ import { import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' -import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/application/batch-policy' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { createKnowledgeConnector, @@ -46,7 +45,10 @@ import { readKnowledgeTagUsage, updateKnowledgeTag, } from '@/lib/knowledge/application/tags' -import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, + MAX_KNOWLEDGE_BATCH_ITEMS, +} from '@/lib/knowledge/constants' import { captureServerEvent } from '@/lib/posthog/server' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index aa2f4e5e494..067ad94ff10 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -181,6 +181,65 @@ describe('defineAuthorizedWorkspaceUseCase', () => { expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess']) }) + it('runs resource authorization after workspace authorization and before business effects', async () => { + mocks.resolvePermission.mockImplementation(async () => { + mocks.events.push('workspaceAuthorization') + return 'write' + }) + const execute = vi.fn(async () => { + mocks.events.push('execute') + return { ok: true as const } + }) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => { + mocks.events.push('canonicalLoad') + return canonicalContext + }, + authorizationOptions: {}, + authorizeResource() { + mocks.events.push('resourceAuthorization') + }, + execute, + projectAudit: () => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + }), + afterSuccess() { + mocks.events.push('afterSuccess') + }, + }) + + await useCase.authorize?.({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + ]) + expect(execute).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + + mocks.events.length = 0 + await expect( + useCase.execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + ).resolves.toEqual({ ok: true }) + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + 'execute', + 'audit', + 'afterSuccess', + ]) + }) + it('supports zero or many semantic audit entries', async () => { const buildUseCase = (auditCount: number) => defineAuthorizedWorkspaceUseCase({ diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index a3286535edb..0d37830b458 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -56,6 +56,8 @@ export interface AuthorizedWorkspaceUseCaseDefinition< | (( args: AuthorizedWorkspaceUseCaseContext ) => WorkspaceAuthorizationOptions | Promise>) + /** Applies current domain-resource policy after workspace authorization. */ + authorizeResource?(args: AuthorizedWorkspaceUseCaseContext): void | Promise execute(args: AuthorizedWorkspaceUseCaseContext): Promise projectAudit?( args: AuthorizedWorkspaceUseCaseResultContext @@ -111,7 +113,8 @@ export function defineAuthorizedWorkspaceUseCase< >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { /** * Everything that runs before the business transaction: allowed-principal - * check, canonical load, asserted-scope comparison, current access check. + * check, canonical load, asserted-scope comparison, current workspace and + * resource access checks. * * `execute` and `authorize` share it rather than each spelling it out, so a * `HEAD` probe cannot answer a different question from the `GET` it stands @@ -145,6 +148,7 @@ export function defineAuthorizedWorkspaceUseCase< context, authorizationOptions ) + await definition.authorizeResource?.(executionContext) return executionContext } diff --git a/apps/sim/lib/core/application/batch-policy.ts b/apps/sim/lib/core/application/batch-policy.ts new file mode 100644 index 00000000000..dc1eab9d420 --- /dev/null +++ b/apps/sim/lib/core/application/batch-policy.ts @@ -0,0 +1,61 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** The failure that ended a `sequential_best_effort` batch early. */ +export interface BatchTerminalFailure { + error: unknown +} + +export interface BatchExecutionResult { + terminalFailure?: BatchTerminalFailure +} + +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export function rethrowBatchTerminalFailure(result: BatchExecutionResult): void { + if (result.terminalFailure) throw result.terminalFailure.error +} + +/** A deduplicated, bounded mixed selection of resources and folders. */ +export interface BoundedResourceSelection { + resourceIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed resource/folder selection before any + * protected row is loaded. + * + * The cap is on the **combined** count, not on each list: a request naming 100 + * resources and 100 folders costs twice what the ceiling is meant to allow, and + * a folder costs more than a resource because it cascades. + * + * Returns neutral key names; each domain renames them so its own selection type + * stays self-describing. + */ +export function requireBoundedResourceSelection( + resourceIds: readonly string[], + folderIds: readonly string[], + maxItems: number, + noun: { singular: string; plural: string } +): BoundedResourceSelection { + const selection = { + resourceIds: [...new Set(resourceIds)], + folderIds: [...new Set(folderIds)], + } + const total = selection.resourceIds.length + selection.folderIds.length + if (total === 0) { + throw new OrchestrationError( + 'validation', + `At least one ${noun.singular} or folder is required` + ) + } + if (total > maxItems) { + throw new OrchestrationError( + 'validation', + `Too many items (${total}). Maximum is ${maxItems} ${noun.plural} and folders combined.` + ) + } + return selection +} diff --git a/apps/sim/lib/core/application/bulk-items.test.ts b/apps/sim/lib/core/application/bulk-items.test.ts new file mode 100644 index 00000000000..60338c9aa6d --- /dev/null +++ b/apps/sim/lib/core/application/bulk-items.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +describe('classifyBulkItemError', () => { + /** + * The invariant this function exists to hold: an id the caller may not reach + * and an id that does not exist must be indistinguishable in the response, + * or a bulk request becomes a membership oracle for a workspace the caller + * can read but not write to. + */ + it.each(['not_found', 'forbidden', 'unauthorized'] as const)( + 'conceals %s as notFound, carrying no message', + (code) => { + const disposition = classifyBulkItemError( + new OrchestrationError(code, `secret detail for ${code}`) + ) + + expect(disposition).toEqual({ kind: 'notFound' }) + } + ) + + it('reports any other classified code as an actionable per-item failure', () => { + expect( + classifyBulkItemError(new OrchestrationError('validation', 'Bad target folder')) + ).toEqual({ kind: 'failed', reason: 'Bad target folder' }) + expect(classifyBulkItemError(new OrchestrationError('conflict', 'Name taken'))).toEqual({ + kind: 'failed', + reason: 'Name taken', + }) + }) + + it('ends the batch on an internal or unclassified error', () => { + const internal = new OrchestrationError('internal', 'connection reset') + expect(classifyBulkItemError(internal)).toEqual({ kind: 'terminal', error: internal }) + + const unclassified = new Error('socket hang up') + expect(classifyBulkItemError(unclassified)).toEqual({ + kind: 'terminal', + error: unclassified, + }) + }) + + it('lets a domain verdict rule on an error the shared classification cannot see', () => { + class DomainLockError extends Error {} + const verdict = (error: unknown): BulkItemDisposition | undefined => + error instanceof DomainLockError ? { kind: 'failed', reason: error.message } : undefined + + expect(classifyBulkItemError(new DomainLockError('Table is locked'), verdict)).toEqual({ + kind: 'failed', + reason: 'Table is locked', + }) + // Without the verdict the same error is an unclassified fault that ends the batch. + expect(classifyBulkItemError(new DomainLockError('Table is locked'))).toMatchObject({ + kind: 'terminal', + }) + }) + + /** + * A verdict must not be able to reopen the probe the concealment closes: the + * concealed codes are decided before the hook could widen them. + */ + it('keeps concealment ahead of a domain verdict that would widen it', () => { + const leakyVerdict = (error: unknown): BulkItemDisposition => ({ + kind: 'failed', + reason: `leaked: ${(error as Error).message}`, + }) + + for (const code of ['not_found', 'forbidden', 'unauthorized'] as const) { + expect( + classifyBulkItemError(new OrchestrationError(code, 'no write access'), leakyVerdict) + ).toEqual({ kind: 'notFound' }) + } + }) +}) diff --git a/apps/sim/lib/core/application/bulk-items.ts b/apps/sim/lib/core/application/bulk-items.ts new file mode 100644 index 00000000000..65c10ec58cf --- /dev/null +++ b/apps/sim/lib/core/application/bulk-items.ts @@ -0,0 +1,58 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' + +/** + * Outcome of one item in a best-effort batch. + * + * `not_found`, `forbidden`, and `unauthorized` collapse into `notFound` so a + * caller cannot use a bulk request to probe which identifiers exist in a + * workspace it can see but may not write to — the same concealment the + * single-item operations apply. Anything the orchestration layer did not + * classify (or classified `internal`) ends the batch: it is an infrastructure + * failure, not a per-item verdict. + */ +export type BulkItemDisposition = + | { kind: 'notFound' } + | { kind: 'failed'; reason: string } + | { kind: 'terminal'; error: unknown } + +/** + * A domain's chance to rule on an error the shared classification cannot see. + * Return `undefined` to defer to the shared rules. + * + * Only for errors that are genuinely a per-item verdict and carry no + * orchestration code — a table's delete lock is the motivating case. A hook + * must never widen `notFound` into a distinguishable outcome, or it reopens the + * probe the concealment closes. + */ +export type BulkItemVerdict = (error: unknown) => BulkItemDisposition | undefined + +/** + * Classifies one item's error in a best-effort batch. One implementation for + * every domain, so the concealment rule above cannot drift between them. + */ +export function classifyBulkItemError( + error: unknown, + verdict?: BulkItemVerdict +): BulkItemDisposition { + const classified = asOrchestrationError(error) + /** + * Concealment is decided BEFORE the domain hook runs, so no hook can widen a + * concealed code back into a distinguishable outcome. A hook exists for + * errors the orchestration layer never classified at all. + */ + if ( + classified?.code === 'not_found' || + classified?.code === 'forbidden' || + classified?.code === 'unauthorized' + ) { + return { kind: 'notFound' } + } + + const domainVerdict = verdict?.(error) + if (domainVerdict) return domainVerdict + + if (classified && classified.code !== 'internal') { + return { kind: 'failed', reason: classified.message } + } + return { kind: 'terminal', error } +} diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 5ac8b5c55cc..2197f839434 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -48,6 +48,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'WORKSPACE_RESOURCE_LIMIT_REACHED', /** The workspace's organization does not permit public sharing. */ 'PUBLIC_SHARING_NOT_ALLOWED', + /** The caller can reach the workspace but cannot administer this credential. */ + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -83,6 +85,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record { /** * Runs everything {@link execute} does up to and including resource * authorization, then stops — allowed-principal check, canonical load, - * asserted-scope comparison, current access check — but not the business - * transaction, the audit projection, or the after-success effects. + * asserted-scope comparison, current workspace access check, resource access + * check — but not the business transaction, the audit projection, or the + * after-success effects. * * It exists for one caller: a surface that must answer *"would this principal * be allowed?"* without causing what the answer would cause. `HEAD` on a route diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 133c013a3eb..b5f2e735d5c 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -284,7 +284,7 @@ const SQL_WHERE_RAW_PATTERNS: readonly RegExp[] = [ * scans do not treat data inside quotes as SQL. Comments are intentionally left * intact so comment-injection sequences are still detected. */ -function maskSqlStringLiterals(sql: string): string { +export function maskSqlStringLiterals(sql: string): string { let out = '' let i = 0 while (i < sql.length) { diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts index 418ee854c6b..cfb5a479f99 100644 --- a/apps/sim/lib/credential-groups/application/list-people.ts +++ b/apps/sim/lib/credential-groups/application/list-people.ts @@ -69,7 +69,11 @@ export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ } catch (error) { if (error instanceof CredentialGroupEnrollmentError) { throw new OrchestrationError( - error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal', + error.status === 400 || error.status === 404 + ? 'validation' + : error.status === 409 + ? 'conflict' + : 'internal', error.message ) } diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts index 10c044b9634..24bc1ba580a 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts @@ -21,15 +21,15 @@ vi.mock('@/lib/credential-groups/enrollments', () => ({ CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { constructor( message: string, - readonly status: 404 | 409 | 502 + readonly status: 400 | 404 | 409 | 502 ) { super(message) } }, + deleteCredentialGroupEnrollment: vi.fn(), inviteCredentialGroupEnrollments: mocks.invite, loadCredentialGroupInviterIdentity: mocks.loadInviter, resendCredentialGroupEnrollment: vi.fn(), - revokeCredentialGroupEnrollment: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.ts index 8896aa495c3..b4220f4a18c 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.ts @@ -9,10 +9,10 @@ import { credentialGroupOperations } from '@/lib/credential-groups/application/o import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation' import { CredentialGroupEnrollmentError, + deleteCredentialGroupEnrollment, inviteCredentialGroupEnrollments, loadCredentialGroupInviterIdentity, resendCredentialGroupEnrollment, - revokeCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' interface CredentialGroupEnrollmentSettingsInput { @@ -109,20 +109,20 @@ export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace }), }) -export interface RevokeCredentialGroupEnrollmentSettingsInput +export interface DeleteCredentialGroupEnrollmentSettingsInput extends CredentialGroupEnrollmentSettingsInput { enrollmentId: string } -export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ - operation: credentialGroupOperations.revokeEnrollment, - resolveContext: ({ input }: { input: RevokeCredentialGroupEnrollmentSettingsInput }) => +export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.deleteEnrollment, + resolveContext: ({ input }: { input: DeleteCredentialGroupEnrollmentSettingsInput }) => resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), authorizationOptions: {}, async execute({ input, context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) try { - const credentialGroupEnrollment = await revokeCredentialGroupEnrollment( + const credentialGroupEnrollment = await deleteCredentialGroupEnrollment( context.workspaceId, context.credentialGroupId, input.enrollmentId @@ -137,7 +137,7 @@ export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace resourceType: AuditResourceType.CREDENTIAL_GROUP, resourceId: context.credentialGroupId, resourceName: context.name, - description: `Revoked Credential Group access for ${result.credentialGroupEnrollment.email}`, + description: `Deleted ${result.credentialGroupEnrollment.email} from the Credential Group`, metadata: { enrollmentId: result.credentialGroupEnrollment.id }, }), }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index 3a889b91aa0..fbc3a0d3a08 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -6,7 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ create: vi.fn(), + get: vi.fn(), list: vi.fn(), + listEnrollments: vi.fn(), requireAvailable: vi.fn(), resolveGroup: vi.fn(), resolvePermission: vi.fn(), @@ -22,11 +24,23 @@ vi.mock('@/lib/credential-groups/application/context', () => ({ vi.mock('@/lib/credential-groups/service', () => ({ createCredentialGroup: mocks.create, deleteCredentialGroup: vi.fn(), - getCredentialGroup: vi.fn(), + getCredentialGroup: mocks.get, listCredentialGroups: mocks.list, updateCredentialGroup: vi.fn(), })) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + } + }, + listCredentialGroupEnrollments: mocks.listEnrollments, +})) + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (permission: string | null, required: string) => permission === 'admin' || permission === required, @@ -35,6 +49,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ import { createCredentialGroupSettings, + getCredentialGroupSettings, listCredentialGroupSettings, } from '@/lib/credential-groups/application/manage-groups' @@ -62,10 +77,17 @@ describe('Credential Group Settings application operations', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolveGroup.mockResolvedValue({ + ...workspaceContext, + credentialGroupId: 'group-1', + name: 'Support', + }) mocks.resolvePermission.mockResolvedValue('admin') mocks.requireAvailable.mockResolvedValue(undefined) mocks.list.mockResolvedValue([]) mocks.create.mockResolvedValue({ id: 'group-1', name: 'Support' }) + mocks.get.mockResolvedValue({ id: 'group-1', name: 'Support' }) + mocks.listEnrollments.mockResolvedValue({ enrollments: [], nextCursor: null }) }) it('rejects an enrollment bearer before loading workspace settings', async () => { @@ -115,4 +137,19 @@ describe('Credential Group Settings application operations', () => { options: [], }) }) + + it('excludes deleted people from Credential Group settings', async () => { + await getCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { + assertedWorkspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + }, + }) + + expect(mocks.listEnrollments).toHaveBeenCalledWith('workspace-1', 'group-1', 50, undefined, { + statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'], + }) + }) }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index f8e9f420bf2..f3fbd3e6761 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -108,7 +108,8 @@ export const getCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ context.workspaceId, context.credentialGroupId, input.limit, - input.cursor + input.cursor, + { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'] } ) return { credentialGroup, ...enrollmentPage } } catch (error) { diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index ecaa65a92fe..fdd1b524246 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -43,8 +43,8 @@ export const credentialGroupOperations = { workspaceApiKey: 'deny', principalKinds: ['session'], }), - revokeEnrollment: defineWorkspaceOperation({ - id: 'credential_groups.enrollments.revoke', + deleteEnrollment: defineWorkspaceOperation({ + id: 'credential_groups.enrollments.delete', minimumRole: 'admin', workspaceApiKey: 'deny', principalKinds: ['session'], diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 4a826736f2e..b7696e2ee69 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { adapter } = vi.hoisted(() => ({ @@ -31,6 +32,7 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ import { completeCredentialGroupEnrollment, + deleteCredentialGroupEnrollment, listCredentialGroupEnrollments, resendCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' @@ -132,6 +134,52 @@ describe('listCredentialGroupEnrollments', () => { ) expect(dbChainMockFns.select).not.toHaveBeenCalled() }) + + it('continues pagination when the cursor enrollment was deleted between pages', async () => { + const remainingEnrollment = { + ...ENROLLMENT, + id: 'enrollment-2', + invitedAt: new Date('2026-08-10T12:00:00.000Z'), + } + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }, { enrollment: remainingEnrollment }]) + .mockResolvedValueOnce([{ options: [] }]) + .mockResolvedValueOnce([{ enrollment: remainingEnrollment }]) + + const firstPage = await listCredentialGroupEnrollments('workspace-1', 'group-1', 1, undefined, { + statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'], + }) + if (!firstPage.nextCursor) throw new Error('Expected a next enrollment cursor') + const result = await listCredentialGroupEnrollments( + 'workspace-1', + 'group-1', + 50, + firstPage.nextCursor, + { statuses: ['invited', 'in_progress', 'completed', 'delivery_failed'] } + ) + + expect(firstPage.nextCursor).toEqual(expect.any(String)) + expect(result.enrollments).toHaveLength(1) + expect(result.enrollments[0]?.id).toBe(remainingEnrollment.id) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(4) + expect(inArray).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.status, [ + 'invited', + 'in_progress', + 'completed', + 'delivery_failed', + ]) + }) + + it('rejects a malformed enrollment cursor', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ options: [] }]) + + await expect( + listCredentialGroupEnrollments('workspace-1', 'group-1', 50, 'not-a-cursor') + ).rejects.toMatchObject({ message: 'Enrollment cursor is invalid', status: 400 }) + + expect(dbChainMockFns.limit).toHaveBeenCalledOnce() + }) }) describe('resendCredentialGroupEnrollment', () => { @@ -202,18 +250,29 @@ describe('resendCredentialGroupEnrollment', () => { }) }) +describe('deleteCredentialGroupEnrollment', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('deletes the enrollment and lets its foreign-key cascade remove managed credentials', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ email: ENROLLMENT.email }]) + dbChainMockFns.returning.mockResolvedValueOnce([ENROLLMENT]) + + const result = await deleteCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id) + + expect(result.id).toBe(ENROLLMENT.id) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledOnce() + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) + }) +}) + describe('completeCredentialGroupEnrollment', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - adapter.getPolicy.mockResolvedValue({ - provider: 'gmail', - providerId: 'google-email', - authorizationAppId: 'google:client', - requiredScopes: ['scope'], - scopeVersion: 1, - }) - adapter.hasRequiredScopes.mockReturnValue(true) }) it('returns unavailable when revocation wins before completion acquires the lifecycle lock', async () => { @@ -238,18 +297,6 @@ describe('completeCredentialGroupEnrollment', () => { inviterName: 'Inviter', }, ]) - queueTableRows(schemaMock.credential, [ - { - optionId: 'option-1', - status: 'active', - scopeVersion: 1, - authorizationAppId: 'google:client', - grantedScopes: ['scope'], - displayName: 'alex@example.com', - metadata: { email: 'alex@example.com' }, - grantedAt: new Date('2026-08-11T12:05:00.000Z'), - }, - ]) queueTableRows(schemaMock.credentialGroupEnrollment, [ { status: 'revoked', @@ -264,10 +311,10 @@ describe('completeCredentialGroupEnrollment', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() }) - it('refuses completion when a connection needs reauthorization under the row locks', async () => { + it('completes when the recipient skips every optional account', async () => { queueTableRows(schemaMock.credentialGroupEnrollment, [ { - enrollment: { ...ENROLLMENT, status: 'in_progress' }, + enrollment: { ...ENROLLMENT, status: 'invited' }, groupId: 'group-1', groupName: 'Group', groupStatus: 'active', @@ -288,7 +335,7 @@ describe('completeCredentialGroupEnrollment', () => { ]) queueTableRows(schemaMock.credentialGroupEnrollment, [ { - status: 'in_progress', + status: 'invited', invitationTokenHash: ENROLLMENT.invitationTokenHash, invitationExpiresAt: ENROLLMENT.invitationExpiresAt, }, @@ -307,20 +354,12 @@ describe('completeCredentialGroupEnrollment', () => { ], }, ]) - queueTableRows(schemaMock.credential, [ - { - optionId: 'option-1', - status: 'needs_reauth', - scopeVersion: 1, - authorizationAppId: 'google:client', - grantedScopes: ['scope'], - grantedAt: new Date('2026-08-11T12:05:00.000Z'), - }, - ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: ENROLLMENT.id }]) - await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(false) + await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(true) - expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.credential) expect(adapter.getPolicy).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index aa3a8b24d9f..c3403131c01 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -126,13 +126,52 @@ async function lockCredentialGroupInvitationTarget( export class CredentialGroupEnrollmentError extends Error { constructor( message: string, - readonly status: 404 | 409 | 502 + readonly status: 400 | 404 | 409 | 502 ) { super(message) this.name = 'CredentialGroupEnrollmentError' } } +interface CredentialGroupEnrollmentCursor { + id: string + invitedAt: Date +} + +function encodeCredentialGroupEnrollmentCursor( + enrollment: Pick +): string { + return Buffer.from( + JSON.stringify({ id: enrollment.id, invitedAt: enrollment.invitedAt.toISOString() }) + ).toString('base64url') +} + +function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupEnrollmentCursor { + try { + const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { + throw new Error('Cursor payload must be an object') + } + const { id, invitedAt: invitedAtValue } = decoded as Record + if ( + typeof id !== 'string' || + !id.trim() || + id !== id.trim() || + id.length > 128 || + typeof invitedAtValue !== 'string' + ) { + throw new Error('Cursor payload is malformed') + } + const invitedAt = new Date(invitedAtValue) + if (Number.isNaN(invitedAt.getTime()) || invitedAt.toISOString() !== invitedAtValue) { + throw new Error('Cursor timestamp is invalid') + } + return { id, invitedAt } + } catch { + throw new CredentialGroupEnrollmentError('Enrollment cursor is invalid', 400) + } +} + function hashInvitationToken(token: string): string { return sha256Hex(token) } @@ -443,30 +482,7 @@ export async function listCredentialGroupEnrollments( .filter((option) => option.status === 'active') .map((option) => option.id) - let cursorPosition: { id: string; invitedAt: Date } | undefined - if (cursor) { - const [cursorRow] = await db - .select({ id: credentialGroupEnrollment.id, invitedAt: credentialGroupEnrollment.invitedAt }) - .from(credentialGroupEnrollment) - .innerJoin( - credentialGroup, - eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) - ) - .where( - and( - eq(credentialGroupEnrollment.id, cursor), - eq(credentialGroup.id, groupId), - eq(credentialGroup.workspaceId, workspaceId), - filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined, - filters.statuses?.length - ? inArray(credentialGroupEnrollment.status, filters.statuses) - : undefined - ) - ) - .limit(1) - if (!cursorRow) throw new CredentialGroupEnrollmentError('Enrollment cursor not found', 404) - cursorPosition = cursorRow - } + const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined const rows = await db .select({ enrollment: credentialGroupEnrollment }) @@ -538,12 +554,18 @@ export async function listCredentialGroupEnrollments( if (current) current.push(summary) else connectionsByEnrollment.set(connection.enrollmentId, [summary]) } + const nextCursorEnrollment = hasNextPage ? pageRows.at(-1)?.enrollment : undefined + if (hasNextPage && !nextCursorEnrollment) { + throw new Error('Credential group enrollment page is missing its cursor boundary') + } return { enrollments: pageRows.map(({ enrollment }) => ({ ...toCredentialGroupEnrollment(enrollment), connections: connectionsByEnrollment.get(enrollment.id) ?? [], })), - nextCursor: hasNextPage ? (pageRows.at(-1)?.enrollment.id ?? null) : null, + nextCursor: nextCursorEnrollment + ? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment) + : null, } } @@ -637,7 +659,7 @@ export async function resendCredentialGroupEnrollment( }) } -export async function revokeCredentialGroupEnrollment( +export async function deleteCredentialGroupEnrollment( workspaceId: string, groupId: string, enrollmentId: string @@ -659,10 +681,8 @@ export async function revokeCredentialGroupEnrollment( return db.transaction(async (tx) => { await lockCredentialGroupInvitationTarget(tx, groupId, existing.email) await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId) - const now = new Date() - const [revoked] = await tx - .update(credentialGroupEnrollment) - .set({ status: 'revoked', revokedAt: now, updatedAt: now }) + const [deleted] = await tx + .delete(credentialGroupEnrollment) .where( and( eq(credentialGroupEnrollment.id, enrollmentId), @@ -670,18 +690,8 @@ export async function revokeCredentialGroupEnrollment( ) ) .returning() - if (!revoked) throw new Error('Credential group enrollment update returned no row') - - await tx - .update(credential) - .set({ managedOauthStatus: 'revoked', revokedAt: now, updatedAt: now }) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, enrollmentId) - ) - ) - return toCredentialGroupEnrollment(revoked) + if (!deleted) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + return toCredentialGroupEnrollment(deleted) }) } @@ -780,8 +790,8 @@ async function buildPublicCredentialGroupEnrollment( } } -/** Finalizes an enrollment only after every active credential option has one usable connection. */ -export async function completeCredentialGroupEnrollment(token: string): Promise { +/** Finalizes an enrollment after the recipient finishes their optional account selections. */ +export async function completeCredentialGroupEnrollment(token: string): Promise { const row = await resolvePublicEnrollmentRowByIdentity({ invitationTokenHash: hashInvitationToken(token), }) @@ -791,7 +801,7 @@ export async function completeCredentialGroupEnrollment(token: string): Promise< export async function completeAuthorizedCredentialGroupEnrollment( identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { const row = await resolveAuthorizedPublicEnrollmentRow(identity) if (!row) return null return completeResolvedCredentialGroupEnrollment(row, identity) @@ -800,7 +810,7 @@ export async function completeAuthorizedCredentialGroupEnrollment( async function completeResolvedCredentialGroupEnrollment( row: NonNullable>>, identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { return db.transaction(async (tx) => { await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id) const now = new Date() @@ -826,7 +836,6 @@ async function completeResolvedCredentialGroupEnrollment( const [group] = await tx .select({ status: credentialGroup.status, - options: credentialGroup.options, }) .from(credentialGroup) .where( @@ -839,52 +848,6 @@ async function completeResolvedCredentialGroupEnrollment( .for('update') if (!group || group.status !== 'active') return null - const activeOptions = group.options.filter((option) => option.status === 'active') - if (activeOptions.length === 0) return false - const connections = await tx - .select({ - optionId: credential.credentialGroupOptionId, - status: credential.managedOauthStatus, - scopeVersion: credential.managedOauthScopeVersion, - authorizationAppId: credential.authorizationAppId, - grantedScopes: credential.grantedScopes, - grantedAt: credential.grantedAt, - }) - .from(credential) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, row.enrollment.id) - ) - ) - .for('update') - - for (const option of activeOptions) { - if (!isCredentialGroupProvider(option.provider)) { - throw new Error(`Unsupported Credential Group provider: ${option.provider}`) - } - const matchingConnections = connections.filter( - (connection) => connection.optionId === option.id - ) - if (matchingConnections.length !== 1) return false - const [connection] = matchingConnections - if (!connection || connection.status !== 'active' || !connection.grantedAt) return false - - const adapter = getCredentialGroupProviderAdapter(option.provider) - const policy = await adapter.getPolicy(option, { - workspaceId: identity.workspaceId, - credentialGroupId: identity.credentialGroupId, - executor: tx, - }) - if ( - connection.authorizationAppId !== policy.authorizationAppId || - connection.scopeVersion !== policy.scopeVersion || - !adapter.hasRequiredScopes(connection.grantedScopes ?? [], policy.requiredScopes) - ) { - return false - } - } - const [completed] = await tx .update(credentialGroupEnrollment) .set({ status: 'completed', completedAt: now, updatedAt: now }) diff --git a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts index 9e6ec9990ce..3358297be25 100644 --- a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts +++ b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts @@ -27,3 +27,9 @@ export const SLACK_MANAGED_USER_SCOPES = [ 'users:read', 'users:read.email', ] as const + +export const SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH = + '/api/credential-groups/slack-managed-users/callback' + +export const SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH = + '/api/credential-groups/oauth/slack/callback' diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index 3d7368d6f3c..8814d4f97f3 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -14,7 +14,10 @@ import { decryptCredentialGroupProviderConfiguration, encryptCredentialGroupProviderConfiguration, } from '@/lib/credential-groups/provider-configuration' -import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' import type { DbOrTx } from '@/lib/db/types' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' @@ -410,7 +413,7 @@ export async function exchangeSlackUserAuthorization(params: { } export function getSlackManagedUsersRedirectUri(): string { - return `${getBaseUrl()}/api/credential-groups/slack-managed-users/callback` + return `${getBaseUrl()}${SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH}` } export async function createSlackManagedUsersAttempt(params: { @@ -668,7 +671,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { authorizationAppId, requiredScopes: [...SLACK_MANAGED_USER_SCOPES], scopeVersion, - required: existingOption?.required ?? true, + required: false, status: existingOption?.status ?? ('active' as const), } const options = existingOption diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts index d6633c862b7..4ee972dce31 100644 --- a/apps/sim/lib/credential-groups/slack-provider.ts +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -11,7 +11,10 @@ import { } from '@/lib/credential-groups/provider-adapter' import { getSlackCredentialGroupConfiguration } from '@/lib/credential-groups/provider-configuration' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' -import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, + SLACK_MANAGED_USER_SCOPES, +} from '@/lib/credential-groups/slack-managed-user-scopes' import { exchangeSlackUserAuthorization, getSlackCustomBotCredential, @@ -128,7 +131,7 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter 409 ) } - const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + const redirectUri = `${getBaseUrl()}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}` return { redirectUri, buildAuthorizationUrl: ({ state }) => { @@ -148,7 +151,7 @@ export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter credentialGroupId: context.credentialGroupId, slackBotCredentialId: context.option.slackBotCredentialId, }) - const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + const redirectUri = `${getBaseUrl()}${SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH}` if ( currentPolicy.authorizationAppId !== policy.authorizationAppId || attempt.redirectUri !== redirectUri diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 917e87d666a..2fc567d9205 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -15,7 +15,7 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) -import { clearCredentialRefs } from '@/lib/credentials/deletion' +import { clearCredentialRefs, deleteConnectionCredential } from '@/lib/credentials/deletion' describe('credential-bound webhook deactivation', () => { beforeEach(() => { @@ -39,3 +39,41 @@ describe('credential-bound webhook deactivation', () => { expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack') }) }) + +describe('deleteConnectionCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('deletes exactly one credential within its canonical workspace scope', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + const deleted = await deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + + expect(deleted).toBe(true) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') + }) + + it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 9a149357a55..6f7bc25515e 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -12,6 +12,15 @@ type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect export type CredentialType = (typeof credentialTypeEnum.enumValues)[number] +export type OrdinaryCredentialType = Exclude + +/** Narrows credentials exposed through ordinary user-managed credential surfaces. */ +export function requireOrdinaryCredentialType(type: CredentialType): OrdinaryCredentialType { + if (type === 'managed_oauth') { + throw new Error('Managed OAuth credential reached an ordinary credential surface') + } + return type +} /** * Credential types shared at the workspace level — every type except a user's diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts new file mode 100644 index 00000000000..717766dec51 --- /dev/null +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -0,0 +1,63 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server/validation' +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialAccessRequiredError } from '@/lib/credentials/application/authorized-credential-use-case' +import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' + +export const credentialValidationParseOptions = { + validationErrorResponse: (error: Parameters[0]) => + validationErrorResponse(error, getValidationErrorMessage(error)), +} as const + +export const internalCredentialErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (!(error instanceof CredentialProviderOperationError)) return null + return internalErrorResponse(error.providerUnavailable ? 502 : 400, { + error: error.message, + code: error.providerErrorCode, + }) + } +) + +export const internalCredentialDetailErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => + error instanceof CredentialAccessRequiredError + ? internalErrorResponse(403, { error: 'Forbidden' }) + : null +) + +export const internalCredentialMemberListErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(404, { error: 'Not found' }) + } + return null + } +) + +export const internalCredentialMemberMutationErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED') || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(403, { error: 'Admin access required' }) + } + return null + } +) diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts index 7038b7f2eb3..fdcca435bb6 100644 --- a/apps/sim/lib/credentials/application/authorization.ts +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -2,8 +2,18 @@ import type { Principal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' +export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' +export const credentialDelegationPolicy = { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + isWithinScope: () => true, +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> + export const managedOAuthCredentialDelegationPolicy = { audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, isWithinScope: ( diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts new file mode 100644 index 00000000000..b0a1939edfd --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { + CredentialAccessRequiredError, + defineAuthorizedCredentialUseCase, +} from '@/lib/credentials/application/authorized-credential-use-case' +import { defineCredentialOperation } from '@/lib/credentials/application/operations' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + getActor: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) + +const memberOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_member', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' +) +const adminOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' +) +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + credential, +} + +function createUseCase(operation: typeof memberOperation | typeof adminOperation) { + return defineAuthorizedCredentialUseCase({ + operation, + resolveContext: async () => ({ ...context }), + execute: mocks.execute, + }) +} + +describe('defineAuthorizedCredentialUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.execute.mockResolvedValue({ ok: true }) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + }) + + it('allows an active credential member for member-level reads', async () => { + await expect( + createUseCase(memberOperation).execute({ principal, input: undefined }) + ).resolves.toEqual({ ok: true }) + expect(mocks.execute).toHaveBeenCalledOnce() + }) + + it('denies member-level reads without credential membership', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: null, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + createUseCase(memberOperation).execute({ principal, input: undefined }) + ).rejects.toBeInstanceOf(CredentialAccessRequiredError) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('requires credential admin independently of workspace read access', async () => { + await expect( + createUseCase(adminOperation).execute({ principal, input: undefined }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authorizes the workspace before resolving credential membership', async () => { + await createUseCase(memberOperation).execute({ principal, input: undefined }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts new file mode 100644 index 00000000000..57e6d109993 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -0,0 +1,89 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + ForbiddenOperationError, + type WorkspaceAuthorizationContext, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialActorContext } from '@/lib/credentials/access' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import type { CredentialOperation } from '@/lib/credentials/application/operations' +import type { CredentialRow } from '@/lib/credentials/queries' + +export interface CredentialAuthorizationContext extends WorkspaceAuthorizationContext { + credential: CredentialRow + credentialAccess?: CredentialActorContext +} + +export class CredentialAccessRequiredError extends OrchestrationError { + constructor() { + super('forbidden', 'Credential access required') + this.name = 'CredentialAccessRequiredError' + } +} + +export function requireCredentialAccess( + context: CredentialAuthorizationContext +): CredentialActorContext { + if (!context.credentialAccess) { + throw new Error('Credential use case executed without resource authorization') + } + return context.credentialAccess +} + +type AuthorizedCredentialUseCaseDefinition< + O extends CredentialOperation, + I, + C extends CredentialAuthorizationContext, + R, +> = Omit< + AuthorizedWorkspaceUseCaseDefinition, + 'authorizationOptions' | 'authorizeResource' +> + +export function defineAuthorizedCredentialUseCase< + const O extends CredentialOperation, + I, + C extends CredentialAuthorizationContext, + R, +>(definition: AuthorizedCredentialUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async authorizeResource({ principal, context }) { + const actor = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if ( + !actor.credential || + actor.credential.workspaceId !== context.workspaceId || + !actor.hasWorkspaceAccess + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } + context.credentialAccess = actor + switch (definition.operation.minimumCredentialRole) { + case 'member': + if (!actor.member && !actor.isAdmin) { + throw new CredentialAccessRequiredError() + } + return + case 'admin': + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + } + return + default: + throw new Error( + `Unsupported credential role: ${definition.operation.minimumCredentialRole}` + ) + } + }, + }) +} diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts new file mode 100644 index 00000000000..95fd6e08712 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -0,0 +1,102 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import { resolvePrincipalAuditAttribution, type SessionPrincipal } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialUserOperation } from '@/lib/credentials/application/operations' + +export interface CredentialUserAuditEntry { + workspaceId: string | null + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +interface CredentialUserUseCaseDefinition { + operation: O + execute(args: { + principal: SessionPrincipal + input: I + request?: OrchestrationRequestContext + }): Promise + projectAudit?(args: { + principal: SessionPrincipal + input: I + result: R + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] + projectErrorAudit?(args: { + principal: SessionPrincipal + input: I + error: unknown + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined + afterSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise + afterError?(args: { principal: SessionPrincipal; input: I; error: unknown }): void | Promise +} + +function recordCredentialUserAudit( + principal: SessionPrincipal, + operation: CredentialUserOperation, + projected: CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined, + request?: OrchestrationRequestContext +): void { + if (!projected) return + const attribution = resolvePrincipalAuditAttribution(principal) + const entries = Array.isArray(projected) ? projected : [projected] + for (const entry of entries) { + recordAudit({ + workspaceId: entry.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: operation.id, + actor: attribution.actor, + }, + request, + }) + } +} + +/** Defines a current-user credential operation that cannot borrow workspace identity. */ +export function defineAuthorizedCredentialUserUseCase< + const O extends CredentialUserOperation, + I, + R, +>(definition: CredentialUserUseCaseDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + if (principal.kind !== 'session') { + throw new OrchestrationError('forbidden', 'Session authentication required') + } + try { + const result = await definition.execute({ principal, input, request }) + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectAudit?.({ principal, input, result }), + request + ) + await definition.afterSuccess?.({ principal, input, result }) + return result + } catch (error) { + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectErrorAudit?.({ principal, input, error }), + request + ) + await definition.afterError?.({ principal, input, error }) + throw error + } + }, + } +} diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts new file mode 100644 index 00000000000..143a068961e --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listCatalog: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableOAuthCredentialProvider: ( + catalog: Array<{ + available: boolean + authorizationOptions: Array<{ providerId: string }> + }>, + providerId: string + ) => { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) throw Object.assign(new Error('Unknown OAuth provider'), { code: 'validation' }) + if (!provider.available) + throw Object.assign(new Error('OAuth provider is unavailable'), { code: 'conflict' }) + return provider + }, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { providerId: string; additionalProviderIds?: readonly string[] } + ) => + credentialProviderId === service.providerId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +})) + +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' + +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const salesforceProvider = { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + providerId: 'salesforce-sandbox', + displayName: 'Sandbox CRM', +} + +describe('resolveCredentialConnectionTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listCatalog.mockResolvedValue([salesforceProvider]) + mocks.getWorkspaceCredential.mockResolvedValue(credential) + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + }) + + it('accepts an exact authorization option for a new connection', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: 'salesforce-sandbox', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + }) + expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('loads reconnect credentials through the asserted workspace and requires admin access', async () => { + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: false }) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + + expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }) + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1') + }) + + it('preserves the credential authorization-server ID on reconnect', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + credentialId: 'credential-1', + displayName: 'Sandbox CRM', + }) + }) + + it('rejects providers whose custom flow cannot reconnect', async () => { + mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }]) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts new file mode 100644 index 00000000000..a7cb618ceab --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -0,0 +1,107 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, + requireAvailableOAuthCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolvedCredentialConnectionTarget { + provider: OAuthCredentialProviderCatalogEntry + providerId: string + credentialId?: string + displayName?: string +} + +export class CredentialConnectionProviderMismatchError extends OrchestrationError { + constructor() { + super('validation', 'Credential provider does not match the requested OAuth provider') + this.name = 'CredentialConnectionProviderMismatchError' + } +} + +export async function resolveCredentialConnectionTarget(params: { + principal: Principal + context: ActiveWorkspaceApplicationContext + providerId?: string + credentialId?: string + assertedProviderId?: string +}): Promise { + const { principal, context, providerId, credentialId, assertedProviderId } = params + if (Boolean(providerId) === Boolean(credentialId)) { + throw new Error('Credential connection requires exactly one target identifier') + } + + const catalog = await listCredentialProviderCatalog(principal, context) + if (providerId) { + return { + provider: requireAvailableOAuthCredentialProvider(catalog, providerId), + providerId, + } + } + + if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID') + const userId = requirePrincipalSubjectUserId(principal) + const targetCredentialId = credentialId + const credential = await getWorkspaceCredential({ + workspaceId: context.workspaceId, + credentialId: targetCredentialId, + }) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + if (credential.type !== 'oauth' || !credential.providerId) { + throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') + } + const credentialProviderId = credential.providerId + if (assertedProviderId && assertedProviderId !== credentialProviderId) { + throw new CredentialConnectionProviderMismatchError() + } + + const actor = await getCredentialActorContext(targetCredentialId, userId) + if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access on the credential is required to reconnect it' + ) + } + + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `OAuth provider is unavailable: ${credentialProviderId}` + ) + } + if (!provider.supportsReconnect) { + throw new OrchestrationError( + 'conflict', + `OAuth provider does not support reconnecting credentials: ${credentialProviderId}` + ) + } + + return { + provider, + providerId: credentialProviderId, + credentialId: credential.id, + displayName: credential.displayName, + } +} diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts new file mode 100644 index 00000000000..c89fc68718c --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), + createDraft: vi.fn(), + getBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mocks.getBaseUrl, +})) + +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} + +describe('createCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + mocks.createDraft.mockResolvedValue({ + id: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + mocks.getBaseUrl.mockReturnValue('https://sim.ai') + }) + + it('rejects workspace keys before canonical workspace loading', async () => { + await expect( + createCredentialConnection.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('creates a user-bound draft and returns its canonical connection context', async () => { + const result = await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: undefined, + displayName: 'Work Gmail', + }) + expect(result).toEqual({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + draftId: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + providerId: 'google-email', + workspaceId: 'workspace-1', + }) + }) + + it("preserves an existing credential's name on reconnect", async () => { + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + + await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', credentialId: 'credential-1' }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts new file mode 100644 index 00000000000..df4e423add7 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -0,0 +1,68 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateCredentialConnectionInput = { + workspaceId: string +} & ( + | { providerId: string; displayName?: string; credentialId?: never } + | { + credentialId: string + assertedProviderId?: string + providerId?: never + displayName?: never + } +) + +export interface CreateCredentialConnectionResult { + authorizationUrl: string + draftId: string + expiresAt: Date + providerId: string + workspaceId: string + credentialId?: string +} + +export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createConnection, + resolveContext: async ({ input }: { input: CreateCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: input.providerId, + credentialId: input.credentialId, + assertedProviderId: 'assertedProviderId' in input ? input.assertedProviderId : undefined, + }) + const displayName = input.providerId ? input.displayName : target.displayName + + const draft = await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: context.workspaceId, + providerId: target.providerId, + credentialId: target.credentialId, + displayName, + }) + const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) + authorizationUrl.searchParams.set('draftId', draft.id) + return { + authorizationUrl: authorizationUrl.toString(), + draftId: draft.id, + expiresAt: draft.expiresAt, + providerId: target.providerId, + workspaceId: context.workspaceId, + ...(target.credentialId ? { credentialId: target.credentialId } : {}), + } + }, +}) diff --git a/apps/sim/lib/credentials/application/credential-context.ts b/apps/sim/lib/credentials/application/credential-context.ts new file mode 100644 index 00000000000..d5fbf242545 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-context.ts @@ -0,0 +1,32 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialAuthorizationContext } from '@/lib/credentials/application/authorized-credential-use-case' +import { getCredentialById, getWorkspaceCredential } from '@/lib/credentials/queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolveCredentialApplicationContextInput { + credentialId: string + assertedWorkspaceId?: string +} + +/** Loads a credential canonically and verifies any asserted workspace scope. */ +export async function resolveCredentialApplicationContext( + input: ResolveCredentialApplicationContextInput +): Promise { + const assertedWorkspace = input.assertedWorkspaceId + ? await loadActiveWorkspaceApplicationContext(input.assertedWorkspaceId) + : null + if (input.assertedWorkspaceId && !assertedWorkspace) { + throw new OrchestrationError('not_found', 'Credential not found') + } + const credential = assertedWorkspace + ? await getWorkspaceCredential({ + workspaceId: assertedWorkspace.workspaceId, + credentialId: input.credentialId, + }) + : await getCredentialById(input.credentialId) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + const workspace = + assertedWorkspace ?? (await loadActiveWorkspaceApplicationContext(credential.workspaceId)) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + return { ...workspace, credential } +} diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts new file mode 100644 index 00000000000..cd1d065d1e2 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -0,0 +1,244 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + defineAuthorizedCredentialUseCase, + requireCredentialAccess, +} from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { + createCredentialRecord, + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCredentialResult, + type PerformUpdateCredentialParams, + updateCredentialRecord, +} from '@/lib/credentials/orchestration' +import { + type CredentialRow, + findWorkspaceCredentialLookup, + listVisibleWorkspaceCredentials, + type VisibleWorkspaceCredential, + type WorkspaceCredentialLookup, +} from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export class CredentialProviderOperationError extends OrchestrationError { + constructor( + message: string, + readonly providerErrorCode: string, + readonly providerUnavailable: boolean + ) { + super('validation', message) + this.name = 'CredentialProviderOperationError' + } +} + +function throwCredentialMutationFailure(result: { + success: boolean + error?: string + errorCode?: PerformCredentialResult['errorCode'] + providerErrorCode?: string + providerUnavailable?: boolean +}): never { + if (result.providerErrorCode) { + throw new CredentialProviderOperationError( + result.error ?? result.providerErrorCode, + result.providerErrorCode, + result.providerUnavailable === true || isProviderOutageCode(result.providerErrorCode) + ) + } + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential mutation failed') + case 'forbidden': + throw new OrchestrationError('forbidden', result.error ?? 'Credential mutation forbidden') + default: + throw new Error(result.error ?? 'Credential mutation failed') + } +} + +export interface ListInternalCredentialsInput { + workspaceId: string + type?: CredentialRow['type'] + providerId?: string + credentialId?: string +} + +export type ListInternalCredentialsResult = + | { mode: 'list'; credentials: VisibleWorkspaceCredential[] } + | { mode: 'lookup'; credential: WorkspaceCredentialLookup | null } + +export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listInternal, + resolveContext: async ({ input }: { input: ListInternalCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context }): Promise { + if (input.credentialId) { + return { + mode: 'lookup', + credential: await findWorkspaceCredentialLookup({ + workspaceId: context.workspaceId, + credentialId: input.credentialId, + }), + } + } + + const userId = requirePrincipalSubjectUserId(principal) + if (!input.type || input.type === 'oauth') { + await syncWorkspaceOAuthCredentialsForUser({ workspaceId: context.workspaceId, userId }) + } + const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, userId) + const page = await listVisibleWorkspaceCredentials({ + workspaceId: context.workspaceId, + userId, + workspaceAccess, + types: input.type ? [input.type] : undefined, + providerId: input.providerId, + }) + return { mode: 'list', credentials: page.data } + }, +}) + +export type CreateWorkspaceCredentialInput = Omit< + PerformCreateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'request' +> + +export interface CreateWorkspaceCredentialResult { + credential: CredentialRow + created: boolean + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + auditMetadata: Record +} + +export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.create, + resolveContext: async ({ input }: { input: CreateWorkspaceCredentialInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input }): Promise { + const userId = requirePrincipalSubjectUserId(principal) + const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) + if (!result.success) throwCredentialMutationFailure(result) + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + const access = await getCredentialActorContext(result.credential.id, userId) + if (!access.credential || !canUseCredential(access)) { + throw new Error('Created credential is not visible to its creator') + } + const role = access.isAdmin ? 'admin' : access.member?.role + const status = access.member?.status ?? (access.isAdmin ? 'active' : undefined) + if (!role || !status) throw new Error('Created credential has no active actor membership') + return { + credential: access.credential, + created: result.created === true, + role, + status, + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface GetWorkspaceCredentialInput { + credentialId: string +} + +export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.read, + resolveContext: ({ input }: { input: GetWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ context }) { + return { credential: context.credential, access: requireCredentialAccess(context) } + }, +}) + +export type UpdateWorkspaceCredentialInput = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> + +export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ principal, input, context }) { + if (principal.kind === 'delegated' && context.credential.type !== 'oauth') { + throw new OrchestrationError('validation', 'Copilot can update only oauth credentials') + } + const result = await updateCredentialRecord({ ...input, credential: context.credential }) + if (!result.success) throwCredentialMutationFailure(result) + const access = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!access.credential || !access.isAdmin) { + throw new Error('Updated credential is no longer visible to its administrator') + } + return { + credential: access.credential, + access, + previousDisplayName: context.credential.displayName, + updatedFields: result.updatedFields ?? [], + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + resourceName: context.credential.displayName, + description: `Updated ${context.credential.type} credential "${context.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: context.credential.type, + updatedFields: result.updatedFields, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts new file mode 100644 index 00000000000..561ae6a130d --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -0,0 +1,150 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { + credentialOperations, + credentialUserOperations, +} from '@/lib/credentials/application/operations' +import { + leaveCredentialMembership, + listCredentialMembers, + listCredentialMembershipsForUser, + removeCredentialMember, + upsertCredentialMember, +} from '@/lib/credentials/members' +import { captureServerEvent } from '@/lib/posthog/server' + +interface CredentialMemberResourceInput { + credentialId: string +} + +function resolveSessionCredentialContext( + _principal: SessionPrincipal, + input: CredentialMemberResourceInput +) { + return resolveCredentialApplicationContext(input) +} + +export const listCredentialMembersUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listMembers, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: CredentialMemberResourceInput + }) => resolveSessionCredentialContext(principal, input), + authorizationOptions: {}, + async execute({ context }) { + return { members: await listCredentialMembers(context.credential) } + }, +}) + +export interface UpsertCredentialMemberInput extends CredentialMemberResourceInput { + userId: string + role: 'admin' | 'member' +} + +export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.upsertMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: UpsertCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ principal, input, context }) { + const result = await upsertCredentialMember({ + credential: context.credential, + actorUserId: requirePrincipalSubjectUserId(principal), + targetUserId: input.userId, + role: input.role, + }) + return { ...result, targetUserId: input.userId, role: input.role } + }, + projectAudit: ({ context, result }) => ({ + action: result.created + ? AuditAction.CREDENTIAL_MEMBER_ADDED + : AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: result.created + ? `Shared credential with member as "${result.role}"` + : `Changed credential member role to "${result.role}"`, + metadata: { + targetUserId: result.targetUserId, + ...(result.created + ? { role: result.role } + : { fromRole: result.previousRole, toRole: result.role }), + }, + }), + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_shared', { + credential_type: context.credential.type, + role: result.role, + workspace_id: context.workspaceId, + }) + }, +}) + +export interface RemoveCredentialMemberInput extends CredentialMemberResourceInput { + userId: string +} + +export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.removeMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: RemoveCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ input, context }) { + await removeCredentialMember({ credential: context.credential, targetUserId: input.userId }) + return { success: true as const, targetUserId: input.userId } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_MEMBER_REMOVED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: 'Removed credential member', + metadata: { targetUserId: result.targetUserId }, + }), + afterSuccess: ({ principal, context }) => { + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_unshared', { + credential_type: context.credential.type, + workspace_id: context.workspaceId, + }) + }, +}) + +export const listCredentialMembershipsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listMemberships, + async execute({ principal }) { + return { memberships: await listCredentialMembershipsForUser(principal.userId) } + }, +}) + +export interface LeaveCredentialMembershipInput { + credentialId: string +} + +export const leaveCredentialMembershipUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.leaveMembership, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: LeaveCredentialMembershipInput + }) { + await leaveCredentialMembership({ userId: principal.userId, credentialId: input.credentialId }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.test.ts b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts new file mode 100644 index 00000000000..c3154f33db1 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} + +function oauthCredential(id: string, workspaceId = 'workspace-1') { + return { + id, + workspaceId, + type: 'oauth' as const, + displayName: `OAuth ${id}`, + description: null, + providerId: 'google-email', + accountId: `account-${id}`, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-14T12:00:00.000Z'), + updatedAt: new Date('2026-08-14T12:00:00.000Z'), + } +} + +describe('deleteManyCredentialsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.deleteCredential.mockResolvedValue(true) + }) + + it('deletes only OAuth credentials administered in the delegated workspace', async () => { + const allowed = oauthCredential('credential-1') + mocks.getActor + .mockResolvedValueOnce({ + credential: allowed, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + .mockResolvedValueOnce({ + credential: oauthCredential('credential-2', 'workspace-2'), + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }, + }) + + expect(result).toEqual({ + deleted: ['credential-1'], + failed: ['credential-2'], + deletedCredentials: [allowed], + }) + expect(mocks.deleteCredential).toHaveBeenCalledOnce() + expect(mocks.deleteCredential).toHaveBeenCalledWith({ + credential: allowed, + reason: 'copilot_delete', + }) + }) + + it('rejects duplicate IDs before loading any credential', async () => { + await expect( + deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-1'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.deleteCredential).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.ts b/apps/sim/lib/credentials/application/delete-many-credentials.ts new file mode 100644 index 00000000000..993f1795ef2 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.ts @@ -0,0 +1,114 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('DeleteManyCredentialsApplication') +const MAX_CREDENTIAL_DELETE_BATCH = 20 + +export interface DeleteManyCredentialsInput { + workspaceId: string + credentialIds: string[] +} + +export interface DeleteManyCredentialsResult { + deleted: string[] + failed: string[] + deletedCredentials: CredentialRow[] +} + +export const deleteManyCredentialsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.deleteMany, + resolveContext: async ({ input }: { input: DeleteManyCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async execute({ principal, input, context }): Promise { + if (input.credentialIds.length === 0) { + throw new OrchestrationError('validation', 'At least one credential ID is required') + } + if (input.credentialIds.length > MAX_CREDENTIAL_DELETE_BATCH) { + throw new OrchestrationError( + 'validation', + `At most ${MAX_CREDENTIAL_DELETE_BATCH} credentials can be deleted at once` + ) + } + if (new Set(input.credentialIds).size !== input.credentialIds.length) { + throw new OrchestrationError('validation', 'Credential IDs must be unique') + } + + const userId = requirePrincipalSubjectUserId(principal) + const deleted: string[] = [] + const failed: string[] = [] + const deletedCredentials: CredentialRow[] = [] + + for (const credentialId of input.credentialIds) { + try { + const access = await getCredentialActorContext(credentialId, userId) + if ( + !access.credential || + access.credential.workspaceId !== context.workspaceId || + !access.hasWorkspaceAccess || + !access.isAdmin || + access.credential.type !== 'oauth' + ) { + failed.push(credentialId) + continue + } + const didDelete = await deleteCredentialRecord({ + credential: access.credential, + reason: 'copilot_delete', + }) + if (!didDelete) { + failed.push(credentialId) + continue + } + deleted.push(credentialId) + deletedCredentials.push(access.credential) + } catch (error) { + logger.error('Failed to delete credential in Copilot batch', { credentialId, error }) + failed.push(credentialId) + } + } + + return { deleted, failed, deletedCredentials } + }, + projectAudit: ({ result }) => + result.deletedCredentials.map((credential) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (copilot_delete)`, + metadata: { + reason: 'copilot_delete', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })), + afterSuccess: ({ principal, context, result }) => { + for (const credential of result.deletedCredentials) { + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + } + }, +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.test.ts b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts new file mode 100644 index 00000000000..d983cdf3d53 --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveDraft: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + getActiveConnectDraft: mocks.getActiveDraft, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const draft = { + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: "User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('launchCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveDraft.mockResolvedValue(draft) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + }) + + it('loads the exact draft for the signed-in user and reauthorizes its target', async () => { + const result = await launchCredentialConnection.execute({ + principal, + input: { draftId: 'draft-1' }, + }) + + expect(mocks.getActiveDraft).toHaveBeenCalledWith('draft-1', 'user-1') + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: { ...workspaceContext, draft }, + providerId: 'google-email', + credentialId: undefined, + }) + expect(result).toEqual({ draft }) + }) + + it('rejects an invalid or expired draft before loading a workspace', async () => { + mocks.getActiveDraft.mockResolvedValue(null) + + await expect( + launchCredentialConnection.execute({ principal, input: { draftId: 'draft-missing' } }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.ts b/apps/sim/lib/credentials/application/launch-credential-connection.ts new file mode 100644 index 00000000000..1a19cafecee --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.ts @@ -0,0 +1,52 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export interface LaunchCredentialConnectionInput { + draftId: string +} + +interface LaunchCredentialConnectionContext extends ActiveWorkspaceApplicationContext { + draft: ConnectDraft +} + +export interface LaunchCredentialConnectionResult { + draft: ConnectDraft +} + +export const launchCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.launchConnection, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: LaunchCredentialConnectionInput + }): Promise => { + const draft = await getActiveConnectDraft(input.draftId, principal.userId) + if (!draft) + throw new OrchestrationError('not_found', 'OAuth connection link is invalid or expired') + const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { ...workspace, draft } + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: context.draft.credentialId ? undefined : context.draft.providerId, + credentialId: context.draft.credentialId ?? undefined, + }) + if (target.providerId !== context.draft.providerId) { + throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches') + } + return { draft: context.draft } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts new file mode 100644 index 00000000000..bc6bba937e3 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) + +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('listCredentialProviders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listCatalog.mockResolvedValue([]) + }) + + it('allows sessions to inspect deployment availability', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) + + it('allows workspace keys to inspect deployment availability', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) + + it('searches provider names case-insensitively without matching ids or descriptions', async () => { + const salesforce = { + name: 'Salesforce', + serviceId: 'salesforce', + description: 'Connect a CRM.', + } + const google = { + name: 'Google', + serviceId: 'salesforce-migration', + description: 'Migrate Salesforce records.', + } + mocks.listCatalog.mockResolvedValue([salesforce, google]) + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + const result = await listCredentialProviders.execute({ + principal, + input: { workspaceId: 'workspace-1', search: 'SaLeS' }, + }) + + expect(result.providers).toEqual([salesforce]) + }) + + it('fails fast on a blank search from a non-HTTP caller', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialProviders.execute({ + principal, + input: { workspaceId: 'workspace-1', search: ' ' }, + }) + ).rejects.toThrow('search cannot be empty') + expect(mocks.listCatalog).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts new file mode 100644 index 00000000000..ded16457d4d --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -0,0 +1,41 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, +} from '@/lib/credentials/application/provider-catalog' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ListCredentialProvidersInput { + workspaceId: string + search?: string +} + +export interface ListCredentialProvidersResult { + providers: CredentialProviderCatalogEntry[] +} + +export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listProviders, + resolveContext: async ({ input }: { input: ListCredentialProvidersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const search = input.search?.trim().toLowerCase() + if (input.search !== undefined && !search) { + throw new OrchestrationError('validation', 'search cannot be empty') + } + + const providers = await listCredentialProviderCatalog(principal, context) + return { + providers: search + ? providers.filter((provider) => provider.name.toLowerCase().includes(search)) + : providers, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts index f0dc64e48e3..c22e024cd61 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts @@ -54,21 +54,21 @@ describe('listWorkspaceCredentials', () => { mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true, canAdmin: false }) - mocks.listVisible.mockResolvedValue([]) - mocks.listForWorkspacePrincipal.mockResolvedValue([]) + mocks.listVisible.mockResolvedValue({ data: [], nextCursorKeys: null }) + mocks.listForWorkspacePrincipal.mockResolvedValue({ data: [], nextCursorKeys: null }) }) - it('rejects unsupported principals before canonical workspace loading', async () => { + it('preserves per-credential visibility for sessions', async () => { const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', } - await expect(listWorkspaceCredentials.execute({ principal, input })).rejects.toMatchObject({ - code: 'forbidden', - }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() + await listWorkspaceCredentials.execute({ principal, input }) + expect(mocks.listVisible).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', types: ['oauth', 'service_account'] }) + ) }) it('lists shared connections for a workspace key without creator identity', async () => { diff --git a/apps/sim/lib/credentials/application/oauth-accounts.test.ts b/apps/sim/lib/credentials/application/oauth-accounts.test.ts new file mode 100644 index 00000000000..bf77fae5662 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { account, credential } from '@sim/db/schema' +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' + +const firstCredential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, + displayName: 'First Google account', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +describe('OAuth account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits and captures committed deletions before rethrowing a later failure', async () => { + const secondCredential = { + ...firstCredential, + id: 'credential-2', + displayName: 'Second Google account', + accountId: 'account-2', + } + queueTableRows(account, [{ id: 'account-1' }, { id: 'account-2' }]) + queueTableRows(credential, [firstCredential, secondCredential]) + mocks.deleteCredential + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('Second credential delete failed')) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'google' }, + }) + ).rejects.toMatchObject({ + name: 'OAuthDisconnectPartialFailureError', + credentials: [firstCredential], + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'credential.deleted', + resourceId: firstCredential.id, + metadata: expect.objectContaining({ reason: 'oauth_disconnect' }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ + provider_id: 'google-email', + workspace_id: 'workspace-1', + }), + { groups: { workspace: 'workspace-1' } } + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/oauth-accounts.ts b/apps/sim/lib/credentials/application/oauth-accounts.ts new file mode 100644 index 00000000000..ff144a47524 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.ts @@ -0,0 +1,132 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { credentialUserOperations } from '@/lib/credentials/application/operations' +import { + disconnectOAuthAccounts, + listConnectedAccountsForUser, + listOAuthConnectionsForUser, + OAuthDisconnectPartialFailureError, +} from '@/lib/credentials/oauth-accounts' +import { captureServerEvent } from '@/lib/posthog/server' + +export const listOAuthConnectionsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listOAuthConnections, + async execute({ principal }) { + return { connections: await listOAuthConnectionsForUser(principal.userId) } + }, +}) + +export interface ListConnectedAccountsInput { + provider?: string +} + +export const listConnectedAccountsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listConnectedAccounts, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: ListConnectedAccountsInput + }) { + return { + accounts: await listConnectedAccountsForUser({ + userId: principal.userId, + provider: input.provider, + }), + } + }, +}) + +export interface DisconnectOAuthInput { + provider: string + providerId?: string + accountId?: string +} + +function projectDeletedCredentialAudit( + credentials: OAuthDisconnectPartialFailureError['credentials'] +) { + return credentials.map((credential) => ({ + workspaceId: credential.workspaceId, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (oauth_disconnect)`, + metadata: { + reason: 'oauth_disconnect', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })) +} + +function captureDeletedCredentialEvents( + userId: string, + credentials: OAuthDisconnectPartialFailureError['credentials'], + provider: string, + providerId?: string +): void { + for (const credential of credentials) { + captureServerEvent( + userId, + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? providerId ?? provider, + workspace_id: credential.workspaceId, + }, + { groups: { workspace: credential.workspaceId } } + ) + } +} + +export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.disconnectOAuth, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: DisconnectOAuthInput + }) { + const result = await disconnectOAuthAccounts({ userId: principal.userId, ...input }) + return { ...result, ...input, success: true as const } + }, + projectAudit: ({ result }) => [ + ...projectDeletedCredentialAudit(result.credentials), + { + workspaceId: null, + action: AuditAction.OAUTH_DISCONNECTED, + resourceType: AuditResourceType.OAUTH, + resourceId: result.providerId ?? result.provider, + resourceName: result.provider, + description: `Disconnected OAuth provider: ${result.provider}`, + metadata: { provider: result.provider, providerId: result.providerId }, + }, + ], + projectErrorAudit: ({ error }) => + error instanceof OAuthDisconnectPartialFailureError + ? projectDeletedCredentialAudit(error.credentials) + : undefined, + afterSuccess: ({ principal, result }) => { + captureDeletedCredentialEvents( + principal.userId, + result.credentials, + result.provider, + result.providerId + ) + }, + afterError: ({ principal, input, error }) => { + if (!(error instanceof OAuthDisconnectPartialFailureError)) return + captureDeletedCredentialEvents( + principal.userId, + error.credentials, + input.provider, + input.providerId + ) + }, +}) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts new file mode 100644 index 00000000000..26c363580f9 --- /dev/null +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { + credentialOperations, + defineCredentialOperation, +} from '@/lib/credentials/application/operations' + +describe('credential operations', () => { + it('declares credential admin as the delete authority and workspace read as reach', () => { + expect(credentialOperations.delete).toMatchObject({ + id: 'credentials.delete', + minimumRole: 'read', + minimumCredentialRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + expect(Object.isFrozen(credentialOperations.delete)).toBe(true) + }) + + it('rejects actorless workspace keys for credential admin operations', () => { + const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], + }) + + expect(() => defineCredentialOperation(workspaceKeyOperation, 'admin')).toThrow( + 'Credential operation credentials.test_admin requires a user-bearing principal' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 3f1fad25074..43d7d531b40 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,11 +1,146 @@ -import { defineWorkspaceOperation } from '@/lib/core/application' +import type { ApplicationOperation } from '@/lib/core/application' +import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' + +export type CredentialRole = 'member' | 'admin' + +export type CredentialOperation = O & { + readonly minimumCredentialRole: CredentialRole +} + +/** Adds credential-resource policy to a workspace-scoped operation. */ +export function defineCredentialOperation< + const O extends WorkspaceOperation, + const R extends CredentialRole, +>( + operation: O, + minimumCredentialRole: R +): CredentialOperation & { + readonly minimumCredentialRole: R +} { + if (operation.principalKinds.includes('workspace_api_key')) { + throw new Error(`Credential operation ${operation.id} requires a user-bearing principal`) + } + return Object.freeze({ ...operation, minimumCredentialRole }) +} + +const HUMAN_AND_COPILOT_PRINCIPALS = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const credentialOperations = { + listInternal: defineWorkspaceOperation({ + id: 'credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listProviders: defineWorkspaceOperation({ + id: 'credentials.providers.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + createConnection: defineWorkspaceOperation({ + id: 'credentials.connections.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + prepareConnection: defineWorkspaceOperation({ + id: 'credentials.connections.prepare', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + createServiceAccount: defineWorkspaceOperation({ + id: 'credentials.service_accounts.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + read: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' + ), + create: defineWorkspaceOperation({ + id: 'credentials.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + update: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.update', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + delete: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + deleteMany: defineWorkspaceOperation({ + id: 'credentials.delete_many', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + saveDraft: defineWorkspaceOperation({ + id: 'credentials.drafts.save', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listMembers: defineWorkspaceOperation({ + id: 'credentials.members.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + upsertMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.upsert', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + removeMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.remove', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + launchConnection: defineWorkspaceOperation({ + id: 'credentials.connections.launch', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], }), useManagedOAuth: defineWorkspaceOperation({ id: 'credentials.managed_oauth.use', @@ -15,3 +150,23 @@ export const credentialOperations = { delegatedServices: ['executor'], }), } as const + +export interface CredentialUserOperation + extends ApplicationOperation { + readonly principalKinds: readonly ['session'] +} + +function defineCredentialUserOperation( + id: Id +): CredentialUserOperation { + if (!id.trim()) throw new Error('Credential user operation ID must not be empty') + return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) +} + +export const credentialUserOperations = { + listMemberships: defineCredentialUserOperation('credentials.memberships.list'), + leaveMembership: defineCredentialUserOperation('credentials.memberships.leave'), + listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), + listConnectedAccounts: defineCredentialUserOperation('credentials.accounts.list'), + disconnectOAuth: defineCredentialUserOperation('credentials.oauth_connections.disconnect'), +} as const diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts new file mode 100644 index 00000000000..4d2e8f8ed27 --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} +const gmailProvider = { + type: 'oauth' as const, + serviceId: 'gmail', + name: 'Gmail', + description: 'Gmail OAuth', + providerFamily: 'google', + available: true, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'google-email', label: 'Gmail' }], +} + +describe('prepareCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.listCatalog.mockResolvedValue([gmailProvider]) + }) + + it('resolves a provider inside delegated workspace policy', async () => { + const result = await prepareCredentialConnection.execute({ + principal, + input: { workspaceId: 'workspace-1', providerName: 'gmail' }, + }) + + expect(result).toEqual({ providerId: 'google-email', serviceName: 'Gmail' }) + }) + + it('uses the credential target as the reconnect authority', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'google-email', + credentialId: 'credential-1', + }) + + const result = await prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + + expect(result).toEqual({ + providerId: 'google-email', + serviceName: 'Gmail', + credentialId: 'credential-1', + }) + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: workspace, + credentialId: 'credential-1', + }) + }) + + it('rejects a reconnect whose requested provider does not match the credential', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'slack', + credentialId: 'credential-1', + }) + + await expect( + prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.ts new file mode 100644 index 00000000000..cedd08e89fd --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.ts @@ -0,0 +1,109 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface PrepareCredentialConnectionInput { + workspaceId: string + providerName: string + credentialId?: string +} + +export interface PrepareCredentialConnectionResult { + providerId: string + serviceName: string + credentialId?: string +} + +function resolveRequestedProvider( + providers: readonly OAuthCredentialProviderCatalogEntry[], + providerName: string +): OAuthCredentialProviderCatalogEntry { + const requested = providerName.toLowerCase().trim() + if (!requested) throw new OrchestrationError('validation', 'OAuth provider is required') + + const provider = + providers.find((entry) => + entry.authorizationOptions.some((option) => option.providerId.toLowerCase() === requested) + ) ?? + providers.find( + (entry) => + entry.serviceId.toLowerCase() === requested || entry.name.toLowerCase() === requested + ) ?? + providers.find( + (entry) => + entry.name.toLowerCase().includes(requested) || + requested.includes(entry.name.toLowerCase()) || + entry.authorizationOptions.some( + (option) => + option.providerId.toLowerCase().includes(requested) || + requested.includes(option.providerId.toLowerCase()) + ) + ) + + if (!provider) + throw new OrchestrationError('validation', `OAuth provider not found: ${providerName}`) + if (!provider.available) { + throw new OrchestrationError('conflict', `${provider.name} is not available in this workspace`) + } + return provider +} + +export const prepareCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.prepareConnection, + resolveContext: async ({ input }: { input: PrepareCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const providers = (await listCredentialProviderCatalog(principal, context)).filter( + (entry): entry is OAuthCredentialProviderCatalogEntry => entry.type === 'oauth' + ) + const requestedProvider = resolveRequestedProvider(providers, input.providerName) + const requestedProviderId = requestedProvider.authorizationOptions[0]?.providerId + if (!requestedProviderId) { + throw new Error(`OAuth provider ${requestedProvider.serviceId} has no authorization option`) + } + + if (!input.credentialId) { + return { + providerId: requestedProviderId, + serviceName: requestedProvider.name, + } + } + + const target = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: input.credentialId, + }) + if ( + !credentialProviderMatchesService(target.providerId, { + providerId: requestedProviderId, + additionalProviderIds: requestedProvider.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) { + throw new OrchestrationError( + 'validation', + `Credential belongs to provider ${target.providerId}, not ${requestedProviderId}` + ) + } + + return { + providerId: target.providerId, + serviceName: requestedProvider.name, + credentialId: target.credentialId, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts new file mode 100644 index 00000000000..b4fbe43c3c5 --- /dev/null +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -0,0 +1,59 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import { + type CredentialActorContext, + requireOrdinaryCredentialType, +} from '@/lib/credentials/access' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +type PublicCredentialSource = + | VisibleWorkspaceCredential + | (CredentialRow & { hasServiceAccountKey: boolean; role: 'admin' | 'member' }) + +/** Serializes connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: PublicCredentialSource): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Serializes credential metadata for the internal workspace surface. */ +export function toWorkspaceCredential( + row: CredentialRow | VisibleWorkspaceCredential, + access?: CredentialActorContext +): WorkspaceCredential { + const type = requireOrdinaryCredentialType(row.type) + const role = access?.isAdmin + ? 'admin' + : (access?.member?.role ?? ('role' in row ? row.role : undefined)) + const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined) + return { + id: row.id, + workspaceId: row.workspaceId, + type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + envOwnerUserId: row.envOwnerUserId, + createdBy: row.createdBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(role ? { role } : {}), + ...(status ? { status } : {}), + } +} diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts new file mode 100644 index 00000000000..f485a80465d --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBlockVisibility: vi.fn(), + getAllowedIntegrationsFromEnv: vi.fn(), + getUserPermissionConfig: vi.fn(), + createVisibility: vi.fn(), + getAllOAuthServices: vi.fn(), + getServiceConfigByServiceId: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ + intersectIntegrationAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + if (!permissionGroup) return deployment + if (!deployment) return permissionGroup + return permissionGroup.filter((type) => deployment.includes(type)) + }, +})) + +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: mocks.createVisibility, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: mocks.getAllOAuthServices, + getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, +})) + +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, + type ServiceAccountCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' + +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', +} +const services = [ + { + serviceId: 'salesforce', + providerId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + name: 'Salesforce', + description: 'Connect Salesforce.', + baseProvider: 'salesforce', + authType: 'oauth' as const, + }, + { + serviceId: 'trello', + providerId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + baseProvider: 'trello', + authType: 'oauth' as const, + }, + { + serviceId: 'claude-platform', + providerId: 'claude-platform-service-account', + serviceAccountProviderId: 'claude-platform-service-account', + name: 'Claude Platform', + description: 'Run Claude Platform Managed Agents from your workflows.', + baseProvider: 'claude-platform', + authType: 'service_account' as const, + }, +] + +describe('listCredentialProviderCatalog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAllOAuthServices.mockReturnValue(services) + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['salesforce', 'trello'], + }) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + isCredentialVisible: ({ providerId }: { providerId: string }) => + providerId === 'claude-platform-service-account', + }) + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { + providerIdLabels: { + salesforce: 'Production', + 'salesforce-sandbox': 'Sandbox', + }, + } + } + if (serviceId === 'trello') return {} + return null + }) + }) + + it('projects OAuth services, authorization options, and reconnect capability', async () => { + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + { + type: 'oauth', + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'oauth', + serviceId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + providerFamily: 'trello', + available: false, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], + }, + { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + name: 'Claude Platform API key', + description: 'Connect Claude Platform with a API key.', + providerFamily: 'claude-platform', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/managed-agent', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'sk-ant-...', + required: true, + secret: true, + multiline: false, + hint: 'Claude Platform API keys usually start with sk-ant-.', + }, + ], + }, + ]) + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('does not borrow a human permission group for workspace API keys', async () => { + await listCredentialProviderCatalog( + { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + context + ) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('fails fast when a multi-server provider lacks complete labels', async () => { + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { providerIdLabels: { salesforce: 'Production' } } + } + if (serviceId === 'trello') return {} + return null + }) + + await expect(listCredentialProviderCatalog(personalPrincipal, context)).rejects.toThrow( + 'OAuth provider salesforce-sandbox is missing its authorization option label' + ) + }) +}) + +describe('requireAvailableServiceAccountCredentialProvider', () => { + const provider: ServiceAccountCredentialProviderCatalogEntry = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [], + } + + it('returns an available service-account provider', () => { + expect(requireAvailableServiceAccountCredentialProvider([provider], provider.providerId)).toBe( + provider + ) + }) + + it('rejects a service-account provider hidden by workspace policy', () => { + expect(() => + requireAvailableServiceAccountCredentialProvider( + [{ ...provider, available: false }], + provider.providerId + ) + ).toThrow('Service-account provider is unavailable: zoom-service-account') + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts new file mode 100644 index 00000000000..7dcdc507dbf --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -0,0 +1,356 @@ +import type { Principal } from '@sim/auth/principal' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, + type ClientCredentialAccountField, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, + type TokenServiceAccountField, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + type OAuthServiceMetadata, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' +import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +export interface CredentialProviderAuthorizationOption { + providerId: string + label: string +} + +export interface CredentialProviderFieldOption { + value: string + label: string +} + +export interface CredentialProviderField { + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: string[] + options?: CredentialProviderFieldOption[] + hint?: string +} + +interface CredentialProviderCatalogBase { + type: 'oauth' | 'service_account' + serviceId: string + name: string + description: string + providerFamily: string + available: boolean +} + +export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderCatalogBase { + type: 'oauth' + supportsReconnect: boolean + authorizationOptions: CredentialProviderAuthorizationOption[] +} + +export interface ServiceAccountCredentialProviderCatalogEntry + extends CredentialProviderCatalogBase { + type: 'service_account' + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: CredentialProviderField[] +} + +export type CredentialProviderCatalogEntry = + | OAuthCredentialProviderCatalogEntry + | ServiceAccountCredentialProviderCatalogEntry + +interface CredentialProviderCatalogContext { + workspaceId: string + workspaceOrganizationId: string | null +} + +interface ServiceAccountDescriptor { + name: string + description: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId?: boolean + fields: CredentialProviderField[] +} + +const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' +const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = + 'https://docs.sim.ai/integrations/atlassian-service-account' + +function providerField( + field: TokenServiceAccountField | ClientCredentialAccountField +): CredentialProviderField { + return { + id: field.id, + label: field.label, + placeholder: field.placeholder, + required: !('optional' in field && field.optional), + secret: field.secret, + multiline: 'multiline' in field && field.multiline === true, + ...('requiredForAuthMethods' in field && field.requiredForAuthMethods + ? { requiredForAuthMethods: [...field.requiredForAuthMethods] } + : {}), + ...('options' in field && field.options ? { options: [...field.options] } : {}), + ...('hint' in field && field.hint + ? { hint: field.hint } + : 'hintMessage' in field && field.hintMessage + ? { hint: field.hintMessage } + : {}), + } +} + +function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { + if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Google service account', + description: 'Connect Google APIs with a service-account JSON key.', + docsUrl: GOOGLE_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'serviceAccountJson', + label: 'JSON key', + placeholder: 'Paste the service-account JSON key', + required: true, + secret: true, + multiline: true, + }, + ], + } + } + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Atlassian service account', + description: 'Connect Jira and Confluence with an Atlassian API token.', + docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + required: true, + secret: true, + multiline: false, + }, + { + id: 'domain', + label: 'Site domain', + placeholder: 'your-team.atlassian.net', + required: true, + secret: false, + multiline: false, + }, + ], + } + } + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + return { + name: 'Slack custom bot', + description: 'Connect a reusable Slack app with its signing secret and bot token.', + docsUrl: 'https://docs.sim.ai/integrations/slack', + requiresClientGeneratedCredentialId: true, + fields: [ + { + id: 'signingSecret', + label: 'Signing secret', + placeholder: 'Paste the signing secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'botToken', + label: 'Bot token', + placeholder: 'xoxb-...', + required: true, + secret: true, + multiline: false, + }, + ], + } + } + + const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) + ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS + ] + : undefined + if (tokenDescriptor) { + return { + name: `${tokenDescriptor.serviceLabel} ${tokenDescriptor.connectNoun}`, + description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`, + docsUrl: tokenDescriptor.docsUrl, + helpText: tokenDescriptor.helpText, + fields: tokenDescriptor.fields.map(providerField), + } + } + + const clientDescriptor = Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS + ] + : undefined + if (clientDescriptor) { + return { + name: `${clientDescriptor.serviceLabel} ${clientDescriptor.connectNoun}`, + description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`, + docsUrl: clientDescriptor.docsUrl, + helpText: clientDescriptor.helpText, + fields: clientDescriptor.fields.map(providerField), + } + } + + throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) +} + +function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} + +export async function listCredentialProviderCatalog( + principal: Principal, + context: CredentialProviderCatalogContext +): Promise { + const userId = principalUserId(principal) + const [allowedIntegrations, blockVisibility] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + ]) + const services = getAllOAuthServices() + const oauthServices = services.filter((service) => service.authType === 'oauth') + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowedIntegrations, + blockVisibility, + oauthServices: services, + }) + + const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => { + const config = getServiceConfigByServiceId(service.serviceId) + if (!config) { + throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) + } + const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])] + if (providerIds.length > 1 && !config.providerIdLabels) { + throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`) + } + const authorizationOptions = providerIds.map((providerId) => { + const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId] + if (!label) { + throw new Error(`OAuth provider ${providerId} is missing its authorization option label`) + } + return { providerId, label } + }) + + return { + type: 'oauth', + serviceId: service.serviceId, + name: service.name, + description: service.description, + providerFamily: service.baseProvider, + available: visibility.isOAuthServiceVisible(service), + supportsReconnect: true, + authorizationOptions, + } + }) + + const serviceAccountOwners = new Map() + for (const service of services) { + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId && !serviceAccountOwners.has(serviceAccountProviderId)) { + serviceAccountOwners.set(serviceAccountProviderId, service) + } + } + + const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [ + ...serviceAccountOwners, + ].map(([providerId, owner]) => { + const descriptor = getServiceAccountDescriptor(providerId) + return { + type: 'service_account', + serviceId: providerId, + providerId, + name: descriptor.name, + description: descriptor.description, + providerFamily: owner.baseProvider, + available: visibility.isCredentialVisible({ providerId, type: 'service_account' }), + docsUrl: descriptor.docsUrl, + ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}), + requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true, + fields: descriptor.fields, + } + }) + + return [...oauthEntries, ...serviceAccountEntries] +} + +export function requireAvailableOAuthCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): OAuthCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`) + } + return provider +} + +export function requireAvailableServiceAccountCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): ServiceAccountCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is ServiceAccountCredentialProviderCatalogEntry => + entry.type === 'service_account' && entry.providerId === providerId + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown service-account provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `Service-account provider is unavailable: ${providerId}` + ) + } + return provider +} diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts new file mode 100644 index 00000000000..38711cdf3b8 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + createDraft: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} + +describe('saveCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.createDraft.mockResolvedValue({ id: 'draft-1' }) + }) + + it('authorizes workspace access before resolving reconnect credential access', async () => { + const result = await saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + + expect(result).toEqual({ success: true, draftId: 'draft-1' }) + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + expect(mocks.createDraft).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }) + ) + }) + + it('rejects a reconnect outside the asserted workspace', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, workspaceId: 'workspace-2' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) + + it('rejects managed OAuth credentials as reconnect targets', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, type: 'managed_oauth' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Managed Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts new file mode 100644 index 00000000000..cee499eacfb --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -0,0 +1,66 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface SaveCredentialDraftInput { + workspaceId: string + providerId: string + displayName: string + description?: string + credentialId?: string +} + +interface SaveCredentialDraftContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credentialAccess?: CredentialActorContext +} + +export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.saveDraft, + async resolveContext({ + input, + }: { + input: SaveCredentialDraftInput + }): Promise { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace + }, + authorizationOptions: {}, + async authorizeResource({ principal, input, context }) { + if (!input.credentialId) return + context.credentialAccess = await getCredentialActorContext( + input.credentialId, + requirePrincipalSubjectUserId(principal) + ) + if ( + !context.credentialAccess?.credential || + context.credentialAccess.credential.type === 'managed_oauth' || + context.credentialAccess.credential.workspaceId !== context.workspaceId || + !context.credentialAccess.isAdmin + ) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access required on the target credential' + ) + } + }, + async execute({ principal, input }) { + const draft = await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: input.workspaceId, + providerId: input.providerId, + displayName: input.displayName, + description: input.description, + credentialId: input.credentialId, + }) + return { success: true as const, draftId: draft.id } + }, +}) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts new file mode 100644 index 00000000000..dbdf310231e --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -0,0 +1,346 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + listCatalog: vi.fn(), + requireProvider: vi.fn(), + getCredential: vi.fn(), + getActor: vi.fn(), + deleteRecord: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + createServiceAccountCredential: mocks.create, + deleteCredentialRecord: mocks.deleteRecord, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableServiceAccountCredentialProvider: mocks.requireProvider, +})) +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getCredential, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + createServiceAccountCredentialUseCase, + deleteCredentialUseCase, +} from '@/lib/credentials/application/service-account' + +const WORKSPACE_ID = 'workspace-1' +const workspace = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted', + createdBy: 'user-1', + createdAt: new Date('2026-08-12T20:00:00.000Z'), + updatedAt: new Date('2026-08-12T20:00:00.000Z'), +} + +describe('credential service-account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getCredential.mockResolvedValue(credential) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.create.mockResolvedValue({ + success: true, + credential, + created: true, + auditMetadata: { tenantId: 'tenant-1' }, + }) + mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.deleteRecord.mockResolvedValue(true) + mocks.requireProvider.mockReturnValue({ + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + }) + }) + + it('rejects workspace keys before canonical loading on create', async () => { + await expect( + createServiceAccountCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('creates through the verified service-account primitive', async () => { + const result = await createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + + expect(result).toMatchObject({ + credential, + created: true, + hasServiceAccountKey: true, + role: 'admin', + }) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + providerId: 'zoom-service-account', + }) + ) + }) + + it('rejects service-account providers hidden by workspace policy', async () => { + mocks.requireProvider.mockImplementation(() => { + throw new OrchestrationError( + 'conflict', + 'Service-account provider is unavailable: zoom-service-account' + ) + }) + + await expect( + createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires credential admin access before disconnecting', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('rejects workspace keys before canonical loading on disconnect', async () => { + await expect( + deleteCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('allows an explicit credential admin with workspace read access to disconnect', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).resolves.toEqual({ credential, deleted: true }) + expect(mocks.deleteRecord).toHaveBeenCalledOnce() + }) + + it('applies credential admin policy during authorization-only checks', async () => { + await deleteCredentialUseCase.authorize?.({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId) + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('enforces personal-key workspace policy before credential authorization', async () => { + mocks.loadWorkspace.mockResolvedValue({ ...workspace, allowPersonalApiKeys: false }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('disconnects an administered credential', async () => { + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: true }) + expect(mocks.deleteRecord).toHaveBeenCalledWith({ credential, reason: 'user_delete' }) + }) + + it('deletes every type through the record manager so secret sources are torn down', async () => { + const oauthCredential = { + ...credential, + type: 'oauth', + providerId: 'google-email', + accountId: 'acct-1', + } + mocks.getCredential.mockResolvedValue(oauthCredential) + mocks.getActor.mockResolvedValue({ + credential: oauthCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: oauthCredential.id }, + }) + + expect(result).toEqual({ credential: oauthCredential, deleted: true }) + expect(mocks.deleteRecord).toHaveBeenCalledWith({ + credential: oauthCredential, + reason: 'user_delete', + }) + }) + + it('treats a concurrent disconnect as an idempotent success', async () => { + mocks.deleteRecord.mockResolvedValue(false) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: false }) + }) + + it.each([ + ['env_personal', 'personal'], + ['env_workspace', 'workspace'], + ] as const)('preserves %s deletion audit and analytics dimensions', async (type, label) => { + const envCredential = { + ...credential, + type, + displayName: 'MY_API_KEY', + providerId: null, + envKey: 'MY_API_KEY', + envOwnerUserId: type === 'env_personal' ? 'user-1' : null, + encryptedServiceAccountKey: null, + } + mocks.getCredential.mockResolvedValue(envCredential) + mocks.getActor.mockResolvedValue({ + credential: envCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await deleteCredentialUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, credentialId: envCredential.id }, + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + description: `Deleted ${label} env credential "MY_API_KEY"`, + metadata: expect.objectContaining({ + credentialType: type, + envKey: 'MY_API_KEY', + }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ provider_id: 'MY_API_KEY' }), + expect.anything() + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts new file mode 100644 index 00000000000..fe3c058ba77 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -0,0 +1,202 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, + deleteCredentialRecord, +} from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateServiceAccountInput = Omit< + CreateServiceAccountCredentialParams, + 'userId' | 'request' +> + +export interface CreateServiceAccountResult { + credential: CredentialRow + created: boolean + hasServiceAccountKey: boolean + role: 'admin' | 'member' + auditMetadata: Record +} + +class CredentialProviderUnavailableError extends HttpError { + readonly statusCode = 503 + + constructor() { + super('Credential provider is temporarily unavailable') + this.name = 'CredentialProviderUnavailableError' + } +} + +export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createServiceAccount, + resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context, request }): Promise { + const catalog = await listCredentialProviderCatalog(principal, context) + requireAvailableServiceAccountCredentialProvider(catalog, input.providerId) + const result = await createServiceAccountCredential({ + ...input, + workspaceId: context.workspaceId, + userId: requirePrincipalSubjectUserId(principal), + request, + }) + if (!result.success) { + if (result.providerUnavailable) throw new CredentialProviderUnavailableError() + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential create failed') + case 'forbidden': + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + result.error ?? 'Write permission required' + ) + default: + throw new Error('Failed to create service-account credential') + } + } + if (!result.credential) { + throw new Error('Credential creation succeeded without a credential') + } + const actor = await getCredentialActorContext( + result.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!actor.credential || (!actor.member && !actor.isAdmin)) { + throw new Error('Created credential is not visible to its creator') + } + return { + credential: result.credential, + created: result.created === true, + hasServiceAccountKey: Boolean(result.credential.encryptedServiceAccountKey), + role: actor.isAdmin ? 'admin' : 'member', + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created service_account credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: 'service_account', + provider_id: result.credential.providerId ?? 'service_account', + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface DeleteCredentialInput { + workspaceId?: string + credentialId: string +} + +export interface DeleteCredentialResult { + credential: CredentialRow + deleted: boolean +} + +export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.delete, + resolveContext: ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialApplicationContext({ + credentialId: input.credentialId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, context }): Promise { + const allowedTypes = + principal.kind === 'session' + ? ['oauth', 'env_workspace', 'env_personal', 'service_account'] + : principal.kind === 'delegated' + ? ['oauth'] + : ['oauth', 'service_account'] + if (!allowedTypes.includes(context.credential.type)) { + throw new OrchestrationError( + 'validation', + `Only ${allowedTypes.join(', ')} credentials can be managed by this caller` + ) + } + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const deleted = await deleteCredentialRecord({ credential: context.credential, reason }) + return { credential: context.credential, deleted } + }, + projectAudit: ({ principal, result }) => { + if (!result.deleted) return [] + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const description = + result.credential.type === 'env_personal' + ? `Deleted personal env credential "${result.credential.envKey}"` + : result.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${result.credential.envKey}"` + : `Deleted ${result.credential.type} credential "${result.credential.displayName}" (${reason})` + return { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description, + metadata: { + reason, + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + envKey: result.credential.envKey, + }, + } + }, + afterSuccess: ({ principal, context, result }) => { + if (!result.deleted) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: result.credential.type, + provider_id: + result.credential.providerId ?? result.credential.envKey ?? result.credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + }, +}) diff --git a/apps/sim/lib/credentials/client-state.ts b/apps/sim/lib/credentials/client-state.ts index c1e6e3b6fee..c3963cc59e9 100644 --- a/apps/sim/lib/credentials/client-state.ts +++ b/apps/sim/lib/credentials/client-state.ts @@ -52,6 +52,11 @@ interface OAuthReturnBase { displayName: string providerId: string preCount: number + baselineCredentials?: Array<{ + id: string + accountId: string | null + updatedAt?: string + }> workspaceId: string reconnect?: boolean requestedAt: number diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts new file mode 100644 index 00000000000..0ce2dccee72 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateId } = vi.hoisted(() => ({ + mockGenerateId: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { createConnectDraft } from '@/lib/credentials/connect-draft' + +describe('createConnectDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGenerateId.mockReturnValue('new-draft-id') + }) + + it('supersedes an active connection intent with a new exact draft id', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'new-draft-id', expiresAt }]) + + const result = await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id: 'new-draft-id' }) + ) + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as + | { set?: Record; setWhere?: unknown } + | undefined + expect(conflict?.set).toEqual( + expect.objectContaining({ + id: 'new-draft-id', + displayName: 'Work Gmail', + credentialId: null, + }) + ) + expect(conflict?.setWhere).toBeUndefined() + expect(result).toEqual({ id: 'new-draft-id', expiresAt }) + }) + + it('supersedes a reconnect target when its mutable display name changes', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'new-draft-id', expiresAt }]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }) + ).resolves.toEqual({ id: 'new-draft-id', expiresAt }) + + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ + id: 'new-draft-id', + displayName: 'Renamed Gmail', + credentialId: 'credential-1', + }), + }) + ) + }) + + it('fails when the draft upsert does not return a row', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + ).rejects.toThrow('Failed to create OAuth credential draft') + }) +}) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 2e72f796526..97f0f6ef744 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,12 +2,19 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' +import { and, eq, gt, lt } from 'drizzle-orm' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') -const DRAFT_TTL_MS = 15 * 60 * 1000 + +export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect + +export interface CreatedConnectDraft { + id: string + expiresAt: Date +} /** * Creates the pending credential draft at OAuth click time so custom and @@ -21,7 +28,8 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string -}): Promise { + description?: string +}): Promise { const { userId, workspaceId, providerId, credentialId } = params let displayName = params.displayName @@ -32,61 +40,42 @@ export async function createConnectDraft(params: { const service = getAllOAuthServices().find((s) => credentialProviderMatchesService(providerId, s) ) - const serviceName = service?.name ?? providerId + if (!service) throw new Error(`Cannot create OAuth draft for unknown provider ${providerId}`) + const serviceName = service.name - let userName: string | null = null - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - userName = row?.name ?? null - } catch (error) { - // Cosmetic only — fall back to the "My {Service}" default - logger.warn('User name lookup failed for connect draft display name', { - userId, - workspaceId, - providerId, - error, - }) - } + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (!row) throw new Error(`Cannot create OAuth draft for missing user ${userId}`) + const userName = row.name // Auto-number against existing workspace credentials so repeat connects for // the same provider stay distinguishable — same behavior as the connect - // modal, which computes this client-side. Best effort: on failure the name - // simply skips deduplication. - let takenNames: ReadonlySet = new Set() - try { - const rows = await db - .select({ displayName: credential.displayName }) - .from(credential) - .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) - takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) - } catch (error) { - // Cosmetic only — proceed without collision numbering - logger.warn('Credential name lookup failed for connect draft deduplication', { - userId, - workspaceId, - providerId, - error, - }) - } + // modal, which computes this client-side. + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase())) displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + const expiresAt = new Date(now.getTime() + CREDENTIAL_DRAFT_TTL_MS) await db .delete(pendingCredentialDraft) .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db + const id = generateId() + const [draft] = await db .insert(pendingCredentialDraft) .values({ - id: generateId(), + id, userId, workspaceId, providerId, displayName, + description: params.description?.trim() || null, credentialId: credentialId ?? null, expiresAt, createdAt: now, @@ -97,11 +86,25 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - // credentialId must be written on BOTH paths: a plain connect that reuses a - // stale reconnect draft row would otherwise silently rebind the old - // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + /** + * A new launch supersedes an abandoned or failed launch for this provider. + * Rotating the primary key invalidates the earlier callback's exact draft + * binding, so only the newest OAuth flow can materialize its intent. + */ + set: { + id, + displayName, + description: params.description?.trim() || null, + credentialId: credentialId ?? null, + expiresAt, + createdAt: now, + }, }) + .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) + + if (!draft) { + throw new Error('Failed to create OAuth credential draft') + } logger.info('Created OAuth connect credential draft', { userId, @@ -109,4 +112,23 @@ export async function createConnectDraft(params: { providerId, credentialId: credentialId ?? null, }) + return draft +} + +export async function getActiveConnectDraft( + draftId: string, + userId: string +): Promise { + const [draft] = await db + .select() + .from(pendingCredentialDraft) + .where( + and( + eq(pendingCredentialDraft.id, draftId), + eq(pendingCredentialDraft.userId, userId), + gt(pendingCredentialDraft.expiresAt, new Date()) + ) + ) + .limit(1) + return draft ?? null } diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 618e51b0d1a..f2b8d866c29 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, or, sql } from 'drizzle-orm' +import { and, eq, notExists, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils' @@ -23,6 +23,12 @@ interface DeleteCredentialParams { request?: NextRequest } +export interface DeleteConnectionCredentialParams { + credentialId: string + workspaceId: string + reason: CredentialDeleteReason +} + /** * Clears all stored references to the credential, deletes the row, and * records an audit entry. Idempotent when the row no longer exists. @@ -71,6 +77,63 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason }) } +/** Clears references and deletes one connection without surface audit attribution. */ +export async function deleteConnectionCredential( + params: DeleteConnectionCredentialParams +): Promise { + const { credentialId, workspaceId } = params + await clearCredentialRefs(credentialId, workspaceId) + const deleted = await db + .delete(schema.credential) + .where( + and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) + ) + .returning({ id: schema.credential.id }) + if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows') + + if (deleted.length === 1) { + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) + } + return deleted.length === 1 +} + +/** + * Deletes the stored OAuth grant behind a credential once nothing references + * it. The `account` row is an OAuth credential's secret source, so leaving it + * behind keeps a usable grant; nothing is revoked provider-side. + * + * Scoped by `accountId`, not by owner — the caller is already authorized + * against the credential, which may belong to another user. + * + * The reference check is a predicate on the delete: `credential.accountId` is + * `ON DELETE CASCADE`, so a credential racing a separate check would be reaped + * by Postgres without {@link clearCredentialRefs} ever running. + */ +export async function deleteOrphanedOAuthAccount(accountId: string): Promise { + const deleted = await db + .delete(schema.account) + .where( + and( + eq(schema.account.id, accountId), + notExists( + db + .select({ referenced: sql`1` }) + .from(schema.credential) + .where(eq(schema.credential.accountId, accountId)) + ) + ) + ) + .returning({ id: schema.account.id }) + + if (deleted.length > 0) { + logger.info('Deleted orphaned OAuth account', { accountId }) + } +} + /** * Clears stored references to a credential across mutable workspace state * (editor blocks, copilot checkpoints, knowledge connectors) and frozen diff --git a/apps/sim/lib/credentials/draft-constants.ts b/apps/sim/lib/credentials/draft-constants.ts new file mode 100644 index 00000000000..26a244bf02c --- /dev/null +++ b/apps/sim/lib/credentials/draft-constants.ts @@ -0,0 +1,3 @@ +export const CREDENTIAL_DRAFT_TTL_MS = 15 * 60 * 1000 +export const CREDENTIAL_DRAFT_TTL_SECONDS = CREDENTIAL_DRAFT_TTL_MS / 1000 +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts new file mode 100644 index 00000000000..5737c26ac23 --- /dev/null +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + auditMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + clearDeadFlag: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/oauth/terminal-errors', () => ({ clearDeadFlag: mocks.clearDeadFlag })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { + handleCreateCredentialFromDraft, + handleReconnectCredential, +} from '@/lib/credentials/draft-hooks' + +describe('handleCreateCredentialFromDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('treats a wrapped workspace-account conflict as an existing connection', async () => { + const now = new Date('2026-08-14T18:00:00.000Z') + const cause = Object.assign(new Error('duplicate key'), { + code: '23505', + constraint_name: 'credential_workspace_account_unique', + }) + dbChainMockFns.values.mockRejectedValueOnce(new Error('Failed query', { cause })) + queueTableRows(schemaMock.credential, [ + { id: 'credential-existing', displayName: 'Existing Gmail' }, + ]) + + await handleCreateCredentialFromDraft({ + draft: { + workspaceId: 'workspace-1', + displayName: 'New Gmail', + description: null, + }, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now, + }) + + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credential) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: now }) + expect(mocks.clearDeadFlag).toHaveBeenCalledWith('account-1') + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'credential.reconnected', + resourceId: 'credential-existing', + resourceName: 'Existing Gmail', + }) + ) + }) + + it('rethrows a different unique constraint failure', async () => { + const cause = Object.assign(new Error('duplicate key'), { + code: '23505', + constraint_name: 'credential_display_name_unique', + }) + const wrapped = new Error('Failed query', { cause }) + dbChainMockFns.values.mockRejectedValueOnce(wrapped) + + await expect( + handleCreateCredentialFromDraft({ + draft: { + workspaceId: 'workspace-1', + displayName: 'New Gmail', + description: null, + }, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: new Date('2026-08-14T18:00:00.000Z'), + }) + ).rejects.toBe(wrapped) + }) +}) + +describe('handleReconnectCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits a reconnect with the credential current name instead of draft presentation', async () => { + queueTableRows(schemaMock.credential, [ + { id: 'credential-1', accountId: null, displayName: 'Renamed Gmail' }, + ]) + queueTableRows(schemaMock.credential, []) + + await handleReconnectCredential({ + draft: { credentialId: 'credential-1' }, + newAccountId: 'account-new', + workspaceId: 'workspace-1', + userId: 'user-1', + now: new Date('2026-08-14T18:00:00.000Z'), + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'credential-1', + resourceName: 'Renamed Gmail', + description: 'Reconnected OAuth credential "Renamed Gmail" to a new account', + }) + ) + }) +}) diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index e467f56609e..79852f7824a 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -2,8 +2,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' +import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { captureServerEvent } from '@/lib/posthog/server' @@ -35,65 +37,103 @@ export async function handleCreateCredentialFromDraft(params: { createdAt: now, updatedAt: now, }) + } catch (insertError: unknown) { + if ( + getPostgresErrorCode(insertError) !== '23505' || + getPostgresConstraintName(insertError) !== 'credential_workspace_account_unique' + ) { + throw insertError + } - await db.insert(schema.credentialMember).values({ - id: generateId(), - credentialId, - userId, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: userId, - createdAt: now, - updatedAt: now, - }) + const [existingCredential] = await db + .select({ id: schema.credential.id, displayName: schema.credential.displayName }) + .from(schema.credential) + .where( + and( + eq(schema.credential.workspaceId, draft.workspaceId), + eq(schema.credential.accountId, accountId) + ) + ) + .limit(1) - logger.info('Created credential from draft', { - credentialId, - displayName: draft.displayName, + if (!existingCredential) { + throw new Error( + `Credential account conflict was reported without an existing credential for account ${accountId}` + ) + } + + logger.info('OAuth account is already connected to this workspace', { + credentialId: existingCredential.id, + displayName: existingCredential.displayName, providerId, accountId, }) + await db + .update(schema.credential) + .set({ updatedAt: now }) + .where(eq(schema.credential.id, existingCredential.id)) + await clearDeadFlag(accountId) recordAudit({ workspaceId: draft.workspaceId, actorId: userId, - action: AuditAction.CREDENTIAL_CREATED, + action: AuditAction.CREDENTIAL_RECONNECTED, resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: draft.displayName, - description: `Created OAuth credential "${draft.displayName}"`, - metadata: { providerId, accountId }, + resourceId: existingCredential.id, + resourceName: existingCredential.displayName, + description: `Reconnected OAuth credential "${existingCredential.displayName}"`, + metadata: { accountId }, }) + return + } - captureServerEvent( - userId, - 'credential_connected', - { - credential_type: 'oauth', - provider_id: providerId, - workspace_id: draft.workspaceId, - }, - { - groups: { workspace: draft.workspaceId }, - setOnce: { first_credential_connected_at: now.toISOString() }, - } - ) - } catch (insertError: unknown) { - const code = - insertError && typeof insertError === 'object' && 'code' in insertError - ? (insertError as { code: string }).code - : undefined - if (code !== '23505') { - throw insertError + await db.insert(schema.credentialMember).values({ + id: generateId(), + credentialId, + userId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + + logger.info('Created credential from draft', { + credentialId, + displayName: draft.displayName, + providerId, + accountId, + }) + + await clearDeadFlag(accountId) + + recordAudit({ + workspaceId: draft.workspaceId, + actorId: userId, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: draft.displayName, + description: `Created OAuth credential "${draft.displayName}"`, + metadata: { providerId, accountId }, + }) + + captureServerEvent( + userId, + 'credential_connected', + { + credential_type: 'oauth', + provider_id: providerId, + workspace_id: draft.workspaceId, + }, + { + groups: { workspace: draft.workspaceId }, + setOnce: { first_credential_connected_at: now.toISOString() }, } - logger.info('Credential already exists, skipping draft', { - providerId, - accountId, - }) - } + ) } /** @@ -105,7 +145,7 @@ export async function handleCreateCredentialFromDraft(params: { * the dead flag. Callers treat that timestamp as proof the reconnect landed. */ export async function handleReconnectCredential(params: { - draft: { credentialId: string | null; workspaceId: string; displayName: string } + draft: { credentialId: string | null } newAccountId: string workspaceId: string userId: string @@ -115,19 +155,21 @@ export async function handleReconnectCredential(params: { if (!draft.credentialId) return const [existingCredential] = await db - .select({ id: schema.credential.id, accountId: schema.credential.accountId }) + .select({ + id: schema.credential.id, + accountId: schema.credential.accountId, + displayName: schema.credential.displayName, + }) .from(schema.credential) .where(eq(schema.credential.id, draft.credentialId)) .limit(1) if (!existingCredential) { - logger.warn('Credential not found for reconnect, skipping', { - credentialId: draft.credentialId, - }) - return + throw new Error(`Cannot reconnect missing credential ${draft.credentialId}`) } const oldAccountId = existingCredential.accountId + const displayName = existingCredential.displayName const accountChanged = oldAccountId !== newAccountId if (accountChanged) { @@ -144,12 +186,9 @@ export async function handleReconnectCredential(params: { .limit(1) if (conflicting) { - logger.warn('New account already used by another credential, skipping reconnect', { - credentialId: draft.credentialId, - newAccountId, - conflictingCredentialId: conflicting.id, - }) - return + throw new Error( + `Cannot reconnect credential ${draft.credentialId}: account ${newAccountId} is already used by credential ${conflicting.id}` + ) } } @@ -177,23 +216,14 @@ export async function handleReconnectCredential(params: { action: AuditAction.CREDENTIAL_RECONNECTED, resourceType: AuditResourceType.CREDENTIAL, resourceId: draft.credentialId, - resourceName: draft.displayName, + resourceName: displayName, description: accountChanged - ? `Reconnected OAuth credential "${draft.displayName}" to a new account` - : `Reconnected OAuth credential "${draft.displayName}"`, + ? `Reconnected OAuth credential "${displayName}" to a new account` + : `Reconnected OAuth credential "${displayName}"`, metadata: { oldAccountId, newAccountId }, }) if (oldAccountId) { - const [stillReferenced] = await db - .select({ id: schema.credential.id }) - .from(schema.credential) - .where(eq(schema.credential.accountId, oldAccountId)) - .limit(1) - - if (!stillReferenced) { - await db.delete(schema.account).where(eq(schema.account.id, oldAccountId)) - logger.info('Deleted orphaned account after reconnect', { accountId: oldAccountId }) - } + await deleteOrphanedOAuthAccount(oldAccountId) } } diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts new file mode 100644 index 00000000000..00401d165d4 --- /dev/null +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + drizzleOrmMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleCreateCredentialFromDraft, mockHandleReconnectCredential } = vi.hoisted(() => ({ + mockHandleCreateCredentialFromDraft: vi.fn(), + mockHandleReconnectCredential: vi.fn(), +})) + +vi.mock('@/lib/credentials/draft-hooks', () => ({ + handleCreateCredentialFromDraft: mockHandleCreateCredentialFromDraft, + handleReconnectCredential: mockHandleReconnectCredential, +})) + +import { + captureOAuthCredentialDraftBinding, + consumeOAuthCredentialDraftBinding, + loadOAuthCredentialDraftBinding, + parseCredentialDraftIdFromCallbackUrl, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' + +function credentialDraft(id: string, workspaceId: string) { + return { + id, + userId: 'user-1', + workspaceId, + providerId: 'google-email', + displayName: 'Work Gmail', + description: null, + credentialId: null, + expiresAt: new Date('2026-08-14T18:15:00.000Z'), + createdAt: new Date('2026-08-14T18:00:00.000Z'), + } +} + +describe('processCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('processes only the exact draft bound to the OAuth state', async () => { + const draft = credentialDraft('draft-2', 'workspace-2') + queueTableRows(schemaMock.pendingCredentialDraft, [draft]) + + await processCredentialDraft({ + draftId: 'draft-2', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft.id, 'draft-2') + expect(mockHandleCreateCredentialFromDraft).toHaveBeenCalledWith({ + draft, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: expect.any(Date), + }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft) + }) + + it('fails closed when a legacy callback has multiple active drafts', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, [ + credentialDraft('draft-1', 'workspace-1'), + credentialDraft('draft-2', 'workspace-2'), + ]) + + await expect( + processCredentialDraft({ + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process an ambiguous OAuth credential draft for user user-1 and provider google-email' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + }) + + it('fails when an exact draft is missing or expired', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, []) + + await expect( + processCredentialDraft({ + draftId: 'draft-missing', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process missing or expired OAuth credential draft draft-missing for user user-1' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) +}) + +describe('parseCredentialDraftIdFromCallbackUrl', () => { + it('extracts the exact draft id from a valid callback URL', () => { + expect( + parseCredentialDraftIdFromCallbackUrl( + 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-1' + ) + ).toBe('draft-1') + }) + + it('fails closed for malformed or non-string callback state', () => { + expect(() => parseCredentialDraftIdFromCallbackUrl({})).toThrow( + 'OAuth state callback URL must be a string' + ) + expect(() => parseCredentialDraftIdFromCallbackUrl('not a URL')).toThrow() + }) +}) + +describe('loadOAuthCredentialDraftBinding', () => { + it('returns the exact draft id when OAuth state is readable', async () => { + await expect( + loadOAuthCredentialDraftBinding(async () => ({ + callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-exact', + })) + ).resolves.toEqual({ status: 'available', draftId: 'draft-exact' }) + }) + + it('marks unreadable OAuth state unavailable instead of permitting legacy draft fallback', async () => { + const stateError = new Error('OAuth state is unavailable') + + await expect( + loadOAuthCredentialDraftBinding(async () => { + throw stateError + }) + ).resolves.toEqual({ status: 'unavailable', error: stateError }) + }) + + it('marks malformed callback state unavailable without throwing from the account hook', async () => { + const binding = await loadOAuthCredentialDraftBinding(async () => ({ callbackURL: null })) + + expect(binding.status).toBe('unavailable') + }) +}) + +describe('OAuth credential draft account hook binding', () => { + it('carries an exact binding from the account before-hook to the deferred after-hook', async () => { + const context = {} + + await captureOAuthCredentialDraftBinding(context, async () => ({ + callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-exact', + })) + + expect(consumeOAuthCredentialDraftBinding(context)).toEqual({ + status: 'available', + draftId: 'draft-exact', + }) + expect(consumeOAuthCredentialDraftBinding(context)).toBeUndefined() + }) + + it('fails before account creation when OAuth state cannot be captured', async () => { + const context = {} + const stateError = new Error('OAuth state is unavailable') + + await expect( + captureOAuthCredentialDraftBinding(context, async () => { + throw stateError + }) + ).rejects.toBe(stateError) + expect(consumeOAuthCredentialDraftBinding(context)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7b9f5cd931..c375dd8d48c 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -1,7 +1,8 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { handleCreateCredentialFromDraft, handleReconnectCredential, @@ -9,33 +10,113 @@ import { const logger = createLogger('CredentialDraftProcessor') +interface OAuthStateWithCallbackUrl { + callbackURL?: unknown +} + +export type OAuthCredentialDraftBinding = + | { status: 'available'; draftId?: string } + | { status: 'unavailable'; error: unknown } + +type AvailableOAuthCredentialDraftBinding = Extract< + OAuthCredentialDraftBinding, + { status: 'available' } +> + +const oauthCredentialDraftBindings = new WeakMap() + +/** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ +export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { + if (callbackUrl === undefined) return undefined + if (typeof callbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + return new URL(callbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined +} + +/** Reads an exact draft binding without falling back when OAuth state is unavailable. */ +export async function loadOAuthCredentialDraftBinding( + loadOAuthState: () => Promise +): Promise { + try { + const oauthState = await loadOAuthState() + return { + status: 'available', + draftId: parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL), + } + } catch (error) { + return { status: 'unavailable', error } + } +} + +/** Captures request-scoped OAuth state before Better Auth defers its account after-hook. */ +export async function captureOAuthCredentialDraftBinding( + context: object, + loadOAuthState: () => Promise +): Promise { + const binding = await loadOAuthCredentialDraftBinding(loadOAuthState) + if (binding.status === 'unavailable') throw binding.error + oauthCredentialDraftBindings.set(context, binding) +} + +/** Consumes the binding captured for the same Better Auth account-hook context. */ +export function consumeOAuthCredentialDraftBinding( + context: object +): AvailableOAuthCredentialDraftBinding | undefined { + const binding = oauthCredentialDraftBindings.get(context) + oauthCredentialDraftBindings.delete(context) + return binding +} + interface ProcessCredentialDraftParams { + draftId?: string userId: string providerId: string accountId: string } /** - * Looks up a pending credential draft for the given user/provider and processes it. + * Looks up a pending credential draft and processes it. + * Draft-backed OAuth launches pass the exact id. Legacy callers without one are + * accepted only when the user/provider pair has a single active draft. * Creates a new credential or reconnects an existing one depending on the draft state. * Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello). */ export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise { - const { userId, providerId, accountId } = params + const { draftId, userId, providerId, accountId } = params - const [draft] = await db + const predicates = [ + eq(schema.pendingCredentialDraft.userId, userId), + eq(schema.pendingCredentialDraft.providerId, providerId), + sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`, + ] + if (draftId) { + predicates.push(eq(schema.pendingCredentialDraft.id, draftId)) + } + + const drafts = await db .select() .from(schema.pendingCredentialDraft) - .where( - and( - eq(schema.pendingCredentialDraft.userId, userId), - eq(schema.pendingCredentialDraft.providerId, providerId), - sql`${schema.pendingCredentialDraft.expiresAt} > NOW()` - ) + .where(and(...predicates)) + .orderBy(desc(schema.pendingCredentialDraft.createdAt)) + .limit(draftId ? 1 : 2) + + if (!draftId && drafts.length > 1) { + throw new Error( + `Cannot process an ambiguous OAuth credential draft for user ${userId} and provider ${providerId}` ) - .limit(1) + } + + const [draft] = drafts - if (!draft) return + if (!draft) { + if (draftId) { + throw new Error( + `Cannot process missing or expired OAuth credential draft ${draftId} for user ${userId}` + ) + } + return + } const now = new Date() diff --git a/apps/sim/lib/credentials/members.test.ts b/apps/sim/lib/credentials/members.test.ts new file mode 100644 index 00000000000..fae51df0525 --- /dev/null +++ b/apps/sim/lib/credentials/members.test.ts @@ -0,0 +1,20 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { listCredentialMembershipsForUser } from '@/lib/credentials/members' + +describe('listCredentialMembershipsForUser', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('excludes managed OAuth credentials from ordinary memberships', async () => { + dbChainMockFns.where.mockResolvedValue([]) + + await listCredentialMembershipsForUser('user-1') + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) +}) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts new file mode 100644 index 00000000000..7c3f68047d8 --- /dev/null +++ b/apps/sim/lib/credentials/members.ts @@ -0,0 +1,266 @@ +import { db } from '@sim/db' +import { credential, credentialMember, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, ne } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isSharedCredentialType, requireOrdinaryCredentialType } from '@/lib/credentials/access' +import type { CredentialRow } from '@/lib/credentials/queries' +import { + getUserEntityPermissions, + getUsersWithPermissions, +} from '@/lib/workspaces/permissions/utils' + +export interface CredentialMemberView { + id: string + userId: string + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + joinedAt: Date | null + userName: string | null + userEmail: string | null + roleSource: 'explicit' | 'workspace-admin' +} + +export async function listCredentialMembers( + credential: CredentialRow +): Promise { + const explicitMembers = await db + .select({ + id: credentialMember.id, + userId: credentialMember.userId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + userName: user.name, + userEmail: user.email, + }) + .from(credentialMember) + .innerJoin(user, eq(credentialMember.userId, user.id)) + .where(eq(credentialMember.credentialId, credential.id)) + + const byUser = new Map( + explicitMembers.map((member) => [member.userId, { ...member, roleSource: 'explicit' as const }]) + ) + + if (isSharedCredentialType(credential.type)) { + const workspaceMembers = await getUsersWithPermissions(credential.workspaceId) + for (const workspaceMember of workspaceMembers) { + if (workspaceMember.permissionType !== 'admin') continue + const existing = byUser.get(workspaceMember.userId) + if (existing) { + existing.role = 'admin' + existing.status = 'active' + existing.roleSource = 'workspace-admin' + } else { + byUser.set(workspaceMember.userId, { + id: `workspace-admin-${workspaceMember.userId}`, + userId: workspaceMember.userId, + role: 'admin', + status: 'active', + joinedAt: null, + userName: workspaceMember.name, + userEmail: workspaceMember.email, + roleSource: 'workspace-admin', + }) + } + } + } + + return Array.from(byUser.values()) +} + +export interface UpsertCredentialMemberParams { + credential: CredentialRow + actorUserId: string + targetUserId: string + role: 'admin' | 'member' +} + +export interface UpsertCredentialMemberResult { + created: boolean + previousRole?: 'admin' | 'member' +} + +export async function upsertCredentialMember( + params: UpsertCredentialMemberParams +): Promise { + if (!isSharedCredentialType(params.credential.type)) { + throw new OrchestrationError('validation', 'Personal secrets cannot be shared') + } + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === null) { + throw new OrchestrationError( + 'validation', + 'Target user must belong to the credential workspace' + ) + } + if (targetWorkspacePermission === 'admin' && params.role !== 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be demoted' + ) + } + + const [existing] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId) + ) + ) + .limit(1) + const now = new Date() + if (existing) { + const previousRole = await db.transaction(async (tx) => { + const [current] = await tx + .select({ role: credentialMember.role }) + .from(credentialMember) + .where(eq(credentialMember.id, existing.id)) + .limit(1) + .for('update') + if (!current) throw new Error('Credential membership disappeared during update') + await tx + .update(credentialMember) + .set({ role: params.role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return current.role + }) + return { created: false, previousRole } + } + + await db.insert(credentialMember).values({ + id: generateId(), + credentialId: params.credential.id, + userId: params.targetUserId, + role: params.role, + status: 'active', + joinedAt: now, + invitedBy: params.actorUserId, + createdAt: now, + updatedAt: now, + }) + return { created: true } +} + +export async function removeCredentialMember(params: { + credential: CredentialRow + targetUserId: string +}): Promise { + const [target] = await db + .select({ id: credentialMember.id, role: credentialMember.role }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + if (!target) throw new OrchestrationError('not_found', 'Member not found') + + if (isSharedCredentialType(params.credential.type)) { + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be removed' + ) + } + } + + const revoked = await db.transaction(async (tx) => { + if (!isSharedCredentialType(params.credential.type) && target.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, target.id)) + return true + }) + if (!revoked) throw new OrchestrationError('validation', 'Cannot remove the last admin') +} + +export async function listCredentialMembershipsForUser(userId: string) { + const rows = await db + .select({ + membershipId: credentialMember.id, + credentialId: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + providerId: credential.providerId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + }) + .from(credentialMember) + .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) + .where(and(eq(credentialMember.userId, userId), ne(credential.type, 'managed_oauth'))) + return rows.map((row) => ({ ...row, type: requireOrdinaryCredentialType(row.type) })) +} + +export async function leaveCredentialMembership(params: { + userId: string + credentialId: string +}): Promise { + const [membership] = await db + .select() + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.userId, params.userId) + ) + ) + .limit(1) + if (!membership) throw new OrchestrationError('not_found', 'Membership not found') + if (membership.status !== 'active') return + + const revoked = await db.transaction(async (tx) => { + if (membership.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, membership.id)) + return true + }) + if (!revoked) { + throw new OrchestrationError('validation', 'Cannot leave credential as the last active admin') + } +} diff --git a/apps/sim/lib/credentials/oauth-accounts.ts b/apps/sim/lib/credentials/oauth-accounts.ts new file mode 100644 index 00000000000..705d926d5bc --- /dev/null +++ b/apps/sim/lib/credentials/oauth-accounts.ts @@ -0,0 +1,152 @@ +import { db } from '@sim/db' +import { account, credential, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { and, desc, eq, inArray, like, or } from 'drizzle-orm' +import { decodeJwt } from 'jose' +import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { OAuthProvider } from '@/lib/oauth' +import { parseProvider } from '@/lib/oauth' +import { providerIdsForService } from '@/lib/oauth/utils' + +const logger = createLogger('CredentialOAuthAccounts') + +interface GoogleIdToken { + email?: string + name?: string +} + +export async function listOAuthConnectionsForUser(userId: string): Promise { + const [accounts, userRecord] = await Promise.all([ + db.select().from(account).where(eq(account.userId, userId)), + db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1), + ]) + const userEmail = userRecord[0]?.email ?? null + const connections: OAuthConnection[] = [] + + for (const accountRow of accounts) { + const { baseProvider, featureType } = parseProvider(accountRow.providerId as OAuthProvider) + if (!baseProvider) continue + const scopes = accountRow.scope?.split(/\s+/).filter(Boolean) ?? [] + let displayName = '' + if (accountRow.idToken) { + try { + const decoded = decodeJwt(accountRow.idToken) + displayName = decoded.email || decoded.name || '' + } catch (error) { + logger.warn('Failed to decode OAuth account ID token', { accountId: accountRow.id, error }) + } + } + if (!displayName && baseProvider === 'github') { + displayName = `${accountRow.accountId} (GitHub)` + } + displayName ||= userEmail || `${accountRow.accountId} (${baseProvider})` + + const existing = connections.find((connection) => connection.provider === accountRow.providerId) + if (existing) { + existing.accounts.push({ id: accountRow.id, name: displayName }) + existing.scopes = Array.from(new Set([...existing.scopes, ...scopes])) + if (accountRow.updatedAt.getTime() > new Date(existing.lastConnected).getTime()) { + existing.lastConnected = accountRow.updatedAt.toISOString() + } + continue + } + connections.push({ + provider: accountRow.providerId, + baseProvider, + featureType, + isConnected: true, + scopes, + lastConnected: accountRow.updatedAt.toISOString(), + accounts: [{ id: accountRow.id, name: displayName }], + }) + } + + return connections +} + +export async function listConnectedAccountsForUser(params: { userId: string; provider?: string }) { + const whereConditions = [eq(account.userId, params.userId)] + if (params.provider) whereConditions.push(eq(account.providerId, params.provider)) + const rows = await db + .select({ + id: account.id, + accountId: account.accountId, + providerId: account.providerId, + credentialDisplayName: credential.displayName, + }) + .from(account) + .leftJoin(credential, eq(credential.accountId, account.id)) + .where(and(...whereConditions)) + .orderBy(desc(account.updatedAt)) + + const seen = new Map() + for (const row of rows) { + if (!seen.has(row.id)) seen.set(row.id, row) + } + return Array.from(seen.values()).map((row) => ({ + id: row.id, + accountId: row.accountId, + providerId: row.providerId, + displayName: row.credentialDisplayName || row.accountId || row.providerId, + })) +} + +export interface DisconnectOAuthAccountsParams { + userId: string + provider: string + providerId?: string + accountId?: string +} + +export class OAuthDisconnectPartialFailureError extends Error { + constructor( + readonly credentials: Array, + cause: unknown + ) { + const error = toError(cause) + super(error.message, { cause: error }) + this.name = 'OAuthDisconnectPartialFailureError' + } +} + +export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsParams) { + const accountFilter = params.accountId + ? and(eq(account.userId, params.userId), eq(account.id, params.accountId)) + : params.providerId + ? and(eq(account.userId, params.userId), eq(account.providerId, params.providerId)) + : and( + eq(account.userId, params.userId), + or( + inArray(account.providerId, providerIdsForService(params.provider)), + like(account.providerId, `${params.provider}-%`) + ) + ) + const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) + const targetAccountIds = targetAccounts.map((row) => row.id) + if (targetAccountIds.length === 0) return { credentials: [] } + + const credentialRows = await db + .select() + .from(credential) + .where(inArray(credential.accountId, targetAccountIds)) + const deletedCredentials: typeof credentialRows = [] + try { + for (const credentialRow of credentialRows) { + if (credentialRow.type !== 'oauth') { + throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + } + const deleted = await deleteCredentialRecord({ + credential: credentialRow, + reason: 'oauth_disconnect', + }) + if (deleted) deletedCredentials.push(credentialRow) + } + await db.delete(account).where(inArray(account.id, targetAccountIds)) + } catch (error) { + if (deletedCredentials.length === 0) throw error + throw new OAuthDisconnectPartialFailureError(deletedCredentials, error) + } + return { credentials: deletedCredentials } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 6120e5bec76..f795e1846e3 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -2,12 +2,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' @@ -82,7 +84,7 @@ export interface PerformCreateCredentialParams { * secrets exist, so the id must be known up front. */ id?: string - request?: NextRequest + request?: OrchestrationRequestContext } export interface PerformCreateCredentialResult { @@ -96,6 +98,8 @@ export interface PerformCreateCredentialResult { credential?: CredentialRow /** False when an existing credential matched the source and was returned instead. */ created?: boolean + /** Verified provider identity metadata for the application audit projection. */ + auditMetadata?: Record } interface ExistingCredentialSourceParams { @@ -183,6 +187,18 @@ async function findExistingCredentialBySourceWith( return null } +async function serviceAccountSecretsMatch( + existingEncryptedSecret: string | null, + submittedEncryptedSecret: string | null +): Promise { + if (!existingEncryptedSecret || !submittedEncryptedSecret) return false + const [existing, submitted] = await Promise.all([ + decryptSecret(existingEncryptedSecret), + decryptSecret(submittedEncryptedSecret), + ]) + return safeCompare(existing.decrypted, submitted.decrypted) +} + function failure( error: string, errorCode: CredentialOrchestrationErrorCode, @@ -191,14 +207,17 @@ function failure( return { success: false, error, errorCode, ...extra } } -export async function performCreateCredential( - params: PerformCreateCredentialParams +export async function createCredentialRecord( + params: PerformCreateCredentialParams, + options: { authorizeWorkspace: boolean } ): Promise { const { workspaceId, type, userId } = params try { - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { + const workspaceAccess = options.authorizeWorkspace + ? await checkWorkspaceAccess(workspaceId, userId) + : undefined + if (workspaceAccess && !workspaceAccess.canWrite) { return failure('Write permission required', 'forbidden') } @@ -320,12 +339,11 @@ export async function performCreateCredential( ) } - /** - * Token service-account creates always carry a fresh token that must be - * stored — falling through to the existing-credential path would return - * the old credential as success and silently drop the submitted token. - */ - if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + if ( + type === 'service_account' && + resolvedProviderId && + isTokenServiceAccountProviderId(resolvedProviderId) + ) { return failure( `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, 'conflict', @@ -334,13 +352,34 @@ export async function performCreateCredential( } const access = await getCredentialActorContext(existingCredential.id, userId, { - workspaceAccess, + ...(workspaceAccess ? { workspaceAccess } : {}), }) if (!access.member && !access.isAdmin) { return failure('A credential with this source already exists in this workspace', 'conflict') } + /** + * Non-token service accounts may replay only the exact stored secret. A + * source match with rotated secret material must not report success while + * silently retaining the old ciphertext. Compare only after credential + * access is established so the encrypted value stays behind its resource + * authorization boundary. + */ + if ( + type === 'service_account' && + !(await serviceAccountSecretsMatch( + existingCredential.encryptedServiceAccountKey, + resolvedEncryptedServiceAccountKey + )) + ) { + return failure( + `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + const shouldUpdateDisplayName = type === 'oauth' && resolvedDisplayName && @@ -486,37 +525,7 @@ export async function performCreateCredential( .where(eq(credential.id, credentialId)) .limit(1) - captureServerEvent( - userId, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - // Provider metadata spreads first so this path's own keys stay - // authoritative and can never be shadowed, matching the update path. - ...extraAuditMetadata, - credentialType: type, - providerId: resolvedProviderId, - }, - request: params.request, - }) - - return { success: true, credential: created, created: true } + return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { @@ -572,6 +581,64 @@ export async function performCreateCredential( } } +export type CreateServiceAccountCredentialParams = Omit< + PerformCreateCredentialParams, + 'type' | 'actorName' | 'actorEmail' +> & { providerId: string } + +/** Creates and verifies one service-account credential without surface side effects. */ +export function createServiceAccountCredential( + params: CreateServiceAccountCredentialParams +): Promise { + return createCredentialRecord( + { ...params, type: 'service_account' }, + { authorizeWorkspace: false } + ) +} + +/** Preserves the legacy internal surface's analytics and audit behavior. */ +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const result = await createCredentialRecord(params, { authorizeWorkspace: true }) + if (!result.success || !result.created) return result + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + + captureServerEvent( + params.userId, + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: result.credential.workspaceId, + }, + { + groups: { workspace: result.credential.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId: result.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + request: params.request, + }) + + return result +} + /** * Provider error codes that mean the upstream service could not be reached, * rather than that the caller's secret was rejected. Each provider family names diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 65da20e8275..47064858db5 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -17,6 +17,8 @@ const { mockVerifyAndBuildServiceAccountSecret, mockIsClientCredentialAccountProviderId, mockGetClientCredentialAccountDescriptor, + mockDeleteConnectionCredential, + mockDeleteOrphanedOAuthAccount, } = vi.hoisted(() => ({ mockRecordAudit: vi.fn(), mockGetCredentialActorContext: vi.fn(), @@ -26,6 +28,8 @@ const { // Only a descriptor carrying `defaultAuthMethod` is multi-grant; single-grant // providers must not trigger the stored-blob read for authMethod/username. mockGetClientCredentialAccountDescriptor: vi.fn(() => undefined), + mockDeleteConnectionCredential: vi.fn(), + mockDeleteOrphanedOAuthAccount: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -47,7 +51,10 @@ vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, getClientCredentialAccountDescriptor: mockGetClientCredentialAccountDescriptor, })) -vi.mock('@/lib/credentials/deletion', () => ({ deleteCredential: vi.fn() })) +vi.mock('@/lib/credentials/deletion', () => ({ + deleteConnectionCredential: mockDeleteConnectionCredential, + deleteOrphanedOAuthAccount: mockDeleteOrphanedOAuthAccount, +})) vi.mock('@/lib/credentials/environment', () => ({ deleteWorkspaceEnvCredentials: vi.fn(), syncPersonalEnvCredentialsForUser: vi.fn(), @@ -60,7 +67,11 @@ vi.mock('@/lib/credentials/token-service-accounts/errors', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -import { performUpdateCredential } from '@/lib/credentials/orchestration' +import { + createServiceAccountCredential, + deleteCredentialRecord, + performUpdateCredential, +} from '@/lib/credentials/orchestration' const OLD_EMAIL = 'old-sa@old-project.iam.gserviceaccount.com' const NEW_EMAIL = 'new-sa@new-project.iam.gserviceaccount.com' @@ -415,4 +426,184 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockRecordAudit).not.toHaveBeenCalled() }) + + it('conceals managed OAuth credentials from the ordinary update path', async () => { + mockCredential({ type: 'managed_oauth' }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + description: 'should not update', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) + +describe('createServiceAccountCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('rejects an existing service-account source instead of discarding the submitted secret', async () => { + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Production Zoom', + auditMetadata: {}, + principal: { kind: 'tenant', id: 'account-1' }, + }) + queueTableRows(schemaMock.credential, [ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + encryptedServiceAccountKey: 'old-cipher', + }, + ]) + mockDecryptSecret + .mockResolvedValueOnce({ decrypted: 'stored-secret' }) + .mockResolvedValueOnce({ decrypted: 'rotated-secret' }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client-id', + clientSecret: 'rotated-client-secret', + orgId: 'account-1', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'conflict', + providerErrorCode: 'duplicate_display_name', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockGetCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1', {}) + }) + + it('returns an accessible credential for an exact non-token secret replay', async () => { + const existingCredential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + encryptedServiceAccountKey: 'stored-cipher', + } + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'replay-cipher', + displayName: 'Production Zoom', + auditMetadata: {}, + principal: { kind: 'tenant', id: 'account-1' }, + }) + queueTableRows(schemaMock.credential, [existingCredential]) + mockDecryptSecret.mockResolvedValue({ decrypted: 'same-secret' }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-1', + }) + + expect(result).toMatchObject({ + success: true, + credential: existingCredential, + created: false, + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) + +describe('deleteCredentialRecord', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('rejects deleting a custom Slack bot used by an active Credential Group', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'group-1' }]) + + await expect( + deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'service_account', + providerId: 'slack-custom-bot', + } as never, + reason: 'user_delete', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Remove this custom Slack bot from its Credential Groups before deleting it.', + }) + expect(mockDeleteConnectionCredential).not.toHaveBeenCalled() + }) + + it('revokes the backing OAuth grant of a deleted oauth credential', async () => { + mockDeleteConnectionCredential.mockResolvedValueOnce(true) + + const deleted = await deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'oauth', + providerId: 'google-email', + accountId: 'acct-1', + } as never, + reason: 'user_delete', + }) + + expect(deleted).toBe(true) + expect(mockDeleteOrphanedOAuthAccount).toHaveBeenCalledWith('acct-1') + }) + + it('leaves the OAuth grant alone when the credential row was already gone', async () => { + mockDeleteConnectionCredential.mockResolvedValueOnce(false) + + const deleted = await deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'oauth', + providerId: 'google-email', + accountId: 'acct-1', + } as never, + reason: 'user_delete', + }) + + expect(deleted).toBe(false) + expect(mockDeleteOrphanedOAuthAccount).not.toHaveBeenCalled() + }) + + it('does not touch OAuth grants for a service-account credential', async () => { + mockDeleteConnectionCredential.mockResolvedValueOnce(true) + + await deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'service_account', + providerId: 'google-service-account', + accountId: 'acct-1', + } as never, + reason: 'user_delete', + }) + + expect(mockDeleteOrphanedOAuthAccount).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ff34fd0a244..2ca86cb8b07 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { decryptSecret } from '@/lib/core/security/encryption' import { listSlackCredentialGroupConfigurationsForBot } from '@/lib/credential-groups/provider-configuration' import { @@ -23,7 +24,11 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, } from '@/lib/credentials/client-credential-accounts/descriptors' -import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' +import { + type CredentialDeleteReason, + deleteConnectionCredential, + deleteOrphanedOAuthAccount, +} from '@/lib/credentials/deletion' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, @@ -42,8 +47,13 @@ import { import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +type CredentialRow = typeof credential.$inferSelect +export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { + type CreateServiceAccountCredentialParams, + createCredentialRecord, + createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, type PerformCreateCredentialResult, @@ -170,41 +180,26 @@ export interface PerformCredentialResult { workspaceId?: string updatedFields?: string[] previousDisplayName?: string + auditMetadata?: Record } -export async function performUpdateCredential( - params: PerformUpdateCredentialParams +export type UpdateCredentialRecordParams = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> & { credential: CredentialRow } + +/** Updates one already-authorized credential without surface authorization or audit. */ +export async function updateCredentialRecord( + params: UpdateCredentialRecordParams ): Promise { try { - const access = await getCredentialActorContext(params.credentialId, params.userId) - if (!access.credential) { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (access.credential.type === 'managed_oauth') { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return { - success: false, - error: 'Credential admin permission required', - errorCode: 'forbidden', - } - } - if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { - return { - success: false, - error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, - errorCode: 'validation', - } - } - const updates: Record = {} if (params.description !== undefined) { updates.description = params.description ?? null } if ( params.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') + (params.credential.type === 'oauth' || params.credential.type === 'service_account') ) { updates.displayName = params.displayName } @@ -229,8 +224,8 @@ export async function performUpdateCredential( params.username !== undefined let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined - if (hasRotationSecret && access.credential.type === 'service_account') { - const providerId = access.credential.providerId ?? '' + if (hasRotationSecret && params.credential.type === 'service_account') { + const providerId = params.credential.providerId ?? '' // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual @@ -260,15 +255,15 @@ export async function performUpdateCredential( // One read + decrypt at most, and only for the providers that can use it. const storedBlob = needsStoredDataCenter || needsStoredAuthMethod || needsStoredUsername || needsStoredIdentity - ? await readStoredSecretBlob(access.credential.id) + ? await readStoredSecretBlob(params.credential.id) : null try { const slackConfigurations = providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? await listSlackCredentialGroupConfigurationsForBot({ - workspaceId: access.credential.workspaceId, - slackBotCredentialId: access.credential.id, + workspaceId: params.credential.workspaceId, + slackBotCredentialId: params.credential.id, }) : [] if (slackConfigurations.length > 0) { @@ -326,7 +321,7 @@ export async function performUpdateCredential( const previousIdentity = deriveStoredDisplayName(storedBlob) if ( previousIdentity !== undefined && - previousIdentity === access.credential.displayName && + previousIdentity === params.credential.displayName && secret.displayName && secret.displayName !== previousIdentity ) { @@ -360,7 +355,7 @@ export async function performUpdateCredential( } if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { + if (params.credential.type === 'oauth' || params.credential.type === 'service_account') { return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' } } return { @@ -389,31 +384,12 @@ export async function performUpdateCredential( } const updatedFields = auditUpdatedFields(updates) - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - // Provider metadata first: the orchestration's own keys stay authoritative - // and can never be shadowed by a builder's audit payload. - metadata: { - ...rotatedAuditMetadata, - credentialType: access.credential.type, - updatedFields, - }, - request: params.request, - }) - return { success: true, - workspaceId: access.credential.workspaceId, + workspaceId: params.credential.workspaceId, updatedFields, - previousDisplayName: access.credential.displayName, + previousDisplayName: params.credential.displayName, + auditMetadata: rotatedAuditMetadata, } } catch (error) { if (error instanceof Error && error.message.includes('unique')) { @@ -428,6 +404,180 @@ export async function performUpdateCredential( } } +/** Preserves the legacy callers while application adapters migrate to the manager above. */ +export async function performUpdateCredential( + params: PerformUpdateCredentialParams +): Promise { + const access = await getCredentialActorContext(params.credentialId, params.userId) + if (!access.credential) { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (access.credential.type === 'managed_oauth') { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return { + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + } + } + if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { + return { + success: false, + error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, + errorCode: 'validation', + } + } + + const result = await updateCredentialRecord({ ...params, credential: access.credential }) + if (!result.success) return result + + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: access.credential.type, + updatedFields: result.updatedFields, + }, + request: params.request, + }) + + return result +} + +export interface DeleteCredentialRecordParams { + credential: CredentialRow + reason: CredentialDeleteReason +} + +/** Deletes one already-authorized credential and its backing secret source. */ +export async function deleteCredentialRecord( + params: DeleteCredentialRecordParams +): Promise { + const { credential: credentialRow } = params + + if (credentialRow.type === 'managed_oauth') { + throw new OrchestrationError('not_found', 'Credential not found') + } + + if (credentialRow.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + const [binding] = await db + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, credentialRow.workspaceId), + sql`EXISTS ( + SELECT 1 + FROM jsonb_array_elements(${credentialGroup.options}) AS option + WHERE option->>'slackBotCredentialId' = ${credentialRow.id} + AND option->>'status' = 'active' + )` + ) + ) + .limit(1) + if (binding) { + throw new OrchestrationError( + 'conflict', + 'Remove this custom Slack bot from its Credential Groups before deleting it.' + ) + } + } + + if (credentialRow.type === 'env_personal') { + if (!credentialRow.envKey || !credentialRow.envOwnerUserId) { + throw new Error('Personal environment credential is missing its source identity') + } + const [personalRow] = await db + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, credentialRow.envOwnerUserId)) + .limit(1) + const current = { ...((personalRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(environment) + .values({ + id: credentialRow.envOwnerUserId, + userId: credentialRow.envOwnerUserId, + variables: current, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables: current, updatedAt: new Date() }, + }) + await syncPersonalEnvCredentialsForUser({ + userId: credentialRow.envOwnerUserId, + envKeys: Object.keys(current), + }) + return true + } + + if (credentialRow.type === 'env_workspace') { + if (!credentialRow.envKey) { + throw new Error('Workspace environment credential is missing its source identity') + } + const [workspaceRow] = await db + .select({ + id: workspaceEnvironment.id, + createdAt: workspaceEnvironment.createdAt, + variables: workspaceEnvironment.variables, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, credentialRow.workspaceId)) + .limit(1) + const current = { ...((workspaceRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(workspaceEnvironment) + .values({ + id: workspaceRow?.id ?? generateId(), + workspaceId: credentialRow.workspaceId, + variables: current, + createdAt: workspaceRow?.createdAt ?? new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: current, updatedAt: new Date() }, + }) + await deleteWorkspaceEnvCredentials({ + workspaceId: credentialRow.workspaceId, + removedKeys: [credentialRow.envKey], + }) + return true + } + + if (credentialRow.type === 'oauth') { + const deleted = await deleteConnectionCredential({ + credentialId: credentialRow.id, + workspaceId: credentialRow.workspaceId, + reason: params.reason, + }) + if (deleted && credentialRow.accountId) { + await deleteOrphanedOAuthAccount(credentialRow.accountId) + } + return deleted + } + + return deleteConnectionCredential({ + credentialId: credentialRow.id, + workspaceId: credentialRow.workspaceId, + reason: params.reason, + }) +} + +/** Preserves the legacy callers while application adapters migrate to the manager above. */ export async function performDeleteCredential( params: CredentialActorParams ): Promise { @@ -454,176 +604,60 @@ export async function performDeleteCredential( } } - if (access.credential.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { - const [binding] = await db - .select({ id: credentialGroup.id }) - .from(credentialGroup) - .where( - and( - eq(credentialGroup.workspaceId, access.credential.workspaceId), - sql`EXISTS ( - SELECT 1 - FROM jsonb_array_elements(${credentialGroup.options}) AS option - WHERE option->>'slackBotCredentialId' = ${access.credential.id} - AND option->>'status' = 'active' - )` - ) - ) - .limit(1) - if (binding) { - return { - success: false, - error: 'Remove this custom Slack bot from its Credential Groups before deleting it.', - errorCode: 'conflict', - } - } - } - - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return { success: false, error: 'Invalid personal secret owner', errorCode: 'validation' } - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(environment) - .values({ id: ownerUserId, userId: ownerUserId, variables: current, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - await deleteCredential({ - credentialId: params.credentialId, - actorId: params.userId, - actorName: params.actorName, - actorEmail: params.actorEmail, - reason: params.reason ?? 'user_delete', - request: params.request, - }) + const reason = params.reason ?? 'user_delete' + await deleteCredentialRecord({ credential: access.credential, reason }) captureServerEvent( params.userId, 'credential_deleted', { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? params.credentialId, + credential_type: access.credential.type, + provider_id: + access.credential.providerId ?? access.credential.envKey ?? params.credentialId, workspace_id: access.credential.workspaceId, }, { groups: { workspace: access.credential.workspaceId } } ) + const envDescription = + access.credential.type === 'env_personal' + ? `Deleted personal env credential "${access.credential.envKey}"` + : access.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${access.credential.envKey}"` + : `Deleted ${access.credential.type} credential "${access.credential.displayName}" (${reason})` + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: envDescription, + metadata: { + reason, + credentialType: access.credential.type, + providerId: access.credential.providerId, + accountId: access.credential.accountId, + envKey: access.credential.envKey, + }, + request: params.request, + }) + return { success: true, workspaceId: access.credential.workspaceId } } catch (error) { + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + if (orchestrationError.code !== 'not_found' && orchestrationError.code !== 'conflict') { + throw orchestrationError + } + return { + success: false, + error: orchestrationError.message, + errorCode: orchestrationError.code, + } + } logger.error('Failed to delete credential', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index e5ff19c7d1a..efe7459628e 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -4,6 +4,9 @@ import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' import { + findWorkspaceCredentialLookup, + getCredentialById, + getWorkspaceCredential, listVisibleWorkspaceCredentials, listWorkspacePrincipalCredentials, } from '@/lib/credentials/queries' @@ -130,3 +133,31 @@ describe('listWorkspacePrincipalCredentials', () => { ).rejects.toBe(failure) }) }) + +describe('ordinary credential lookups', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it.each([ + [ + 'workspace credential', + () => getWorkspaceCredential({ workspaceId: 'workspace-1', credentialId: 'credential-1' }), + ], + ['credential by id', () => getCredentialById('credential-1')], + [ + 'legacy id/account lookup', + () => + findWorkspaceCredentialLookup({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }), + ], + ])('excludes managed OAuth from the %s path', async (_name, lookup) => { + dbChainMockFns.limit.mockResolvedValue([]) + + await lookup() + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) +}) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 92122ebf37f..4781e70b8f6 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -15,7 +15,12 @@ import { textKey, timestampKey, } from '@/lib/api/list-query' -import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' +import { + isSharedCredentialType, + type OrdinaryCredentialType, + requireOrdinaryCredentialType, + SHARED_CREDENTIAL_TYPES, +} from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' /** @@ -42,6 +47,13 @@ export interface VisibleWorkspaceCredential { role: 'admin' | 'member' } +export interface WorkspaceCredentialLookup { + id: string + displayName: string + type: OrdinaryCredentialType + providerId: string | null +} + const credentialIdKey = textKey(credential.id, (row) => row.id) /** @@ -264,3 +276,75 @@ export async function listWorkspacePrincipalCredentials(params: { return keysetPage(keys, mapped, limit) } +/** + * A single credential scoped to a workspace, or null when it does not exist + * there. Scoping by workspace is what keeps a credential id from another tenant + * from resolving at all. + */ +export async function getWorkspaceCredential(params: { + workspaceId: string + credentialId: string +}): Promise { + const [row] = await db + .select() + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + return row ?? null +} + +/** Preserves the internal route's legacy id-first, account-id-second lookup semantics. */ +export async function findWorkspaceCredentialLookup(params: { + workspaceId: string + credentialId: string +}): Promise { + const projection = { + id: credential.id, + displayName: credential.displayName, + type: credential.type, + providerId: credential.providerId, + } + const [byId] = await db + .select(projection) + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + if (byId) return { ...byId, type: requireOrdinaryCredentialType(byId.type) } + + const [byAccountId] = await db + .select(projection) + .from(credential) + .where( + and( + eq(credential.accountId, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + return byAccountId + ? { ...byAccountId, type: requireOrdinaryCredentialType(byAccountId.type) } + : null +} + +/** Canonical credential lookup used before its workspace scope is known. */ +export async function getCredentialById(credentialId: string): Promise { + const [row] = await db + .select() + .from(credential) + .where(and(eq(credential.id, credentialId), ne(credential.type, 'managed_oauth'))) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts b/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts new file mode 100644 index 00000000000..7eb9797c8ca --- /dev/null +++ b/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + * + * Guards the isolate hardening contract in `isolated-vm-worker.cjs`: no raw + * `ivm.Reference` host bridge may survive as an isolate global once user code + * runs. Each bootstrap must capture its bridges in a closure and list their + * global names in its own `undefined_globals`. + * + * The two execution paths harden independently, so every assertion is scoped to + * one path's source slice — a bridge installed by `executeCode` but undefined + * only by `executeTask` must still fail. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const WORKER_SOURCE = readFileSync(join(__dirname, 'isolated-vm-worker.cjs'), 'utf8') + +const EXECUTE_CODE_MARKER = 'async function executeCode(' +const EXECUTE_TASK_MARKER = 'async function executeTask(' + +/** + * Source of one execution path, from its function declaration to the start of + * the next one (or end of file for the last path). + */ +function pathSource(marker: string, nextMarker?: string): string { + const start = WORKER_SOURCE.indexOf(marker) + expect(start, `${marker} not found — worker layout changed`).toBeGreaterThan(-1) + const end = nextMarker ? WORKER_SOURCE.indexOf(nextMarker, start) : WORKER_SOURCE.length + expect(end, `${nextMarker} not found — worker layout changed`).toBeGreaterThan(start) + return WORKER_SOURCE.slice(start, end) +} + +const EXECUTION_PATHS = [ + { name: 'executeCode', source: pathSource(EXECUTE_CODE_MARKER, EXECUTE_TASK_MARKER) }, + { name: 'executeTask', source: pathSource(EXECUTE_TASK_MARKER) }, +] + +/** + * Globals bound to a raw `ivm.Reference`. The worker names these `__*Ref`; + * `ivm.Callback` bridges (`__log`, `__textEncode`, …) are plain isolate + * functions that expose no host handle and are deliberately not matched. + */ +function referenceBridges(source: string): string[] { + return [...source.matchAll(/jail\.set\('(__\w+Ref)'/g)].map((match) => match[1]) +} + +function hardeningList(source: string): string { + const list = source.match(/const undefined_globals = \[[\s\S]*?\]/) + expect(list, 'no undefined_globals hardening list in this execution path').not.toBeNull() + return (list as RegExpMatchArray)[0] +} + +describe('isolated-vm worker hardening', () => { + it.each(EXECUTION_PATHS)( + '$name undefines every ivm.Reference bridge it installs', + ({ name, source }) => { + const bridges = referenceBridges(source) + expect( + bridges.length, + `${name} installs no __*Ref bridges — detection is stale` + ).toBeGreaterThan(0) + + const list = hardeningList(source) + for (const bridge of bridges) { + expect( + list.includes(`'${bridge}'`), + `${name} sets ${bridge} as an isolate global but its hardening list omits it` + ).toBe(true) + } + } + ) + + it.each(EXECUTION_PATHS)('$name undefines the isolated-vm escape globals', ({ source }) => { + const list = hardeningList(source) + for (const name of ['Isolate', 'Context', 'Script', 'Reference', 'ExternalCopy']) { + expect(list).toContain(`'${name}'`) + } + }) +}) diff --git a/apps/sim/lib/execution/isolated-vm-worker.cjs b/apps/sim/lib/execution/isolated-vm-worker.cjs index 0ccc4387943..ac19d487b21 100644 --- a/apps/sim/lib/execution/isolated-vm-worker.cjs +++ b/apps/sim/lib/execution/isolated-vm-worker.cjs @@ -310,8 +310,12 @@ async function executeCode(request, executionId) { info: (...args) => __log(...args), }; - // Set up fetch function that uses the host's secure fetch - async function fetch(url, options) { + // Set up fetch function that uses the host's secure fetch. The raw + // host bridge is captured in this closure so the hardening step below + // can undefine the global without breaking fetch(). + (() => { + const __fetch = globalThis.__fetchRef; + const fetchImpl = async function fetch(url, options) { let optionsJson; if (options) { try { @@ -323,7 +327,7 @@ async function executeCode(request, executionId) { throw new Error('fetch options exceed maximum payload size'); } } - const resultJson = await __fetchRef.apply(undefined, [url, optionsJson], { result: { promise: true } }); + const resultJson = await __fetch.apply(undefined, [url, optionsJson], { result: { promise: true } }); let result; try { result = JSON.parse(resultJson); @@ -355,7 +359,16 @@ async function executeCode(request, executionId) { blob: async () => { throw new Error('blob() not supported in sandbox'); }, arrayBuffer: async () => { throw new Error('arrayBuffer() not supported in sandbox'); }, }; - } + }; + // Same property attributes a top-level \`function fetch\` declaration + // produced, so user code sees an unchanged global. + Object.defineProperty(global, 'fetch', { + value: fetchImpl, + writable: true, + enumerable: true, + configurable: false + }); + })(); const sim = (() => { const broker = __brokerRef; @@ -408,7 +421,7 @@ async function executeCode(request, executionId) { const undefined_globals = [ 'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference', 'ExternalCopy', 'process', 'require', 'module', 'exports', '__dirname', '__filename', - '__brokerRef', '__broker', '__callSimBroker' + '__fetchRef', '__brokerRef', '__broker', '__callSimBroker' ]; for (const name of undefined_globals) { try { diff --git a/apps/sim/lib/folders/bulk.test.ts b/apps/sim/lib/folders/bulk.test.ts new file mode 100644 index 00000000000..08bb9092973 --- /dev/null +++ b/apps/sim/lib/folders/bulk.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListActiveFolderRows } = vi.hoisted(() => ({ + mockListActiveFolderRows: vi.fn(), +})) + +vi.mock('@/lib/folders/queries', () => ({ + listActiveFolderRows: mockListActiveFolderRows, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + deleteFolder: vi.fn(), + updateFolder: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyFolderResourceChanged: vi.fn(), +})) + +import { planFolderSelection } from '@/lib/folders/bulk' + +/** + * `a` holds `a1`, which holds `a1x`. `b` is a sibling with nothing inside it, so a plan can + * distinguish "carried by an ancestor" from "selected in its own right". + */ +const TREE = [ + { id: 'a', name: 'A', parentId: null }, + { id: 'a1', name: 'A1', parentId: 'a' }, + { id: 'a1x', name: 'A1X', parentId: 'a1' }, + { id: 'b', name: 'B', parentId: null }, +] + +describe('planFolderSelection', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListActiveFolderRows.mockResolvedValue(TREE) + }) + + const plan = (folderIds: string[]) => planFolderSelection('ws-1', 'table', folderIds) + + it('selects a folder and reports nothing contained', async () => { + const result = await plan(['a']) + expect(result.selected).toEqual([{ id: 'a', name: 'A' }]) + expect(result.contained).toEqual([]) + expect([...result.covered].sort()).toEqual(['a', 'a1', 'a1x']) + }) + + it('reports an explicitly selected descendant as contained, not as a second selection', async () => { + const result = await plan(['a1', 'a']) + expect(result.selected).toEqual([{ id: 'a', name: 'A' }]) + expect(result.contained).toEqual([{ id: 'a1', name: 'A1' }]) + }) + + it('reports the same selection identically whichever order the ids arrive in', async () => { + // Regression: the ancestor marked its descendants covered, and testing `covered` before + // containment then dropped an explicitly requested descendant from every outcome + // category — so reversing the input silently changed what the API reported. + const ancestorFirst = await plan(['a', 'a1']) + const descendantFirst = await plan(['a1', 'a']) + + expect(ancestorFirst.selected).toEqual(descendantFirst.selected) + expect(ancestorFirst.contained).toEqual(descendantFirst.contained) + expect(ancestorFirst.contained).toEqual([{ id: 'a1', name: 'A1' }]) + }) + + it('never drops a requested folder from every outcome category', async () => { + for (const order of [ + ['a', 'a1', 'a1x'], + ['a1x', 'a1', 'a'], + ['a1', 'a1x', 'a'], + ]) { + const result = await plan(order) + const accounted = new Set([ + ...result.selected.map((f) => f.id), + ...result.contained.map((f) => f.id), + ...result.notFound, + ]) + expect([...accounted].sort()).toEqual(['a', 'a1', 'a1x']) + } + }) + + it('reports ids that resolve to nothing as notFound', async () => { + const result = await plan(['b', 'ghost']) + expect(result.selected).toEqual([{ id: 'b', name: 'B' }]) + expect(result.notFound).toEqual(['ghost']) + }) + + it('accounts for a duplicated id exactly once', async () => { + const result = await plan(['b', 'b']) + expect(result.selected).toEqual([{ id: 'b', name: 'B' }]) + }) +}) diff --git a/apps/sim/lib/folders/bulk.ts b/apps/sim/lib/folders/bulk.ts new file mode 100644 index 00000000000..2e32c343132 --- /dev/null +++ b/apps/sim/lib/folders/bulk.ts @@ -0,0 +1,287 @@ +import { createLogger } from '@sim/logger' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { deleteFolder, updateFolder } from '@/lib/folders/orchestration' +import { listActiveFolderRows } from '@/lib/folders/queries' +import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' +import { notifyFolderResourceChanged } from '@/lib/realtime/notify' + +const logger = createLogger('FolderBulk') + +export interface BulkFolderAffected { + id: string + name: string +} + +export interface BulkFolderFailure extends BulkFolderAffected { + reason: string +} + +export interface FolderSelectionPlan { + /** Selected folders that resolve to an active folder of this resource type, request order preserved. */ + selected: BulkFolderAffected[] + /** Selected ids with no active folder of this resource type in the workspace. */ + notFound: string[] + /** + * Selected folders that sit inside another selected folder. They travel with + * their ancestor — moving or deleting them again would either rip a subfolder + * out of the parent it is moving with, or archive it under a second + * timestamp that the parent's restore could never bring back. + */ + contained: BulkFolderAffected[] + /** + * Every folder id the selection covers, including descendants. A resource + * filed in one of these is already handled by its folder and must not be + * acted on a second time. + */ + covered: Set +} + +/** + * Resolves a bulk folder selection against the workspace's live folder tree + * once, before anything is written. + * + * Reads the whole active tree in a single query rather than one lookup per + * selected id: the containment questions ("is this folder inside another + * selected one", "is this resource inside a selected folder") need the tree + * anyway, and a workspace's folder count is already bounded. + */ +export async function planFolderSelection( + workspaceId: string, + resourceType: FolderResourceType, + folderIds: readonly string[] +): Promise { + if (folderIds.length === 0) { + return { selected: [], notFound: [], contained: [], covered: new Set() } + } + + const rows = await listActiveFolderRows(workspaceId, resourceType, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const rowsById = new Map(rows.map((row) => [row.id, row])) + + const selected: BulkFolderAffected[] = [] + const notFound: string[] = [] + const contained: BulkFolderAffected[] = [] + const covered = new Set() + + const requested = new Set() + for (const folderId of folderIds) { + if (!rowsById.has(folderId)) { + notFound.push(folderId) + continue + } + requested.add(folderId) + } + + /** + * One `childrenByParent` index for the whole plan. Building it per requested + * folder would be O(rows) each time — up to `MAX_FOLDERS_PER_WORKSPACE` Map + * writes per selected folder, all but the first thrown away. + */ + const childrenByParent = indexFolderChildren(rows) + const descendantsOf = new Map() + for (const folderId of requested) { + descendantsOf.set(folderId, collectDescendantFolderIdsFrom(childrenByParent, folderId)) + } + + const insideAnotherSelection = new Set() + for (const [folderId, descendants] of descendantsOf) { + for (const descendantId of descendants) { + if (requested.has(descendantId)) insideAnotherSelection.add(descendantId) + } + } + + const reported = new Set() + for (const folderId of folderIds) { + const row = rowsById.get(folderId) + if (!row || reported.has(folderId)) continue + reported.add(folderId) + const entry = { id: row.id, name: row.name } + /** + * Containment is tested before `covered`, and that order matters: an explicitly requested + * folder that sits inside another selected folder must always be reported. Testing + * `covered` first made the outcome order-dependent — an ancestor processed earlier marked + * the descendant covered, so the descendant fell out of every outcome category and the + * same id set produced different results depending on the order it arrived in. + */ + if (insideAnotherSelection.has(folderId)) { + contained.push(entry) + continue + } + if (covered.has(folderId)) continue + selected.push(entry) + covered.add(folderId) + for (const descendantId of descendantsOf.get(folderId) ?? []) covered.add(descendantId) + } + + /** + * A contained folder's subtree is covered by its ancestor, but record it + * anyway: the ancestor's descendant walk and this one are the same set, and + * an explicit add keeps `covered` correct even if the tree contains a cycle + * the walk had to cut short. + */ + for (const folder of contained) { + covered.add(folder.id) + for (const descendantId of descendantsOf.get(folder.id) ?? []) covered.add(descendantId) + } + + return { selected, notFound, contained, covered } +} + +/** + * The halves of a plan a caller's bulk outcome absorbs verbatim: ids nothing + * resolved to, and folders a selected ancestor already carries. + * + * Declared as push sinks rather than concrete arrays so each domain can pass + * its own outcome arrays, whose element unions (`'table' | 'folder'`, + * `'knowledgeBase' | 'folder'`) are wider than the folder entries written into + * them. + */ +export interface FolderPlanSink { + notFound: { push(entry: { kind: 'folder'; id: string }): unknown } + skipped: { push(entry: { kind: 'folder'; id: string; name: string }): unknown } +} + +/** Projects a plan's unactionable halves into a bulk outcome. */ +export function foldFolderPlan(plan: FolderSelectionPlan, outcome: FolderPlanSink): void { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) +} + +export interface BulkFolderOutcome { + succeeded: BulkFolderAffected[] + failed: BulkFolderFailure[] +} + +export interface BulkFolderDeleteOutcome extends BulkFolderOutcome { + /** Folders archived across every cascade, including the selected folders themselves. */ + folderCount: number + /** Resources of `resourceType` archived by the cascades. */ + resourceCount: number +} + +/** + * Re-parents each selected folder under `targetParentId`. + * + * Best-effort per folder, matching the resource half of the same request. + * `updateFolder` owns the invariants a caller must not be trusted with — a + * folder cannot become its own parent, cannot move under one of its own + * descendants, and cannot cross into another workspace — and reports them as + * `validation` failures, which surface here as a per-folder reason. + * + * Per-folder realtime notification is suppressed and one batch notification is + * sent instead: every per-item notify carries an identical body and triggers an + * identical workspace-wide invalidation, so a 100-folder move would otherwise + * cost 100 sequential internal round trips and make every connected client + * refetch the same list 100 times for one gesture. The batch notify runs in a + * `finally`, so a batch that ends early on an internal fault still tells the + * clients about the folders it did move. + */ +export async function bulkMoveFolders(params: { + workspaceId: string + resourceType: FolderResourceType + userId: string + folders: readonly BulkFolderAffected[] + targetParentId: string | null +}): Promise { + const succeeded: BulkFolderAffected[] = [] + const failed: BulkFolderFailure[] = [] + + try { + for (const folder of params.folders) { + const result = await updateFolder( + { + resourceType: params.resourceType, + folderId: folder.id, + workspaceId: params.workspaceId, + userId: params.userId, + parentId: params.targetParentId, + }, + { notify: false } + ) + if (result.success && result.folder) { + succeeded.push({ id: result.folder.id, name: result.folder.name }) + continue + } + if (result.errorCode === 'internal') throw new Error(result.error ?? 'Failed to move folder') + failed.push({ ...folder, reason: result.error ?? 'Failed to move folder' }) + } + } finally { + if (succeeded.length > 0) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } + } + + logger.info('Bulk moved folders', { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + succeeded: succeeded.length, + failed: failed.length, + }) + return { succeeded, failed } +} + +/** + * Archives each selected folder and everything under it. + * + * `projectAudit: false` — the calling application use case projects + * `FOLDER_DELETED` from the authoritative result with full principal + * attribution, which the orchestration's own `actorId: userId` entry cannot + * express for a non-human principal. + * + * `notify: false` for the same reason as {@link bulkMoveFolders}: one batch + * notification replaces a per-folder storm of identical invalidations, and it + * fires from a `finally` so a batch cut short by an internal fault still + * announces the folders it did archive. + */ +export async function bulkDeleteFolders(params: { + workspaceId: string + resourceType: FolderResourceType + userId: string + folders: readonly BulkFolderAffected[] + countKey: 'tables' | 'knowledgeBases' +}): Promise { + const succeeded: BulkFolderAffected[] = [] + const failed: BulkFolderFailure[] = [] + let folderCount = 0 + let resourceCount = 0 + + try { + for (const folder of params.folders) { + const result = await deleteFolder( + { + resourceType: params.resourceType, + folderId: folder.id, + workspaceId: params.workspaceId, + userId: params.userId, + folderName: folder.name, + }, + { projectAudit: false, notify: false } + ) + if (result.success) { + succeeded.push(folder) + folderCount += result.deletedItems?.folders ?? 0 + resourceCount += result.deletedItems?.[params.countKey] ?? 0 + continue + } + if (result.errorCode === 'internal') + throw new Error(result.error ?? 'Failed to delete folder') + failed.push({ ...folder, reason: result.error ?? 'Failed to delete folder' }) + } + } finally { + if (succeeded.length > 0) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } + } + + logger.info('Bulk deleted folders', { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + succeeded: succeeded.length, + failed: failed.length, + folderCount, + resourceCount, + }) + return { succeeded, failed, folderCount, resourceCount } +} diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index a91a8a75e3c..cb4f2c3cbe1 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -665,7 +665,17 @@ export async function createFolder(params: CreateFolderParams): Promise { +export async function updateFolder( + params: UpdateFolderParams, + /** + * `notify: false` for a caller that mutates several folders in one gesture + * and sends a single batch notification of its own. Every per-folder notify + * carries an identical body and triggers an identical workspace-wide + * invalidation, so a batch would otherwise fan out one internal round trip + * per item. Omitted, the orchestration notifies as before. + */ + options?: { notify?: boolean } +): Promise { const config = folderResourceConfig(params.resourceType) try { @@ -748,7 +758,9 @@ export async function updateFolder(params: UpdateFolderParams): Promise { +export async function deleteFolder( + params: DeleteFolderParams, + /** + * `projectAudit: false` for a caller that projects `FOLDER_DELETED` itself — + * an application use case attributes the entry to the acting `Principal`, + * which the `actorId: userId` entry below cannot express for a non-human + * principal. Omitted, the orchestration keeps recording its own entry, so + * every existing caller is unchanged. + * + * `notify: false` for a caller deleting several folders in one gesture that + * sends a single batch notification of its own — see {@link bulkDeleteFolders}. + */ + options?: { projectAudit?: boolean; notify?: boolean } +): Promise { const existing = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { const [row] = await tx .select({ deletedAt: folderTable.deletedAt }) @@ -790,8 +815,8 @@ export async function deleteFolder(params: DeleteFolderParams): Promise { expect(collectDescendantFolderIds([], 'x')).toEqual([]) }) }) + +describe('collectDescendantFolderIdsFrom', () => { + /** + * The index-once path is what a bulk plan walks, so it must answer exactly + * what the rebuild-per-call path answers — including for the cycle case. + */ + it('matches the rebuild-per-call helper for every node in a tree', () => { + const index = indexFolderChildren(tree) + + for (const node of [...tree, { id: 'missing', parentId: null }]) { + expect(collectDescendantFolderIdsFrom(index, node.id).sort()).toEqual( + collectDescendantFolderIds(tree, node.id).sort() + ) + } + }) + + it('is reusable across folders without being rebuilt', () => { + const index = indexFolderChildren(tree) + + expect(collectDescendantFolderIdsFrom(index, 'a').sort()).toEqual(['a1', 'a1x', 'a2']) + expect(collectDescendantFolderIdsFrom(index, 'a').sort()).toEqual(['a1', 'a1x', 'a2']) + expect(collectDescendantFolderIdsFrom(index, 'b')).toEqual([]) + }) + + it('terminates on a parent cycle', () => { + const cyclic: FolderNode[] = [ + { id: 'x', parentId: 'y' }, + { id: 'y', parentId: 'x' }, + ] + + expect(collectDescendantFolderIdsFrom(indexFolderChildren(cyclic), 'x')).toEqual(['y']) + }) +}) + +describe('indexFolderChildren', () => { + it('keys children by parent and drops roots', () => { + const index = indexFolderChildren(tree) + + expect(index.get('root')).toEqual(['a', 'b']) + expect(index.get('a')).toEqual(['a1', 'a2']) + expect(index.has('other')).toBe(false) + }) +}) diff --git a/apps/sim/lib/folders/subtree.ts b/apps/sim/lib/folders/subtree.ts index 87cc3ec3285..42e727dd9cc 100644 --- a/apps/sim/lib/folders/subtree.ts +++ b/apps/sim/lib/folders/subtree.ts @@ -4,16 +4,18 @@ export interface FolderNode { parentId: string | null } +/** Child ids keyed by parent id — the shape a descendant walk reads. */ +export type FolderChildrenIndex = ReadonlyMap + /** - * Returns every descendant of `folderId` from a flat folder list, excluding `folderId` - * itself. The caller supplies the rows, so this stays a pure function usable against a - * query result, a transaction snapshot, or test fixtures. + * Indexes a flat folder list by parent id. * - * Indexes children by parent once up front rather than rescanning the list per level, and - * tracks `seen` so a cycle (which the DB permits between constraint checks) terminates the - * walk instead of recursing forever. + * Exported so a caller walking many folders of the same list builds the index + * once instead of once per folder: {@link collectDescendantFolderIds} rebuilds + * it on every call, which is O(rows) each time, and a workspace's tree is + * bounded only by `MAX_FOLDERS_PER_WORKSPACE`. */ -export function collectDescendantFolderIds(folders: FolderNode[], folderId: string): string[] { +export function indexFolderChildren(folders: readonly FolderNode[]): FolderChildrenIndex { const childrenByParent = new Map() for (const folder of folders) { @@ -23,6 +25,20 @@ export function collectDescendantFolderIds(folders: FolderNode[], folderId: stri else childrenByParent.set(folder.parentId, [folder.id]) } + return childrenByParent +} + +/** + * Returns every descendant of `folderId` from a prebuilt {@link FolderChildrenIndex}, + * excluding `folderId` itself. + * + * Tracks `seen` so a cycle (which the DB permits between constraint checks) terminates the + * walk instead of recursing forever. + */ +export function collectDescendantFolderIdsFrom( + childrenByParent: FolderChildrenIndex, + folderId: string +): string[] { const descendants: string[] = [] const seen = new Set([folderId]) @@ -38,3 +54,17 @@ export function collectDescendantFolderIds(folders: FolderNode[], folderId: stri return descendants } + +/** + * Returns every descendant of `folderId` from a flat folder list, excluding `folderId` + * itself. The caller supplies the rows, so this stays a pure function usable against a + * query result, a transaction snapshot, or test fixtures. + * + * Indexes children by parent once up front rather than rescanning the list per level. A + * caller resolving descendants for several folders of the SAME list should index once with + * {@link indexFolderChildren} and walk with {@link collectDescendantFolderIdsFrom} instead, + * so the index is not rebuilt and discarded per folder. + */ +export function collectDescendantFolderIds(folders: FolderNode[], folderId: string): string[] { + return collectDescendantFolderIdsFrom(indexFolderChildren(folders), folderId) +} diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts index 8bb9720552d..1837aa67571 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.ts @@ -62,16 +62,21 @@ export function createIntegrationCredentialVisibility({ else ownersByProviderId.set(providerId, [service]) } - for (const service of oauthOwners) { - addOwner(oauthOwnersByProviderId, service.providerId, service) - // A second authorization server for the same service (`salesforce-sandbox`) - // issues ordinary OAuth credentials, so they own visibility exactly like - // the primary provider's do. - for (const extraProviderId of service.additionalProviderIds ?? []) { - addOwner(oauthOwnersByProviderId, extraProviderId, service) + for (const service of oauthServices) { + if (service.authType === 'oauth') { + addOwner(oauthOwnersByProviderId, service.providerId, service) + // A second authorization server for the same service (`salesforce-sandbox`) + // issues ordinary OAuth credentials, so they own visibility exactly like + // the primary provider's do. + for (const extraProviderId of service.additionalProviderIds ?? []) { + addOwner(oauthOwnersByProviderId, extraProviderId, service) + } } - if (service.serviceAccountProviderId) { - addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service) + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId) { + addOwner(serviceAccountOwnersByProviderId, serviceAccountProviderId, service) } } diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 5332c38e5c4..716dafa2781 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -148,6 +148,7 @@ import { MicrosoftOneDriveIcon, MicrosoftPlannerIcon, MicrosoftSharepointIcon, + MicrosoftSqlIcon, MicrosoftTeamsIcon, MillionVerifierIcon, MintlifyIcon, @@ -214,6 +215,7 @@ import { SmartleadIcon, SmtpIcon, SnowflakeIcon, + SplunkIcon, SportmonksIcon, SQSIcon, SquareIcon, @@ -425,6 +427,7 @@ export const blockTypeToIconMap: Record = { mistral_parse_v3: MistralIcon, monday: MondayIcon, mongodb: MongoDBIcon, + mssql: MicrosoftSqlIcon, mysql: MySQLIcon, neo4j: Neo4jIcon, netsuite: NetSuiteIcon, @@ -487,6 +490,7 @@ export const blockTypeToIconMap: Record = { smartlead: SmartleadIcon, smtp: SmtpIcon, snowflake: SnowflakeIcon, + splunk: SplunkIcon, sportmonks: SportmonksIcon, sqs: SQSIcon, square: SquareIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 9068d056e2b..df98e42145b 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-14", + "updatedAt": "2026-08-16", "integrations": [ { "type": "onepassword", @@ -2169,8 +2169,8 @@ "type": "microsoft_ad", "slug": "azure-ad", "name": "Azure AD", - "description": "Manage users and groups in Azure AD (Microsoft Entra ID)", - "longDescription": "Integrate Azure Active Directory into your workflows. List, create, update, and delete users and groups. Manage group memberships programmatically.", + "description": "Manage identities, licenses, roles, and access in Azure AD (Microsoft Entra ID)", + "longDescription": "Integrate Azure Active Directory into your workflows. Create, update, and delete users and groups, manage group memberships, assign and remove licenses, reset passwords, revoke sign-in sessions, read sign-in and directory audit logs, grant and revoke app and directory roles, and read registered devices and conditional access policies. Device writes are not supported.", "bgColor": "#0078D4", "iconName": "AzureIcon", "docsUrl": "https://docs.sim.ai/integrations/microsoft_ad", @@ -2226,9 +2226,101 @@ { "name": "Remove Group Member", "description": "Remove a member from a group in Azure AD (Microsoft Entra ID)" + }, + { + "name": "Assign License", + "description": "Add or remove subscription licenses (SKUs) on a user in Microsoft Entra ID. Removing a license immediately revokes the access it granted to the associated services." + }, + { + "name": "List User Licenses", + "description": "List the subscription licenses assigned to a user in Microsoft Entra ID" + }, + { + "name": "List Subscribed SKUs", + "description": "List the subscription SKUs the tenant owns, including how many license units are prepaid and how many are consumed" + }, + { + "name": "Revoke Sign-In Sessions", + "description": "Invalidate every refresh token and session cookie issued to a user, forcing them to sign in again on all applications and devices. Revocation can take a few minutes to take effect and does not apply to external users." + }, + { + "name": "Set Password", + "description": "Set a specific password on a user by updating their password profile. Cannot be used for federated users. Requires an administrator role in Microsoft Entra ID." + }, + { + "name": "Reset Password", + "description": "Reset another user's password through their password authentication method. Leave the new password empty to have Microsoft generate one and return it. The user is prompted to change the password at their next sign-in. Cannot be run against your own account." + }, + { + "name": "List Authentication Methods", + "description": "List the authentication methods a user has registered, such as passwords, phone numbers, FIDO2 keys, and authenticator apps" + }, + { + "name": "List Sign-Ins", + "description": "List sign-in events from the Microsoft Entra ID sign-in logs, newest first. Requires a Microsoft Entra ID P1 or P2 license. Apply a date filter to keep large queries from timing out." + }, + { + "name": "List Directory Audits", + "description": "List directory audit records showing who changed what in Microsoft Entra ID, such as user creation, group membership changes, and role assignments" + }, + { + "name": "List User App Role Assignments", + "description": "List the application role assignments granted to a user, including assignments the user inherits from groups they are a direct member of" + }, + { + "name": "Grant App Role To User", + "description": "Grant a user an application role on a service principal, giving them access to that application" + }, + { + "name": "Revoke App Role From User", + "description": "Revoke an application role assignment from a user, removing their access to that application. Takes the assignment's own ID, not the app role ID." + }, + { + "name": "List Service Principals", + "description": "List the enterprise applications and service principals in the tenant, including the app roles each one exposes" + }, + { + "name": "List Application Assignments", + "description": "List every user, group, and service principal assigned to an application, by reading the app role assignments on its service principal. Recently granted or removed assignments can take time to appear." + }, + { + "name": "List Directory Roles", + "description": "List the administrator roles that are activated in the tenant, such as Global Administrator and User Administrator. Roles that have never been activated are not returned." + }, + { + "name": "List Directory Role Members", + "description": "List the principals holding an administrator role. Returns up to 1000 members; this endpoint does not support paging." + }, + { + "name": "Add Directory Role Member", + "description": "Grant a user an administrator role in Microsoft Entra ID. This is a privileged change that expands what the user can do across the tenant." + }, + { + "name": "Remove Directory Role Member", + "description": "Revoke an administrator role from a user in Microsoft Entra ID. Removes only the role membership; the user account itself is not deleted." + }, + { + "name": "List Devices", + "description": "List the devices registered in Microsoft Entra ID" + }, + { + "name": "Get Device", + "description": "Get a registered device by its object ID from Microsoft Entra ID" + }, + { + "name": "List User Devices", + "description": "List the devices a user has registered or owns. Devices the caller cannot read are returned with only their ID and the remaining fields null." + }, + { + "name": "List Conditional Access Policies", + "description": "List the conditional access policies configured in the tenant, including their state and the conditions and controls they enforce. Read-only." + }, + { + "name": "Get Conditional Access Policy", + "description": "Get a single conditional access policy by ID, including the conditions it matches and the controls it enforces. Read-only." } ], - "operationCount": 13, + "operationCount": 36, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -3811,8 +3903,8 @@ "type": "cloudflare", "slug": "cloudflare", "name": "Cloudflare", - "description": "Manage DNS, domains, certificates, and cache", - "longDescription": "Integrate Cloudflare into the workflow. Manage zones (domains), DNS records, SSL/TLS certificates, zone settings, DNS analytics, and cache purging via the Cloudflare API.", + "description": "Manage DNS, WAF, Zero Trust access, and edge infrastructure", + "longDescription": "Integrate Cloudflare into the workflow. Manage zones (domains), DNS records, SSL/TLS certificates, zone settings, DNS analytics, and cache purging. Configure WAF rulesets, managed rule overrides, and rate limiting rules through the current Rulesets engine. Administer Cloudflare Access (Zero Trust) applications, policies, groups, identity providers, and service tokens, and inspect R2 buckets, Workers scripts and routes, and Cloudflare Tunnels.", "bgColor": "#F5F6FA", "iconName": "CloudflareIcon", "docsUrl": "https://docs.sim.ai/integrations/cloudflare", @@ -3868,9 +3960,149 @@ { "name": "Purge Cache", "description": "Purges cached content for a zone. Can purge everything or specific files/tags/hosts/prefixes." + }, + { + "name": "List Rulesets", + "description": "Lists every ruleset defined on a zone across all phases (WAF custom rules, managed rules, rate limiting, transform rules, and more). The list response deliberately omits the rules inside each ruleset — use \"Get Ruleset\" to read them. Requires an API token with Zone WAF Read (or another matching ruleset Read permission)." + }, + { + "name": "Get Ruleset", + "description": "Reads a single zone ruleset including every rule it contains, in evaluation order. Requires an API token with Zone WAF Read (or another matching ruleset Read permission)." + }, + { + "name": "Get Phase Entry Point Ruleset", + "description": "Reads the entry point ruleset for a phase on a zone, including all of its rules. This is how you find the ruleset ID you need before adding, updating, or deleting a rule — for example http_request_firewall_custom for WAF custom rules, http_request_firewall_managed for managed-ruleset deployments and overrides, or http_ratelimit for rate limiting rules. Requires an API token with Zone WAF Read (or another matching ruleset Read permission)." + }, + { + "name": "Create Ruleset", + "description": "" + }, + { + "name": "Create Ruleset Rule", + "description": "Adds a rule to a zone ruleset. Use \"Get Phase Entry Point Ruleset\" first to find the ruleset ID for the phase you want (for example http_request_firewall_custom for a WAF custom rule, or http_request_firewall_managed with action \"execute\" to deploy a managed ruleset). The rule is appended to the end of the ruleset unless a position is given. Requires an API token with Zone WAF Edit (or another matching ruleset Write permission)." + }, + { + "name": "Update Ruleset Rule", + "description": "Updates a rule in a zone ruleset. Cloudflare replaces the rule definition rather than merging it, so you must send every field you want the rule to keep — any field you omit is reset to its default. Read the current rule with \"Get Ruleset\" first. Requires an API token with Zone WAF Edit (or another matching ruleset Write permission)." + }, + { + "name": "Delete Ruleset Rule", + "description": "Permanently deletes a rule from a zone ruleset. This takes effect immediately on live traffic and cannot be undone — deleting a WAF custom rule, a managed-ruleset deployment, or a rate limiting rule removes that protection from the zone. Also use this to delete rate limiting rules, which live in the http_ratelimit phase ruleset. Requires an API token with Zone WAF Edit (or another matching ruleset Write permission)." + }, + { + "name": "List Managed Ruleset Overrides", + "description": "Lists the WAF managed rulesets deployed on a zone together with the overrides applied to each one. Cloudflare has no dedicated overrides endpoint — overrides live on the \"execute\" rules of the http_request_firewall_managed phase entry point ruleset, which this reads. Requires an API token with Zone WAF Read." + }, + { + "name": "List Rate Limiting Rules", + "description": "Lists the rate limiting rules on a zone by reading the http_ratelimit phase entry point ruleset. This uses the current Rulesets-based rate limiting API; the legacy rate_limits endpoint is no longer available. The returned ruleset ID is what \"Create Rate Limiting Rule\", \"Update Rate Limiting Rule\", and \"Delete Ruleset Rule\" need. Requires an API token with Zone WAF Read." + }, + { + "name": "Create Rate Limiting Rule", + "description": "Creates a rate limiting rule in the http_ratelimit phase entry point ruleset of a zone, using the current Rulesets-based rate limiting API (the legacy rate_limits endpoint is no longer available). Run \"List Rate Limiting Rules\" first to get the ruleset ID. Requires an API token with Zone WAF Edit." + }, + { + "name": "Update Rate Limiting Rule", + "description": "Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of a zone, using the current Rulesets-based rate limiting API. Cloudflare replaces the rule definition rather than merging it, so send the complete rule — every field you omit is reset. Run \"List Rate Limiting Rules\" first to read the current definition and get the ruleset ID. Requires an API token with Zone WAF Edit." + }, + { + "name": "List Access Applications", + "description": "Lists the Cloudflare Access (Zero Trust) applications protecting an account. Requires an API token with Account Access: Apps and Policies Read." + }, + { + "name": "Get Access Application", + "description": "Reads a single Cloudflare Access (Zero Trust) application, including its attached policies. Requires an API token with Account Access: Apps and Policies Read." + }, + { + "name": "Create Access Application", + "description": "Creates a Cloudflare Access (Zero Trust) application that puts an identity check in front of a hostname. Until at least one policy is attached the application denies everyone, so pair this with \"Create Access Policy\". Requires an API token with Account Access: Apps and Policies Edit." + }, + { + "name": "Update Access Application", + "description": "Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with \"Get Access Application\" first. Requires an API token with Account Access: Apps and Policies Edit." + }, + { + "name": "Delete Access Application", + "description": "Permanently deletes a Cloudflare Access (Zero Trust) application and every policy attached to it. The hostname it protected is immediately left without an Access identity check, so anyone who can reach it can reach the origin. This cannot be undone. Requires an API token with Account Access: Apps and Policies Edit." + }, + { + "name": "List Access Policies", + "description": "Lists the Cloudflare Access (Zero Trust) policies attached to an application, in precedence order. Requires an API token with Account Access: Apps and Policies Read." + }, + { + "name": "Create Access Policy", + "description": "Creates a Cloudflare Access (Zero Trust) policy on an application, deciding who may reach it. A policy takes effect on live traffic as soon as it is created — an allow policy with a broad include rule grants access immediately. Requires an API token with Account Access: Apps and Policies Edit." + }, + { + "name": "Update Access Policy", + "description": "Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with \"List Access Policies\" first. Requires an API token with Account Access: Apps and Policies Edit." + }, + { + "name": "Delete Access Policy", + "description": "Permanently deletes a Cloudflare Access (Zero Trust) policy from an application. This changes who can reach the application the moment it runs: removing an allow policy locks out everyone it covered, and removing a deny or require policy drops that restriction. This cannot be undone. Requires an API token with Account Access: Apps and Policies Edit." + }, + { + "name": "List Access Groups", + "description": "Lists the reusable Cloudflare Access (Zero Trust) groups in an account. Groups bundle identity rules that policies can reference by ID. Requires an API token with Account Access: Organizations, Identity Providers, and Groups Read." + }, + { + "name": "List Access Identity Providers", + "description": "Lists the identity providers configured for Cloudflare Access (Zero Trust) in an account, such as Okta, Entra ID, Google Workspace, or a one-time PIN. Use the returned IDs to restrict an application with allowed_idps. Requires an API token with Account Access: Organizations, Identity Providers, and Groups Read." + }, + { + "name": "List Access Service Tokens", + "description": "Lists the Cloudflare Access (Zero Trust) service tokens in an account, which let machines authenticate to Access-protected applications. Client secrets are never returned by this endpoint — only on creation. Requires an API token with Account Access: Service Tokens Read." + }, + { + "name": "Create Access Service Token", + "description": "Creates a Cloudflare Access (Zero Trust) service token so a machine can authenticate to Access-protected applications. This is the only response that ever contains the client secret — Cloudflare will not return it again, so capture it in the same run. Requires an API token with Account Access: Service Tokens Edit." + }, + { + "name": "Revoke Access Service Token", + "description": "Permanently deletes a Cloudflare Access (Zero Trust) service token, revoking it. Every machine or integration still presenting that client ID and secret is locked out of the Access-protected applications immediately, and the secret cannot be recovered. This cannot be undone. Requires an API token with Account Access: Service Tokens Edit." + }, + { + "name": "List R2 Buckets", + "description": "Lists the R2 object storage buckets in an account. Requires an API token with Account Workers R2 Storage Read." + }, + { + "name": "Get R2 Bucket", + "description": "Reads the metadata of a single R2 object storage bucket. Requires an API token with Account Workers R2 Storage Read." + }, + { + "name": "Create R2 Bucket", + "description": "Creates an R2 object storage bucket in an account. The location hint and jurisdiction are fixed at creation and cannot be changed later. Requires an API token with Account Workers R2 Storage Edit." + }, + { + "name": "Delete R2 Bucket", + "description": "Permanently deletes an R2 object storage bucket. Cloudflare only deletes an empty bucket, and the deletion cannot be undone. Requires an API token with Account Workers R2 Storage Edit." + }, + { + "name": "List Worker Scripts", + "description": "Lists the Workers scripts deployed in an account. Requires an API token with Account Workers Scripts Read." + }, + { + "name": "Get Worker Script Settings", + "description": "Reads the deployment settings of a single Workers script — bindings, compatibility date and flags, limits, observability, placement, and tail consumers. The plain \"get script\" endpoint in the Cloudflare API returns raw JavaScript source rather than JSON, so this settings endpoint is the structured way to inspect one script. Requires an API token with Account Workers Scripts Read." + }, + { + "name": "List Worker Routes", + "description": "Lists the Workers routes on a zone, showing which URL patterns are handled by which Worker script. Unlike the Workers script endpoints, routes are zone-scoped. Requires an API token with Zone Workers Routes Read." + }, + { + "name": "List Tunnels", + "description": "Lists the Cloudflare Tunnels (cloudflared) in an account, with their health status and active connections. Requires an API token with Account Cloudflare Tunnel Read." + }, + { + "name": "Get Tunnel", + "description": "Reads a single Cloudflare Tunnel (cloudflared), including its health status and active connector connections. Requires an API token with Account Cloudflare Tunnel Read." + }, + { + "name": "Get Tunnel Configuration", + "description": "Reads the configuration of a remotely-managed Cloudflare Tunnel — its ingress rules, origin request settings, and WARP routing. Only tunnels whose configuration source is \"cloudflare\" have a remote configuration; locally-managed tunnels keep it in their own config file. Requires an API token with Account Cloudflare Tunnel Read." } ], - "operationCount": 13, + "operationCount": 48, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -4561,32 +4793,112 @@ "type": "crowdstrike", "slug": "crowdstrike", "name": "CrowdStrike", - "description": "Query CrowdStrike Identity Protection sensors and documented aggregates", - "longDescription": "Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries.", + "description": "Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities", + "longDescription": "Integrate CrowdStrike Falcon into workflows to triage alerts, contain hosts, manage host groups and custom indicators of compromise, review Spotlight vulnerabilities, run read-only Real Time Response commands, read Case Management cases, and query Identity Protection sensors.", "bgColor": "#E01F3D", "iconName": "CrowdStrikeIcon", "docsUrl": "https://docs.sim.ai/integrations/crowdstrike", "operations": [ + { + "name": "Query Alerts", + "description": "Search CrowdStrike Falcon alerts with a Falcon Query Language filter and return their composite IDs. Uses the current Alerts API (GET /alerts/queries/alerts/v2), which replaced the Detects API decommissioned on September 30, 2025. Requires the \"Alerts: Read\" API scope." + }, + { + "name": "Get Alert Details", + "description": "Get full CrowdStrike Falcon alert records for one or more composite alert IDs (POST /alerts/entities/alerts/v2). Requires the \"Alerts: Read\" API scope." + }, + { + "name": "Update Alerts", + "description": "Update CrowdStrike Falcon alerts by composite ID: change status, assign or unassign an analyst, add or remove tags, append a comment, or toggle visibility (PATCH /alerts/entities/alerts/v3). This modifies live alerts in the Falcon console. Requires the \"Alerts: Write\" API scope." + }, + { + "name": "Perform Host Action", + "description": "Act on CrowdStrike Falcon hosts (POST /devices/entities/devices-actions/v2). Actions: contain, lift_containment, hide_host, unhide_host, detection_suppress, detection_unsuppress. contain network-isolates the host so it can only reach the Falcon cloud; hide_host removes the host record from the console. Both are immediately disruptive. Up to 100 host IDs per call. Requires the \"Hosts: Write\" API scope." + }, + { + "name": "Query Host Groups", + "description": "Search CrowdStrike Falcon host groups with a Falcon Query Language filter and return their IDs (GET /devices/queries/host-groups/v1). Requires the \"Host groups: Read\" API scope." + }, + { + "name": "Get Host Group Details", + "description": "Get CrowdStrike Falcon host group records for one or more group IDs (GET /devices/entities/host-groups/v1). Requires the \"Host groups: Read\" API scope." + }, + { + "name": "Perform Host Group Action", + "description": "Add hosts to or remove hosts from a CrowdStrike Falcon static host group (POST /devices/entities/host-group-actions/v1). Group membership drives policy assignment, so changing it changes which policies apply to those hosts. Requires the \"Host groups: Write\" API scope." + }, + { + "name": "Query Indicators", + "description": "Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Query Language filter and return their IDs (GET /iocs/queries/indicators/v1). Requires the \"IOC Management: Read\" API scope." + }, + { + "name": "Get Indicator Details", + "description": "Get custom CrowdStrike Falcon indicator of compromise (IOC) records for one or more IOC IDs (GET /iocs/entities/indicators/v1). Requires the \"IOC Management: Read\" API scope." + }, + { + "name": "Create Indicators", + "description": "Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/indicators/v1). Each indicator can allow, detect, or block activity across the fleet, so a wrong value can suppress detections or break legitimate software. Requires the \"IOC Management: Write\" API scope." + }, + { + "name": "Update Indicators", + "description": "Update custom CrowdStrike Falcon indicators of compromise by ID (PATCH /iocs/entities/indicators/v1). DESTRUCTIVE: omitted fields may be cleared, so read each indicator with crowdstrike_get_indicator_details first and resend its full field set with your edits applied. Changing action or scope changes prevention behavior fleet-wide. type and value are immutable. Requires the \"IOC Management: Write\" API scope." + }, + { + "name": "Delete Indicators", + "description": "Permanently delete custom CrowdStrike Falcon indicators of compromise (DELETE /iocs/entities/indicators/v1). Cannot be undone; deleting a blocking indicator removes that protection from every host, and a broad filter can delete far more than intended. Supply an ID list or a filter, never both -- CrowdStrike lets a filter silently override the IDs, so this tool rejects that instead. Requires the \"IOC Management: Write\" API scope." + }, + { + "name": "Query Vulnerabilities", + "description": "Search CrowdStrike Falcon Spotlight vulnerabilities with a required Falcon Query Language filter and return their IDs (GET /spotlight/queries/vulnerabilities/v1). Requires the spotlight-vulnerabilities:read API scope, shown as \"Vulnerabilities: Read\" in the Falcon API client UI." + }, + { + "name": "Get Vulnerability Details", + "description": "Get CrowdStrike Falcon Spotlight vulnerability records for one or more vulnerability IDs, including CVE, affected host, application, and remediation details (GET /spotlight/entities/vulnerabilities/v2). Requires the spotlight-vulnerabilities:read API scope, shown as \"Vulnerabilities: Read\" in the Falcon API client UI." + }, + { + "name": "Init RTR Session", + "description": "Open a CrowdStrike Falcon Real Time Response session against a host so read-only commands can be run on it (POST /real-time-response/entities/sessions/v1). This connects a live remote shell to the endpoint. Requires the \"Real time response: Read\" API scope." + }, + { + "name": "Execute RTR Command", + "description": "Run a read-only Real Time Response command in an open CrowdStrike Falcon session (POST /real-time-response/entities/command/v1). baseCommand names the family only (cat, cd, clear, csrutil, env, eventlog, filehash, getsid, help, history, ifconfig, ipconfig, ls, mount, netstat, ps, reg, users); subcommands go in commandString. Host-modifying commands need the Active Responder or Admin endpoints. Requires the \"Real time response: Read\" API scope." + }, + { + "name": "Get RTR Command Status", + "description": "Get the status and output of a Real Time Response command by cloud request ID (GET /real-time-response/entities/command/v1). Long output is chunked across sequences, so increment the sequence ID to read the next chunk. Requires the \"Real time response: Read\" API scope." + }, + { + "name": "Delete RTR Session", + "description": "Close an open CrowdStrike Falcon Real Time Response session (DELETE /real-time-response/entities/sessions/v1). Requires the \"Real time response: Read\" API scope." + }, + { + "name": "Query Cases", + "description": "Search CrowdStrike Falcon Case Management cases with a Falcon Query Language filter and return their IDs (GET /cases/queries/cases/v1). Case Management supersedes the CrowdScore Incidents API, which CrowdStrike has removed from its published API spec. Requires the \"Cases: Read\" API scope." + }, + { + "name": "Get Case Details", + "description": "Get CrowdStrike Falcon Case Management case records for one or more case IDs (POST /cases/entities/cases/v2). Requires the \"Cases: Read\" API scope." + }, { "name": "Query Sensors", - "description": "Search CrowdStrike identity protection sensors by hostname, IP, or related fields" + "description": "Search CrowdStrike Identity Protection sensors -- the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors -- and return their device IDs (GET /identity-protection/queries/devices/v1). Sort uses the dot form, for example status.desc. Requires the \"Identity Protection Entities: Read\" API scope, a separate entitlement from Hosts and Alerts." }, { "name": "Get Sensor Details", - "description": "Get documented CrowdStrike Identity Protection sensor details for one or more device IDs" + "description": "Get CrowdStrike Identity Protection sensor details for one or more device IDs (POST /identity-protection/entities/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the \"Identity Protection Entities: Read\" API scope." }, { "name": "Get Sensor Aggregates", - "description": "Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body" + "description": "Aggregate CrowdStrike Identity Protection sensors from a JSON aggregate query body (POST /identity-protection/aggregates/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the \"Identity Protection Entities: Read\" API scope." } ], - "operationCount": 3, + "operationCount": 23, "triggers": [], "triggerCount": 0, "authType": "api-key", "category": "tools", "integrationType": "security", - "tags": ["identity", "monitoring"] + "tags": ["identity", "monitoring", "incident-management", "automation"] }, { "type": "cursor_v2", @@ -4853,7 +5165,11 @@ }, { "name": "Mute Monitor", - "description": "Mute a monitor to temporarily suppress notifications." + "description": "Mute a monitor to temporarily suppress its notifications. Use Unmute Monitor to reverse it, or schedule a downtime instead when you want a planned, auditable maintenance window." + }, + { + "name": "Unmute Monitor", + "description": "Unmute a monitor so it resumes sending notifications. Reverses Mute Monitor, either for one scope or for every scope at once." }, { "name": "Query Logs", @@ -4874,9 +5190,121 @@ { "name": "Cancel Downtime", "description": "Cancel a scheduled downtime." + }, + { + "name": "List Incidents", + "description": "List incidents for the organization. Requires the Incident Management `incident_read` permission; the Incidents API is in public beta." + }, + { + "name": "Get Incident", + "description": "Get the details of a single incident by ID. Requires the Incident Management `incident_read` permission; the Incidents API is in public beta." + }, + { + "name": "Create Incident", + "description": "Declare a new incident. Requires the Incident Management `incident_write` permission; the Incidents API is in public beta." + }, + { + "name": "Update Incident", + "description": "Partially update an existing incident. Requires the Incident Management `incident_write` permission; the Incidents API is in public beta." + }, + { + "name": "Add Incident Todo", + "description": "Add a follow-up task (todo) to an incident. Requires the Incident Management `incident_write` permission; the Incidents API is in public beta." + }, + { + "name": "List SLOs", + "description": "List service level objectives, optionally filtered by IDs, name, tags, or underlying metrics query." + }, + { + "name": "Get SLO", + "description": "Get the configuration of a single service level objective by ID." + }, + { + "name": "Create SLO", + "description": "Create a service level objective from a metric query, monitors, or a time-slice condition." + }, + { + "name": "Update SLO", + "description": "Update a service level objective. Reads the current SLO first and applies only the fields you supply, so anything left blank keeps its stored value." + }, + { + "name": "Delete SLO", + "description": "Permanently delete a service level objective. Datadog returns a conflict when the SLO is still referenced by a dashboard." + }, + { + "name": "Get SLO History", + "description": "Get an SLO’s history over a time window, including the overall SLI value and remaining error budget." + }, + { + "name": "List Dashboards", + "description": "List custom created or cloned dashboards. Datadog preset dashboards are not returned." + }, + { + "name": "Get Dashboard", + "description": "Get the full definition of a dashboard, including its widgets." + }, + { + "name": "Create Dashboard", + "description": "Create a dashboard from a title, layout type, and widget definitions." + }, + { + "name": "Delete Dashboard", + "description": "Delete a dashboard by ID." + }, + { + "name": "List Synthetic Tests", + "description": "List all Synthetic tests (API, browser, and mobile) with their current status." + }, + { + "name": "Get Synthetic Test", + "description": "Get the configuration of a Synthetic test by public ID. Browser test steps are not included by this type-agnostic endpoint." + }, + { + "name": "Get Synthetic Test Results", + "description": "Get the latest result summaries (up to the last 150 runs) for a Synthetic API test." + }, + { + "name": "Get Browser Synthetic Test Results", + "description": "Get the latest result summaries (up to the last 150 runs) for a Synthetic browser test, including step counts and errors." + }, + { + "name": "Trigger Synthetic Tests", + "description": "Trigger an immediate run of one or more Synthetic tests by public ID." + }, + { + "name": "Pause Or Start Synthetic Test", + "description": "Pause or resume a Synthetic test by setting its status to \"paused\" or \"live\"." + }, + { + "name": "List Security Signals", + "description": "Search Cloud SIEM security signals by query and time range. Requires the `security_monitoring_signals_read` permission." + }, + { + "name": "Get Security Signal", + "description": "Get the details of a single Cloud SIEM security signal. Requires the `security_monitoring_signals_read` permission." + }, + { + "name": "Update Security Signal State", + "description": "Change the triage state of a Cloud SIEM security signal to open, under_review, or archived. Requires the `security_monitoring_signals_write` permission." + }, + { + "name": "Assign Security Signal", + "description": "Assign a Cloud SIEM security signal to a Datadog user by UUID. Requires the `security_monitoring_signals_write` permission." + }, + { + "name": "List Security Rules", + "description": "List Cloud SIEM detection rules. Requires the `security_monitoring_rules_read` permission." + }, + { + "name": "Search Spans", + "description": "Search indexed APM spans using the span query syntax, with cursor pagination." + }, + { + "name": "List Services", + "description": "List service definitions from the Datadog Service Catalog. Requires the `apm_service_catalog_read` permission." } ], - "operationCount": 12, + "operationCount": 41, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -13003,6 +13431,49 @@ "integrationType": "productivity", "tags": ["project-management", "microsoft-365"] }, + { + "type": "mssql", + "slug": "microsoft-sql-server", + "name": "Microsoft SQL Server", + "description": "Connect to Microsoft SQL Server database", + "longDescription": "Integrate Microsoft SQL Server into the workflow. Can query, insert, update, delete, execute raw T-SQL, and introspect schemas.", + "bgColor": "#FFFFFF", + "iconName": "MicrosoftSqlIcon", + "docsUrl": "https://docs.sim.ai/integrations/mssql", + "operations": [ + { + "name": "Query (SELECT)", + "description": "Execute a SELECT query on a Microsoft SQL Server database" + }, + { + "name": "Insert Data", + "description": "Insert data into a Microsoft SQL Server table" + }, + { + "name": "Update Data", + "description": "Update rows in a Microsoft SQL Server table" + }, + { + "name": "Delete Data", + "description": "Delete rows from a Microsoft SQL Server table" + }, + { + "name": "Execute Raw SQL", + "description": "Execute a raw T-SQL statement on a Microsoft SQL Server database" + }, + { + "name": "Introspect Schema", + "description": "Introspect a Microsoft SQL Server schema to retrieve table structures, columns, keys, and indexes. Results only cover objects the login can see, so a low-privilege account returns a partial schema rather than an error" + } + ], + "operationCount": 6, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationType": "databases", + "tags": ["data-analytics"] + }, { "type": "microsoft_teams", "slug": "microsoft-teams", @@ -13723,15 +14194,15 @@ "type": "okta", "slug": "okta", "name": "Okta", - "description": "Manage users and groups in Okta", - "longDescription": "Integrate Okta identity management into your workflow. List, create, update, activate, suspend, and delete users. Reset passwords. Manage groups and group membership.", + "description": "Manage users, groups, apps, and MFA in Okta", + "longDescription": "Integrate Okta identity management into your workflow. Manage users, groups, and group rules. Run service desk actions like resetting MFA factors and clearing sessions. Review and change application assignments and admin roles. Query the System Log to audit sign-ins and admin changes.", "bgColor": "#191919", "iconName": "OktaIcon", "docsUrl": "https://docs.sim.ai/integrations/okta", "operations": [ { "name": "List Users", - "description": "List all users in your Okta organization with optional search and filtering" + "description": "List users in your Okta organization with optional search and filtering. Users with a DEPROVISIONED status are omitted unless a search or filter expression selects them." }, { "name": "Get User", @@ -13783,7 +14254,7 @@ }, { "name": "Update Group", - "description": "Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. All profile properties must be specified (full replacement)." + "description": "Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. Fields left blank keep their stored value." }, { "name": "Delete Group", @@ -13800,9 +14271,113 @@ { "name": "List Group Members", "description": "List all members of a specific group in your Okta organization" + }, + { + "name": "List Group Rules", + "description": "List the group rules in your Okta organization. Each rule assigns users to groups automatically based on an expression over their profile, so this shows how group membership is being driven." + }, + { + "name": "Get Group Rule", + "description": "Retrieve a single Okta group rule by ID, including the expression that decides which users it matches and the groups those users are assigned to." + }, + { + "name": "Create Group Rule", + "description": "Create a group rule that automatically assigns users matching an Okta expression to one or more groups. New rules are created INACTIVE, so run Activate Group Rule afterwards to start applying it." + }, + { + "name": "Activate Group Rule", + "description": "Activate a group rule so Okta starts applying it, assigning every matching user to the target groups." + }, + { + "name": "Deactivate Group Rule", + "description": "Deactivate a group rule so Okta stops applying it. Existing memberships the rule created are left in place. A rule must be INACTIVE before it can be edited." + }, + { + "name": "Delete Group Rule", + "description": "Permanently delete a group rule. Destructive and irreversible. Optionally also removes the users that this rule had assigned from those groups, which revokes any access those groups grant." + }, + { + "name": "List Factors", + "description": "List the MFA factors a user has enrolled, with each factor type, provider, and enrollment status. Use this before resetting a factor to confirm which one to target." + }, + { + "name": "Get Factor", + "description": "Retrieve a single enrolled MFA factor for a user, including its type, provider, enrollment status, and factor-specific profile." + }, + { + "name": "Enroll Factor", + "description": "Enroll an MFA factor for a user. The profile fields required depend on the factor type: a phone number for sms and call, an email address for email, and a question and answer for question. Factors that enroll from the user device, such as webauthn and push, need no profile fields." + }, + { + "name": "Reset Factor", + "description": "Unenroll one specific MFA factor for a user so they can re-enroll it. Destructive and irreversible: the existing enrollment is removed. Unenrolling a push or signed_nonce factor also unenrolls the related Okta Verify factors. Factors cannot be unenrolled from a deactivated user." + }, + { + "name": "Reset All Factors", + "description": "Reset every MFA factor for a user, returning all enrollments to the unenrolled state. Destructive and irreversible: the user must re-enroll each factor before they can complete MFA again. The user status stays ACTIVE." + }, + { + "name": "Clear User Sessions", + "description": "Revoke every active Okta session for a user, signing them out of all devices immediately. Destructive and irreversible: the user must sign in again. Optionally also revokes their OAuth and OpenID Connect tokens, and clears remembered factors." + }, + { + "name": "Get Session", + "description": "Retrieve an Okta session by ID, including who it belongs to, when it expires, and which authentication methods were used to establish it." + }, + { + "name": "Revoke Session", + "description": "Revoke a single Okta session by ID, ending that sign-in immediately. Destructive and irreversible: the affected user must sign in again on that device." + }, + { + "name": "List Applications", + "description": "List the applications configured in your Okta organization, with optional name search, filtering, and cursor pagination." + }, + { + "name": "Get Application", + "description": "Retrieve a single Okta application by ID, including its sign-on mode, status, enabled provisioning features, and configuration objects." + }, + { + "name": "List Application Users", + "description": "List the users assigned to an Okta application, including how each assignment was made and its provisioning sync state. Use this to audit who has access to an app." + }, + { + "name": "Assign User to Application", + "description": "Assign a user to an Okta application, granting them access to it. Applications that require credentials also need the username the user signs in with." + }, + { + "name": "Remove User from Application", + "description": "Unassign a user from an Okta application, revoking their access. Destructive and irreversible: the app profile for that user is permanently removed, and if provisioning is enabled the downstream account is deactivated." + }, + { + "name": "List Application Groups", + "description": "List the groups assigned to an Okta application. Every member of an assigned group inherits access to the app, so this is the starting point for an app access review." + }, + { + "name": "Assign Group to Application", + "description": "Assign a group to an Okta application so every member of the group inherits access to it." + }, + { + "name": "Remove Group from Application", + "description": "Unassign a group from an Okta application. Destructive: every member who had access only through this group loses access to the app." + }, + { + "name": "List User Roles", + "description": "List the administrator roles assigned to a user. Returns both standard roles and custom role bindings, so you can review who holds privileged access." + }, + { + "name": "Assign User Role", + "description": "Grant a user an administrator role. Use a standard role type such as USER_ADMIN or HELP_DESK_ADMIN, or CUSTOM together with a custom role ID and a resource set ID. This grants privileged access, so confirm the role is the least privilege that fits." + }, + { + "name": "Remove User Role", + "description": "Revoke an administrator role from a user. Destructive: the user immediately loses the admin permissions that role granted. Takes the role assignment ID, not the role type, which List User Roles returns as the role id field." + }, + { + "name": "Get System Log Events", + "description": "Query the Okta System Log for sign-ins, admin changes, and security events. Supports a time window, SCIM filter expressions, keyword search, and cursor pagination for audit and investigation workflows." } ], - "operationCount": 18, + "operationCount": 44, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -17966,6 +18541,114 @@ "iconName": "ServiceNowIcon", "docsUrl": "https://docs.sim.ai/integrations/servicenow", "operations": [ + { + "name": "Create Incident", + "description": "Create an incident in ServiceNow. Reference fields (caller, assignment group, assigned to, configuration item) take sys_ids unless input display value is enabled." + }, + { + "name": "Get Incident", + "description": "Retrieve a single ServiceNow incident by number (e.g., INC0010001) or sys_id. Reference fields are returned with both their sys_id and their label by default." + }, + { + "name": "List Incidents", + "description": "Search ServiceNow incidents by state, priority, assignment, caller, or text. All filters are ANDed together." + }, + { + "name": "Update Incident", + "description": "Update fields on an existing ServiceNow incident. Only the fields you supply are changed. Reference fields take sys_ids unless input display value is enabled." + }, + { + "name": "Resolve Incident", + "description": "Move a ServiceNow incident to Resolved (state 6) with a resolution code and resolution notes." + }, + { + "name": "Close Incident", + "description": "Move a ServiceNow incident to Closed (state 7) with a resolution code and resolution notes. Which roles may close an incident is instance-configurable, so the credential may need elevated rights." + }, + { + "name": "Add Incident Comment", + "description": "Append an internal work note or a customer-visible additional comment to a ServiceNow incident. Both are journal fields, so the text is appended rather than replacing earlier entries." + }, + { + "name": "Create Change Request", + "description": "Create a change request in ServiceNow. Reference fields (assignment group, assigned to, requested by, configuration item) take sys_ids unless input display value is enabled." + }, + { + "name": "Get Change Request", + "description": "Retrieve a single ServiceNow change request by number (e.g., CHG0030001) or sys_id. Reference fields are returned with both their sys_id and their label by default." + }, + { + "name": "List Change Requests", + "description": "Search ServiceNow change requests by state, type, risk, assignment, or text. All filters are ANDed together." + }, + { + "name": "Update Change Request", + "description": "Update fields on an existing ServiceNow change request. Only the fields you supply are changed. Use Move ServiceNow Change State for state transitions." + }, + { + "name": "Move Change State", + "description": "Move a ServiceNow change request to another state. Base-system change model states are -5=New, -4=Assess, -3=Authorize, -2=Scheduled, -1=Implement, 0=Review, 3=Closed, 4=Canceled. The state machine rejects transitions whose conditions are not met." + }, + { + "name": "List Change Tasks", + "description": "List the change tasks belonging to a ServiceNow change request, via the Change Management API. Every field is returned as {value, display_value}, so a reference field carries both its sys_id and its label. This endpoint is not the Table API: the shape is fixed with no display-value option, and the results come back under `tasks` rather than the `records` the other list operations use." + }, + { + "name": "Get Change Next States", + "description": "Read the states a ServiceNow change request can actually move to next, with the instance's own state-to-label map and the conditions each transition still has to meet. Use this instead of assuming the base-system state codes, which a customized change model can change." + }, + { + "name": "List Catalog Items", + "description": "Browse or search the ServiceNow service catalog. Returns each item with its sys_id, name, description, type, category, and catalogs, which is what Order ServiceNow Catalog Item needs." + }, + { + "name": "Order Catalog Item", + "description": "Submit a service catalog request for a catalog item using the Service Catalog API order_now endpoint. Returns the generated request number and sys_id." + }, + { + "name": "List Requested Items", + "description": "List requested items (RITMs) from the ServiceNow Requested Item [sc_req_item] table, optionally scoped to a parent request or a catalog item. To scope by requester, pass an encoded query against the parent request, for example \"request.requested_for=\"." + }, + { + "name": "Get Requested Item", + "description": "Retrieve a single ServiceNow requested item (RITM) by number (e.g., RITM0010001) or sys_id from the Requested Item [sc_req_item] table." + }, + { + "name": "List Approvals", + "description": "List approval records from the ServiceNow Approval [sysapproval_approver] table. Defaults to the \"requested\" state, which is what a user's pending approvals look like." + }, + { + "name": "Approve or Reject", + "description": "Approve or reject a ServiceNow approval record by setting its state on the Approval [sysapproval_approver] table." + }, + { + "name": "Search Configuration Items", + "description": "Search the ServiceNow CMDB for configuration items. Defaults to the base cmdb_ci table, which returns CIs of every class; pass a CI class to scope the search to a subclass such as cmdb_ci_linux_server." + }, + { + "name": "Get Configuration Item", + "description": "Retrieve a configuration item and its CMDB relationships through the CMDB Instance API. Returns the CI attributes plus its inbound and outbound relations, each carrying the related CI and the relationship type." + }, + { + "name": "List CI Relationships", + "description": "List rows from the CI Relationship [cmdb_rel_ci] table for a configuration item. Each row carries the parent CI, the child CI, and the relationship type." + }, + { + "name": "Search Knowledge", + "description": "Search ServiceNow knowledge base articles through the Knowledge Management API. Returns ranked results with a snippet and the article number, which Get ServiceNow Knowledge Article accepts to fetch the full body." + }, + { + "name": "Get Knowledge Article", + "description": "Retrieve the full content of a ServiceNow knowledge article by sys_id or KB number through the Knowledge Management API." + }, + { + "name": "Find User", + "description": "Look up ServiceNow users by email, user name, or display name. Use this to resolve the sys_id needed by reference fields such as caller_id, assigned_to, and approver." + }, + { + "name": "List Group Members", + "description": "List the members of a ServiceNow group from the Group Member [sys_user_grmember] table. Each row links a user to a group, so use it to find who can be assigned work for an assignment group." + }, { "name": "Create Record", "description": "Create a new record in a ServiceNow table" @@ -17999,7 +18682,7 @@ "description": "Attach a file to a ServiceNow record" } ], - "operationCount": 8, + "operationCount": 35, "triggers": [ { "id": "servicenow_incident_created", @@ -18929,6 +19612,73 @@ "integrationType": "databases", "tags": ["data-warehouse", "data-analytics", "cloud"] }, + { + "type": "splunk", + "slug": "splunk", + "name": "Splunk", + "description": "Run SPL searches and manage saved searches and alerts in Splunk", + "longDescription": "Integrate Splunk Enterprise or Splunk Cloud into workflows. Run SPL searches synchronously or as asynchronous jobs, fetch results, dispatch saved searches, and inspect fired alerts and indexes.", + "bgColor": "#FFFFFF", + "iconName": "SplunkIcon", + "docsUrl": "https://docs.sim.ai/integrations/splunk", + "operations": [ + { + "name": "Run Search", + "description": "Run an SPL search synchronously and return its results in a single call (oneshot mode). Use for short searches; use Create Search Job for long-running ones." + }, + { + "name": "Create Search Job", + "description": "Start a Splunk search job and return its search ID (sid). The search runs asynchronously — poll its status and fetch results separately." + }, + { + "name": "Get Search Job", + "description": "Get the status and progress of a Splunk search job by search ID, including dispatch state, completion progress, and event/result counts." + }, + { + "name": "Get Search Results", + "description": "Fetch the transformed results of a completed Splunk search job by search ID, with pagination." + }, + { + "name": "Cancel Search Job", + "description": "Cancel a running Splunk search job and delete its result cache." + }, + { + "name": "List Saved Searches", + "description": "List saved searches and reports configured in Splunk, including their SPL, schedule, and alert configuration." + }, + { + "name": "Get Saved Search", + "description": "Get the configuration of a single Splunk saved search by name, including its SPL, schedule, and alert settings." + }, + { + "name": "Dispatch Saved Search", + "description": "Run a Splunk saved search immediately and return the search ID (sid) of the dispatched job." + }, + { + "name": "List Fired Alerts", + "description": "List the saved searches with currently triggered (unexpired) Splunk alerts and how many times each has fired." + }, + { + "name": "Get Fired Alerts", + "description": "List the unexpired triggered instances of a Splunk alert by saved search name, including severity, trigger time, and the search ID of each firing." + }, + { + "name": "List Indexes", + "description": "List the indexes configured on the Splunk instance with their size, event count, and retention settings." + }, + { + "name": "List Apps", + "description": "List the apps installed on the Splunk instance with their label, version, author, and enabled state." + } + ], + "operationCount": 12, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "observability", + "tags": ["monitoring", "data-analytics"] + }, { "type": "sportmonks", "slug": "sportmonks", diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index b9ae9031575..d07c1c86c27 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -77,6 +77,13 @@ export const internalKnowledgeErrorPolicies = { update: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to update knowledge base')), delete: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to delete knowledge base')), restore: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')), + /** + * Workspace-scoped bulk routes. Deliberately not concealed: the request names + * a workspace, not one knowledge base, and per-item authorization failures + * are already folded into the response's `notFound` list by the use case. + */ + bulkMove: internalKnowledgeErrorPolicy('Failed to move knowledge bases'), + bulkDelete: internalKnowledgeErrorPolicy('Failed to delete knowledge bases'), default: internalKnowledgeErrorPolicy('Internal server error'), documents: concealKnowledgeBase( internalKnowledgeErrorPolicy('Failed to process knowledge document request') diff --git a/apps/sim/lib/knowledge/application/batch-policy.ts b/apps/sim/lib/knowledge/application/batch-policy.ts index 3e995996c01..03fb857c2d2 100644 --- a/apps/sim/lib/knowledge/application/batch-policy.ts +++ b/apps/sim/lib/knowledge/application/batch-policy.ts @@ -1,12 +1,27 @@ +import { + type BatchExecutionResult, + type BatchTerminalFailure, + requireBoundedResourceSelection, + rethrowBatchTerminalFailure, +} from '@/lib/core/application/batch-policy' import { OrchestrationError } from '@/lib/core/orchestration/types' - -export const MAX_KNOWLEDGE_BATCH_ITEMS = 100 +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' export const ADD_WORKSPACE_FILES_COST_POLICY = { maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, usageAdmission: 'once_before_processing', } as const +export const BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export const BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + export const BULK_DELETE_KNOWLEDGE_BASES_COST_POLICY = { maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, execution: 'sequential_best_effort', @@ -17,17 +32,16 @@ export const BULK_DELETE_KNOWLEDGE_DOCUMENTS_COST_POLICY = { execution: 'sequential_best_effort', } as const -export interface KnowledgeBatchTerminalFailure { - error: unknown -} +/** Domain names for the shared batch shapes, so call sites read in knowledge terms. */ +export type KnowledgeBatchTerminalFailure = BatchTerminalFailure +export type KnowledgeBatchExecutionResult = BatchExecutionResult -export interface KnowledgeBatchExecutionResult { - terminalFailure?: KnowledgeBatchTerminalFailure -} - -export function rethrowKnowledgeBatchTerminalFailure(result: KnowledgeBatchExecutionResult): void { - if (result.terminalFailure) throw result.terminalFailure.error -} +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export const rethrowKnowledgeBatchTerminalFailure: (result: KnowledgeBatchExecutionResult) => void = + rethrowBatchTerminalFailure export function requireBoundedKnowledgeBatch( items: readonly string[], @@ -45,3 +59,25 @@ export function requireBoundedKnowledgeBatch( } return [...new Set(items)] } + +export interface BoundedKnowledgeSelection { + knowledgeBaseIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed knowledge-base/folder selection before any + * protected row is loaded. The cap is on the combined count — see + * {@link requireBoundedResourceSelection}. + */ +export function requireBoundedKnowledgeSelection( + knowledgeBaseIds: readonly string[], + folderIds: readonly string[], + maxItems: number +): BoundedKnowledgeSelection { + const selection = requireBoundedResourceSelection(knowledgeBaseIds, folderIds, maxItems, { + singular: 'knowledge base', + plural: 'knowledge bases', + }) + return { knowledgeBaseIds: selection.resourceIds, folderIds: selection.folderIds } +} diff --git a/apps/sim/lib/knowledge/application/bulk.test.ts b/apps/sim/lib/knowledge/application/bulk.test.ts new file mode 100644 index 00000000000..a7965424441 --- /dev/null +++ b/apps/sim/lib/knowledge/application/bulk.test.ts @@ -0,0 +1,334 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + bulkDeleteFolders: vi.fn(), + bulkMoveFolders: vi.fn(), + deleteRecord: vi.fn(), + findActiveFolder: vi.fn(), + knowledgeBaseDeleted: vi.fn(), + planFolderSelection: vi.fn(), + resolveKnowledgeBase: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspace: vi.fn(), + updateRecord: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base', FOLDER: 'folder' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDeleted: mocks.knowledgeBaseDeleted }, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/folders/bulk', () => ({ + planFolderSelection: mocks.planFolderSelection, + bulkMoveFolders: mocks.bulkMoveFolders, + bulkDeleteFolders: mocks.bulkDeleteFolders, + /** Pure projection — mirrored here rather than mocked, so outcomes stay realistic. */ + foldFolderPlan: ( + plan: { notFound: string[]; contained: { id: string; name: string }[] }, + outcome: { + notFound: { kind: string; id: string }[] + skipped: { kind: string; id: string; name: string }[] + } + ) => { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) + }, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, + resolveActiveKnowledgeBaseInWorkspace: mocks.resolveKnowledgeBase, +})) +vi.mock('@/lib/knowledge/service', () => ({ + updateKnowledgeBase: mocks.updateRecord, + deleteKnowledgeBase: mocks.deleteRecord, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { bulkDeleteKnowledgeItems, bulkMoveKnowledgeItems } from '@/lib/knowledge/application/bulk' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + +function knowledgeContext(id: string, folderId: string | null = null) { + return { + ...workspaceContext, + knowledgeBaseId: id, + knowledgeBase: { id, name: `Base ${id}`, workspaceId: 'workspace-1', folderId }, + } +} + +const emptyPlan = { selected: [], notFound: [], contained: [], covered: new Set() } + +describe('knowledge bulk application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mocks.resolveKnowledgeBase.mockImplementation(async (knowledgeBaseId: string) => + knowledgeContext(knowledgeBaseId) + ) + mocks.updateRecord.mockImplementation(async (id: string) => ({ id, name: `Base ${id}` })) + mocks.deleteRecord.mockResolvedValue(undefined) + mocks.bulkMoveFolders.mockResolvedValue({ succeeded: [], failed: [] }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [], + failed: [], + folderCount: 0, + resourceCount: 0, + }) + }) + + it('rejects an empty selection before the canonical workspace load', async () => { + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', knowledgeBaseIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('bounds knowledge bases and folders against one combined cap', async () => { + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: Array.from({ length: 60 }, (_, index) => `knowledge-${index}`), + folderIds: Array.from({ length: 60 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + }) + + it('deletes knowledge bases and folders in one operation and audits every affected item', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Policies' }], + notFound: [], + contained: [], + covered: new Set(['folder-1']), + }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Policies' }], + failed: [], + folderCount: 3, + resourceCount: 4, + }) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.deleted).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + { kind: 'folder', id: 'folder-1', name: 'Policies' }, + ]) + expect(result.deletedItems).toEqual({ knowledgeBases: 5, folders: 3 }) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'knowledge_base.deleted', resourceId: 'knowledge-1' }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'folder.deleted', resourceId: 'folder-1' }) + ) + expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledExactlyOnceWith({ + knowledgeBaseId: 'knowledge-1', + }) + }) + + /** + * The whole point of taking both id lists in one request: a knowledge base + * that is also inside a selected folder must be deleted exactly once, by the + * folder's cascade. + */ + it('skips a knowledge base that a selected folder already carries', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Policies' }], + notFound: [], + contained: [], + covered: new Set(['folder-1', 'folder-child']), + }) + mocks.resolveKnowledgeBase.mockImplementation(async (knowledgeBaseId: string) => + knowledgeContext(knowledgeBaseId, 'folder-child') + ) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.skipped).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + ]) + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('conceals an inaccessible knowledge base as not-found rather than naming it', async () => { + mocks.resolveKnowledgeBase.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Knowledge base not found') + ) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['workspace-2-knowledge'], + folderIds: [], + }, + }) + + expect(result.notFound).toEqual([{ kind: 'knowledgeBase', id: 'workspace-2-knowledge' }]) + expect(result.failed).toEqual([]) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination folder is not in the workspace', async () => { + mocks.findActiveFolder.mockResolvedValue(null) + + await expect( + bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: [], + targetFolderId: 'foreign-folder', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination sits inside the moving subtree', async () => { + // `covered` is the selected folders plus their descendants. Without an up-front check the + // knowledge bases move, the folders then fail their own cycle check, and the caller is left + // with a half-applied selection. + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: [], + contained: [], + covered: new Set(['folder-2', 'folder-2-child']), + }) + + for (const targetFolderId of ['folder-2', 'folder-2-child']) { + await expect( + bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-2'], + targetFolderId, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + + expect(mocks.updateRecord).not.toHaveBeenCalled() + expect(mocks.bulkMoveFolders).not.toHaveBeenCalled() + }) + + it('moves knowledge bases and folders in one operation', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: ['ghost-folder'], + contained: [{ id: 'folder-3', name: 'Nested' }], + covered: new Set(['folder-2', 'folder-3']), + }) + mocks.bulkMoveFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-2', name: 'Archive' }], + failed: [], + }) + + const result = await bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-2', 'folder-3', 'ghost-folder'], + targetFolderId: 'folder-1', + }, + }) + + expect(result.moved).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + { kind: 'folder', id: 'folder-2', name: 'Archive' }, + ]) + expect(result.skipped).toEqual([{ kind: 'folder', id: 'folder-3', name: 'Nested' }]) + expect(result.notFound).toEqual([{ kind: 'folder', id: 'ghost-folder' }]) + expect(mocks.updateRecord).toHaveBeenCalledWith( + 'knowledge-1', + { folderId: 'folder-1' }, + 'request-1', + { assertedWorkspaceId: 'workspace-1' } + ) + }) + + it('records audit for the committed prefix before rethrowing an infrastructure failure', async () => { + mocks.deleteRecord.mockImplementation(async (knowledgeBaseId: string) => { + if (knowledgeBaseId === 'knowledge-2') throw new Error('connection reset') + }) + + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1', 'knowledge-2', 'knowledge-3'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ action: 'knowledge_base.deleted', resourceId: 'knowledge-1' }) + ) + expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/bulk.ts b/apps/sim/lib/knowledge/application/bulk.ts new file mode 100644 index 00000000000..f838260ad78 --- /dev/null +++ b/apps/sim/lib/knowledge/application/bulk.ts @@ -0,0 +1,412 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { + bulkDeleteFolders, + bulkMoveFolders, + foldFolderPlan, + planFolderSelection, +} from '@/lib/folders/bulk' +import { findActiveFolder } from '@/lib/folders/queries' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + type BoundedKnowledgeSelection, + BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY, + BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY, + type KnowledgeBatchExecutionResult, + requireBoundedKnowledgeSelection, + rethrowKnowledgeBatchTerminalFailure, +} from '@/lib/knowledge/application/batch-policy' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeBaseContext, + type KnowledgeWorkspaceContext, + resolveActiveKnowledgeBaseInWorkspace, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' + +const logger = createLogger('KnowledgeBulkApplication') + +const KNOWLEDGE_FOLDER_RESOURCE_TYPE = 'knowledge_base' as const + +export type BulkKnowledgeItemKind = 'knowledgeBase' | 'folder' + +export interface BulkKnowledgeItem { + kind: BulkKnowledgeItemKind + id: string + name: string +} + +export interface BulkKnowledgeFailure extends BulkKnowledgeItem { + reason: string +} + +/** An id the batch could not resolve. No name, because nothing was found to name. */ +export interface BulkKnowledgeMissing { + kind: BulkKnowledgeItemKind + id: string +} + +interface BulkKnowledgeContext extends KnowledgeWorkspaceContext, BoundedKnowledgeSelection {} + +export interface BulkMoveKnowledgeItemsInput { + assertedWorkspaceId: string + knowledgeBaseIds: string[] + folderIds: string[] + targetFolderId: string | null + source?: string +} + +export interface BulkDeleteKnowledgeItemsInput { + assertedWorkspaceId: string + knowledgeBaseIds: string[] + folderIds: string[] + source?: string +} + +interface BulkKnowledgeOutcome { + skipped: BulkKnowledgeItem[] + notFound: BulkKnowledgeMissing[] + failed: BulkKnowledgeFailure[] +} + +export interface BulkMoveKnowledgeItemsResult extends BulkKnowledgeOutcome { + moved: BulkKnowledgeItem[] +} + +export interface BulkDeleteKnowledgeItemsResult extends BulkKnowledgeOutcome { + deleted: BulkKnowledgeItem[] + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: { knowledgeBases: number; folders: number } +} + +interface BulkMoveKnowledgeItemsExecutionResult + extends BulkMoveKnowledgeItemsResult, + KnowledgeBatchExecutionResult {} +interface BulkDeleteKnowledgeItemsExecutionResult + extends BulkDeleteKnowledgeItemsResult, + KnowledgeBatchExecutionResult {} + +async function resolveBulkKnowledgeContext( + input: { assertedWorkspaceId: string; knowledgeBaseIds: string[]; folderIds: string[] }, + maxItems: number +): Promise { + const selection = requireBoundedKnowledgeSelection( + input.knowledgeBaseIds, + input.folderIds, + maxItems + ) + return { + ...(await resolveKnowledgeWorkspaceContext({ workspaceId: input.assertedWorkspaceId })), + ...selection, + } +} + +/** + * Walks the knowledge-base half of the selection. + * + * A base filed inside one of the selected folders is skipped: the folder + * operation already carries it, and acting on it separately would either pull + * it out of the folder it is travelling with or archive it under a second + * timestamp its folder's restore could never recover. + */ +async function runKnowledgeItems( + knowledgeBaseIds: readonly string[], + workspace: KnowledgeWorkspaceContext, + covered: ReadonlySet, + authorize: (canonical: ActiveKnowledgeBaseContext) => Promise, + apply: (canonical: ActiveKnowledgeBaseContext) => Promise, + succeeded: BulkKnowledgeItem[], + outcome: BulkKnowledgeOutcome +): Promise { + for (const knowledgeBaseId of knowledgeBaseIds) { + let knowledgeBaseName = knowledgeBaseId + try { + const canonical = await resolveActiveKnowledgeBaseInWorkspace(knowledgeBaseId, workspace) + knowledgeBaseName = canonical.knowledgeBase.name + const folderId = canonical.knowledgeBase.folderId + if (folderId && covered.has(folderId)) { + outcome.skipped.push({ + kind: 'knowledgeBase', + id: canonical.knowledgeBaseId, + name: knowledgeBaseName, + }) + continue + } + await authorize(canonical) + succeeded.push({ + kind: 'knowledgeBase', + id: canonical.knowledgeBaseId, + name: await apply(canonical), + }) + } catch (error) { + const disposition = classifyBulkItemError(error) + if (disposition.kind === 'notFound') { + outcome.notFound.push({ kind: 'knowledgeBase', id: knowledgeBaseId }) + continue + } + if (disposition.kind === 'failed') { + outcome.failed.push({ + kind: 'knowledgeBase', + id: knowledgeBaseId, + name: knowledgeBaseName, + reason: disposition.reason, + }) + continue + } + return disposition.error + } + } + return undefined +} + +export const bulkMoveKnowledgeItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkMoveItems, + resolveContext: ({ input }: { input: BulkMoveKnowledgeItemsInput }) => + resolveBulkKnowledgeContext(input, BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY.maxItems), + async execute({ principal, input, context }): Promise { + /** + * The destination check and the folder plan read different rows and share + * no data, so they overlap rather than serialize. Both still complete + * before anything is written: an invalid target must fail the whole request + * rather than leave half the selection moved. + */ + const [targetFolder, plan] = await Promise.all([ + input.targetFolderId === null + ? null + : findActiveFolder( + input.targetFolderId, + context.workspaceId, + KNOWLEDGE_FOLDER_RESOURCE_TYPE + ), + planFolderSelection(context.workspaceId, KNOWLEDGE_FOLDER_RESOURCE_TYPE, context.folderIds), + ]) + if (input.targetFolderId !== null && !targetFolder) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the resources move, the folders then fail + * their cycle check, and the caller is left with a half-applied selection. + * + * This is a fast-fail optimization, not the enforcement point. It reads a snapshot taken + * outside the folder mutation lock, so a concurrent reparent can invalidate it between the + * check and the write. The invariant itself is enforced where it must be — `updateFolder` + * re-checks `wouldCreateFolderCycle` inside `acquireFolderMutationLock`, so a cycle is never + * created. Losing that race costs a reported per-folder `failed` alongside resources that + * did move, which is the batch's documented `sequential_best_effort` outcome, not corruption. + */ + if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into itself or one of its own subfolders' + ) + } + + const moved: BulkKnowledgeItem[] = [] + const outcome: BulkKnowledgeOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + const terminalError = await runKnowledgeItems( + context.knowledgeBaseIds, + context, + plan.covered, + (canonical) => + authorizeWorkspaceOperation(principal, knowledgeOperations.bulkMoveItems, canonical, { + delegation: knowledgeDelegationPolicy, + }), + async (canonical) => + ( + await updateKnowledgeBase( + canonical.knowledgeBaseId, + { folderId: input.targetFolderId }, + generateRequestId(), + { assertedWorkspaceId: context.workspaceId } + ) + ).name, + moved, + outcome + ) + + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkMoveFolders({ + workspaceId: context.workspaceId, + resourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + userId: resolveKnowledgeAttributedUserId(principal, context), + folders: plan.selected, + targetParentId: input.targetFolderId, + }) + for (const folder of folders.succeeded) moved.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + } + + logger.info('Bulk moved knowledge bases and folders', { + workspaceId: context.workspaceId, + moved: moved.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + }) + return { + moved, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + }, + projectAudit: ({ input, result }) => + result.moved.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved knowledge base folder "${item.name}" to the workspace root` + : `Moved knowledge base folder "${item.name}" into another folder`, + metadata: { + source: input.source, + folderResourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + parentId: input.targetFolderId, + bulk: true, + }, + } + : { + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved knowledge base "${item.name}" to the workspace root` + : `Moved knowledge base "${item.name}" into a folder`, + metadata: { + source: input.source, + updatedFields: ['folderId'], + folderId: input.targetFolderId, + bulk: true, + }, + } + ), + afterSuccess: ({ result }) => { + rethrowKnowledgeBatchTerminalFailure(result) + }, +}) + +export const bulkDeleteKnowledgeItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkDeleteItems, + resolveContext: ({ input }: { input: BulkDeleteKnowledgeItemsInput }) => + resolveBulkKnowledgeContext(input, BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY.maxItems), + async execute({ principal, context }): Promise { + const plan = await planFolderSelection( + context.workspaceId, + KNOWLEDGE_FOLDER_RESOURCE_TYPE, + context.folderIds + ) + + const deleted: BulkKnowledgeItem[] = [] + const outcome: BulkKnowledgeOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + const terminalError = await runKnowledgeItems( + context.knowledgeBaseIds, + context, + plan.covered, + (canonical) => + authorizeWorkspaceOperation(principal, knowledgeOperations.bulkDeleteItems, canonical, { + delegation: knowledgeDelegationPolicy, + }), + async (canonical) => { + await deleteKnowledgeBase(canonical.knowledgeBaseId, generateRequestId(), { + assertedWorkspaceId: context.workspaceId, + }) + return canonical.knowledgeBase.name + }, + deleted, + outcome + ) + + const deletedItems = { knowledgeBases: deleted.length, folders: 0 } + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkDeleteFolders({ + workspaceId: context.workspaceId, + resourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + userId: resolveKnowledgeAttributedUserId(principal, context), + folders: plan.selected, + countKey: 'knowledgeBases', + }) + for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + deletedItems.folders = folders.folderCount + deletedItems.knowledgeBases += folders.resourceCount + } + + logger.info('Bulk deleted knowledge bases and folders', { + workspaceId: context.workspaceId, + deleted: deleted.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + deletedItems, + }) + return { + deleted, + deletedItems, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + }, + /** + * One entry per item the batch actually deleted. A folder's entry carries the + * cascade counts rather than one entry per cascaded knowledge base, matching + * what `DELETE /api/folders/[id]` already records for a single folder — a + * cascade is unbounded, and per-resource entries would let one request write + * thousands of audit rows. + */ + projectAudit: ({ input, result }) => + result.deleted.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted knowledge base folder "${item.name}"`, + metadata: { + source: input.source, + folderResourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + affected: result.deletedItems, + bulk: true, + }, + } + : { + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: item.id, + resourceName: item.name, + description: `Deleted knowledge base "${item.name}"`, + metadata: { source: input.source, knowledgeBaseName: item.name, bulk: true }, + } + ), + afterSuccess: ({ result }) => { + try { + for (const item of result.deleted) { + if (item.kind === 'knowledgeBase') { + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: item.id }) + } + } + } finally { + rethrowKnowledgeBatchTerminalFailure(result) + } + }, +}) diff --git a/apps/sim/lib/knowledge/application/contexts.ts b/apps/sim/lib/knowledge/application/contexts.ts index cf7ea274243..5219c3f2082 100644 --- a/apps/sim/lib/knowledge/application/contexts.ts +++ b/apps/sim/lib/knowledge/application/contexts.ts @@ -82,18 +82,30 @@ export async function resolveKnowledgeWorkspaceContext(input: { return context } -export async function resolveActiveKnowledgeBaseContext(input: { - knowledgeBaseId: string - assertedWorkspaceId?: string -}): Promise { - const knowledgeBase = await getKnowledgeBaseById(input.knowledgeBaseId) +/** + * Loads a knowledge base and asserts it lives in `workspaceId` when the caller named one. + * + * Shared by both resolvers below so the not-found concealment — a base outside the asserted + * workspace is reported as missing, never as forbidden — and the nullable-`workspaceId` guard + * that legacy personal bases need are written once, and cannot be dropped from one path only. + */ +async function requireKnowledgeBase(knowledgeBaseId: string, workspaceId: string | undefined) { + const knowledgeBase = await getKnowledgeBaseById(knowledgeBaseId) if ( !knowledgeBase?.workspaceId || - (input.assertedWorkspaceId !== undefined && - knowledgeBase.workspaceId !== input.assertedWorkspaceId) + (workspaceId !== undefined && knowledgeBase.workspaceId !== workspaceId) ) { throw new OrchestrationError('not_found', 'Knowledge base not found') } + /** The guard above proves `workspaceId` is set; carry that into the type so callers see it. */ + return knowledgeBase as typeof knowledgeBase & { workspaceId: string } +} + +export async function resolveActiveKnowledgeBaseContext(input: { + knowledgeBaseId: string + assertedWorkspaceId?: string +}): Promise { + const knowledgeBase = await requireKnowledgeBase(input.knowledgeBaseId, input.assertedWorkspaceId) const workspaceContext = await loadKnowledgeWorkspaceContext(knowledgeBase.workspaceId) if (!workspaceContext) throw new OrchestrationError('not_found', 'Knowledge base not found') return { @@ -103,6 +115,21 @@ export async function resolveActiveKnowledgeBaseContext(input: { } } +/** + * Resolves one knowledge base against a workspace context the caller already loaded. + * + * Same result as {@link resolveActiveKnowledgeBaseContext}, minus its workspace load. A batch has + * that context in hand before the first item — it is what bounded and authorized the request — + * and it cannot differ per item, so re-resolving it once per base is a whole extra query each. + */ +export async function resolveActiveKnowledgeBaseInWorkspace( + knowledgeBaseId: string, + workspaceContext: KnowledgeWorkspaceContext +): Promise { + const knowledgeBase = await requireKnowledgeBase(knowledgeBaseId, workspaceContext.workspaceId) + return { ...workspaceContext, knowledgeBaseId: knowledgeBase.id, knowledgeBase } +} + export async function resolveActiveKnowledgeResourceContext(input: { knowledgeBaseId: string assertedWorkspaceId?: string diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 88d80335a64..392e0ada0dd 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -11,7 +11,8 @@ import { PrincipalKindAuthorizationError, type WorkspaceOperation, } from '@/lib/core/application' -import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' @@ -553,24 +554,20 @@ export const bulkDeleteKnowledgeBases = defineAuthorizedKnowledgeUseCase({ if (input.cancellationSignal?.aborted) break deleted.push(await executeDeleteKnowledgeBase({ context: canonical })) } catch (error) { - const classified = asOrchestrationError(error) - if ( - classified?.code === 'not_found' || - classified?.code === 'forbidden' || - classified?.code === 'unauthorized' - ) { + const disposition = classifyBulkItemError(error) + if (disposition.kind === 'notFound') { notFound.push(knowledgeBaseId) continue } - if (classified && classified.code !== 'internal') { + if (disposition.kind === 'failed') { failed.push({ id: knowledgeBaseId, name: knowledgeBaseName, - reason: classified.message, + reason: disposition.reason, }) continue } - terminalFailure = { error } + terminalFailure = { error: disposition.error } break } } diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 64a1c433876..69b9fb11f8d 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -16,6 +16,8 @@ describe('knowledge operation registry', () => { 'knowledge.create', 'knowledge.update', 'knowledge.delete', + 'knowledge.bulk_move_items', + 'knowledge.bulk_delete_items', 'knowledge.bulk_delete', 'knowledge.vfs.rename', 'knowledge.vfs.delete', diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 40c58d8b40d..e0931460074 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -65,6 +65,18 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + bulkMoveItems: defineWorkspaceOperation({ + id: 'knowledge.bulk_move_items', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), + bulkDeleteItems: defineWorkspaceOperation({ + id: 'knowledge.bulk_delete_items', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), bulkDelete: defineWorkspaceOperation({ id: 'knowledge.bulk_delete', minimumRole: 'write', diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 45e4aa1cc60..7b75bc6ae17 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -6,6 +6,13 @@ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 export const MAX_KNOWLEDGE_BASES_PER_WORKSPACE = 10_000 /** Hard bound for path-indexed knowledge folder trees and recursive cascades. */ export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = MAX_FOLDERS_PER_WORKSPACE + +/** + * Maximum items a knowledge bulk request may address by identifier. Lives here + * rather than in the application batch policy so the boundary contracts can + * bound their id arrays without pulling a server-only module into client code. + */ +export const MAX_KNOWLEDGE_BATCH_ITEMS = 100 /** Hard bound for connector-type rows projected onto one knowledge-base list. */ export const MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST = 100_000 /** Maximum documents accepted by one internal bulk-create command. */ diff --git a/apps/sim/lib/media/ffmpeg.test.ts b/apps/sim/lib/media/ffmpeg.test.ts new file mode 100644 index 00000000000..c004f06081b --- /dev/null +++ b/apps/sim/lib/media/ffmpeg.test.ts @@ -0,0 +1,116 @@ +/** + * @vitest-environment node + */ +import fs from 'node:fs' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { capturedVideoFilters, capturedCaptions } = vi.hoisted(() => ({ + capturedVideoFilters: [] as string[], + capturedCaptions: [] as string[], +})) + +vi.mock('node:child_process', () => ({ + execSync: () => '/usr/bin/ffmpeg\n', +})) + +vi.mock('fluent-ffmpeg', () => { + const makeCommand = (cwd?: string) => { + const handlers: Record void> = {} + const cmd: Record = {} + const chain = (fn?: (arg: unknown) => void) => (arg?: unknown) => { + fn?.(arg) + return cmd + } + cmd.input = chain() + cmd.inputOptions = chain() + cmd.outputOptions = chain() + cmd.complexFilter = chain() + cmd.audioFilters = chain() + cmd.noVideo = chain() + cmd.setStartTime = chain() + cmd.setDuration = chain() + cmd.seekInput = chain() + cmd.frames = chain() + cmd.videoFilters = chain((arg) => { + const filter = String(arg) + capturedVideoFilters.push(filter) + // The caption is a bare relative filename resolved against the command's cwd (the + // temp dir). Read it back while it still exists to prove the raw caption never + // reached the filtergraph string. + const match = filter.match(/textfile=([^:]+)/) + if (match && cwd) { + capturedCaptions.push(fs.readFileSync(path.join(cwd, match[1]), 'utf-8')) + } + }) + cmd.on = (event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler + return cmd + } + cmd.save = (outputPath: string) => { + fs.writeFileSync(outputPath, Buffer.from('stub-output')) + handlers.end?.() + return cmd + } + return cmd + } + const ffmpeg = ((_input?: unknown, options?: { cwd?: string }) => + makeCommand(options?.cwd)) as unknown as Record & (() => unknown) + ;(ffmpeg as Record).setFfmpegPath = () => {} + ;(ffmpeg as Record).ffprobe = ( + _path: string, + cb: (err: unknown, data: unknown) => void + ) => cb(null, { streams: [], format: {} }) + return { default: ffmpeg } +}) + +import { runFfmpegOperation } from '@/lib/media/ffmpeg' + +const videoInput = { + buffer: Buffer.from('fake-video-bytes'), + mimeType: 'video/mp4', + name: 'clip.mp4', +} + +describe('runFfmpegOperation add_text filtergraph injection', () => { + beforeEach(() => { + capturedVideoFilters.length = 0 + capturedCaptions.length = 0 + }) + + it('routes the caption through textfile= so it never becomes filtergraph syntax', async () => { + await runFfmpegOperation('add_text', [videoInput], { text: 'Hello World :) 100% done' }) + + expect(capturedVideoFilters).toHaveLength(1) + const filter = capturedVideoFilters[0] + expect(filter.startsWith('drawtext=textfile=')).toBe(true) + expect(filter).toContain(':expansion=none:') + // The caption text must not be inlined into the filter string. + expect(filter).not.toContain('Hello World') + }) + + it('neutralizes a breakout payload that would inject textfile=/proc/self/environ', async () => { + const payload = "x',drawtext=textfile=/proc/self/environ:x=10:y=10,drawtext=text=hi" + await runFfmpegOperation('add_text', [videoInput], { text: payload }) + + const filter = capturedVideoFilters[0] + // The whole graph is a single, app-authored drawtext reading our temp caption file. + expect(filter.startsWith('drawtext=textfile=')).toBe(true) + // None of the attacker's injected syntax leaks into the filtergraph string. + expect(filter).not.toContain('/proc/self/environ') + expect(filter).not.toContain('drawtext=text=hi') + // ...and the payload is stored verbatim as literal caption bytes instead. + expect(capturedCaptions).toEqual([payload]) + }) + + it('neutralizes a movie= read-SSRF breakout payload', async () => { + const payload = + "x'[d];movie=filename=http\\://169.254.169.254/latest:f=tty[m];[d][m]overlay=0:0" + await runFfmpegOperation('add_text', [videoInput], { text: payload }) + + const filter = capturedVideoFilters[0] + expect(filter).not.toContain('movie=') + expect(filter).not.toContain('169.254.169.254') + expect(capturedCaptions).toEqual([payload]) + }) +}) diff --git a/apps/sim/lib/media/ffmpeg.ts b/apps/sim/lib/media/ffmpeg.ts index bcfa0b6adf0..9cc94c84a19 100644 --- a/apps/sim/lib/media/ffmpeg.ts +++ b/apps/sim/lib/media/ffmpeg.ts @@ -169,10 +169,6 @@ const TEXT_POSITION: Record = { 'bottom-right': { x: 'w*0.95-text_w', y: 'h*0.86' }, } -function escapeDrawtext(text: string): string { - return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%') -} - async function withTempDir(fn: (dir: string) => Promise): Promise { ensureFfmpeg() const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'media-ffmpeg-')) @@ -480,8 +476,26 @@ async function addText( ): Promise { if (!options.text) throw new Error('add_text requires text') const pos = TEXT_POSITION[options.position || 'bottom'] || TEXT_POSITION.bottom + // Route the caption out-of-band through a file the operation owns; never inline it into + // the filtergraph. Inline escaping is not safe — FFmpeg's av_get_token copies bytes + // verbatim inside a single-quoted run, so a literal quote closes the quote and the rest + // of the caption is parsed as filtergraph syntax, injecting filters like + // `textfile=/proc/self/environ` or `movie=http\://...` (arbitrary local-file read + + // read-SSRF, CWE-88). `textfile=` renders the bytes literally and `expansion=none` + // disables drawtext's `%{...}` functions, so the caption can never re-enter the parser. + // + // Reference the caption by a bare relative filename and run FFmpeg with its working + // directory set to the temp dir. FFmpeg's filtergraph tokenizer cannot round-trip a + // single quote inside a `textfile=` value — it drops or mis-parses it — so embedding the + // absolute temp path would break add_text whenever `os.tmpdir()` contains a quote (e.g. a + // Windows profile like `C:\Users\O'Brien\...`). The working directory is handed to the + // process via execve, never parsed as filtergraph syntax, so any character in it is safe. + const captionFileName = 'caption.txt' + await fs.writeFile(path.join(dir, captionFileName), options.text, 'utf-8') const drawtext = [ - `text='${escapeDrawtext(options.text)}'`, + `textfile=${captionFileName}`, + 'expansion=none', + 'reload=0', 'fontcolor=white', 'fontsize=h/18', 'box=1', @@ -491,7 +505,7 @@ async function addText( `y=${pos.y}`, ].join(':') const outputPath = path.join(dir, 'out.mp4') - const command = ffmpeg(inputPath) + const command = ffmpeg(inputPath, { cwd: dir }) .videoFilters(`drawtext=${drawtext}`) .outputOptions(['-c:a', 'copy']) await runCommand(command, outputPath) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 125f3de697c..e8f43754cf9 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -334,11 +334,18 @@ export const OAUTH_PROVIDERS: Record = { 'openid', 'profile', 'email', - 'User.Read.All', 'User.ReadWrite.All', 'Group.ReadWrite.All', 'GroupMember.ReadWrite.All', 'Directory.Read.All', + 'LicenseAssignment.ReadWrite.All', + 'UserAuthenticationMethod.ReadWrite.All', + 'AuditLog.Read.All', + 'Application.Read.All', + 'AppRoleAssignment.ReadWrite.All', + 'RoleManagement.ReadWrite.Directory', + 'Device.Read.All', + 'Policy.Read.All', 'offline_access', ], }, diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts new file mode 100644 index 00000000000..058bfca32dc --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' +import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' + +const CLIENT_SECRET = 'shopify-client-secret' +const USER_ID = 'user-1' +const SHOP_DOMAIN = 'example.myshopify.com' + +function parse( + state: string, + overrides: { userId?: string; shopDomain?: string; now?: Date } = {} +) { + return parseShopifyOAuthState({ + state, + userId: overrides.userId ?? USER_ID, + shopDomain: overrides.shopDomain ?? SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + now: overrides.now, + }) +} + +describe('Shopify OAuth state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps overlapping connection drafts bound to their own state', () => { + const first = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + clientSecret: CLIENT_SECRET, + }) + const second = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + clientSecret: CLIENT_SECRET, + }) + + expect(parse(first)).toEqual({ + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + }) + expect(parse(second)).toEqual({ + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + }) + }) + + it('rejects tampered, cross-user, and cross-shop state', () => { + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const [payload, signature] = state.split('.') + + expect(() => parse(`${payload}x.${signature}`)).toThrow( + 'Shopify OAuth state signature is invalid' + ) + expect(() => parse(state, { userId: 'user-2' })).toThrow( + 'Shopify OAuth state belongs to a different user' + ) + expect(() => parse(state, { shopDomain: 'other.myshopify.com' })).toThrow( + 'Shopify OAuth state belongs to a different shop' + ) + }) + + it('rejects expired state', () => { + const issuedAt = new Date('2026-08-14T18:00:00.000Z') + vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime()) + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + }) + + expect(parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS) })).toEqual( + {} + ) + expect(() => + parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS + 1) }) + ).toThrow('Shopify OAuth state is expired') + }) +}) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts new file mode 100644 index 00000000000..75c7f4b5771 --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -0,0 +1,111 @@ +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { generateId } from '@sim/utils/id' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' + +const SHOPIFY_OAUTH_STATE_VERSION = 1 + +interface ShopifyOAuthStatePayload { + v: typeof SHOPIFY_OAUTH_STATE_VERSION + nonce: string + userId: string + shopDomain: string + draftId?: string + returnUrl?: string + issuedAt: number +} + +interface CreateShopifyOAuthStateParams { + userId: string + shopDomain: string + draftId?: string + returnUrl?: string + clientSecret: string +} + +interface ParseShopifyOAuthStateParams { + state: string + userId: string + shopDomain: string + clientSecret: string + now?: Date +} + +function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStatePayload { + if (!value || typeof value !== 'object') return false + const payload = value as Record + return ( + payload.v === SHOPIFY_OAUTH_STATE_VERSION && + typeof payload.nonce === 'string' && + payload.nonce.length > 0 && + typeof payload.userId === 'string' && + payload.userId.length > 0 && + typeof payload.shopDomain === 'string' && + payload.shopDomain.length > 0 && + (payload.draftId === undefined || + (typeof payload.draftId === 'string' && payload.draftId.length > 0)) && + (payload.returnUrl === undefined || + (typeof payload.returnUrl === 'string' && payload.returnUrl.length > 0)) && + typeof payload.issuedAt === 'number' && + Number.isSafeInteger(payload.issuedAt) + ) +} + +/** Creates a signed, user-bound Shopify state token carrying the exact credential draft. */ +export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): string { + const payload: ShopifyOAuthStatePayload = { + v: SHOPIFY_OAUTH_STATE_VERSION, + nonce: generateId(), + userId: params.userId, + shopDomain: params.shopDomain, + ...(params.draftId ? { draftId: params.draftId } : {}), + ...(params.returnUrl ? { returnUrl: params.returnUrl } : {}), + issuedAt: Date.now(), + } + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = hmacSha256Hex(encoded, params.clientSecret) + return `${encoded}.${signature}` +} + +/** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */ +export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { + draftId?: string + returnUrl?: string +} { + const [encoded, signature, extra] = params.state.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('Shopify OAuth state is malformed') + } + + const expectedSignature = hmacSha256Hex(encoded, params.clientSecret) + if (!safeCompare(signature, expectedSignature)) { + throw new Error('Shopify OAuth state signature is invalid') + } + + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + } catch { + throw new Error('Shopify OAuth state payload is invalid') + } + if (!isShopifyOAuthStatePayload(decoded)) { + throw new Error('Shopify OAuth state payload is invalid') + } + + if (decoded.userId !== params.userId) { + throw new Error('Shopify OAuth state belongs to a different user') + } + if (decoded.shopDomain !== params.shopDomain) { + throw new Error('Shopify OAuth state belongs to a different shop') + } + + const now = params.now?.getTime() ?? Date.now() + if (decoded.issuedAt > now || now - decoded.issuedAt > CREDENTIAL_DRAFT_TTL_MS) { + throw new Error('Shopify OAuth state is expired') + } + + return { + ...(decoded.draftId ? { draftId: decoded.draftId } : {}), + ...(decoded.returnUrl ? { returnUrl: decoded.returnUrl } : {}), + } +} diff --git a/apps/sim/lib/oauth/shopify.ts b/apps/sim/lib/oauth/shopify.ts new file mode 100644 index 00000000000..29a7299b874 --- /dev/null +++ b/apps/sim/lib/oauth/shopify.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' + +const logger = createLogger('ShopifyOAuth') + +interface CompleteShopifyOAuthConnectionParams { + accessToken: string + shopDomain: string + scope?: string + userId: string + draftId?: string + signal?: AbortSignal +} + +function getShopifyAccountId(value: unknown): string { + if (!value || typeof value !== 'object') { + throw new Error('Shopify shop response must be an object') + } + const shop = (value as { shop?: unknown }).shop + if (!shop || typeof shop !== 'object') { + throw new Error('Shopify shop response is missing shop data') + } + const id = (shop as { id?: unknown }).id + if ((typeof id !== 'string' && typeof id !== 'number') || String(id).length === 0) { + throw new Error('Shopify shop response is missing its account id') + } + return String(id) +} + +/** Persists a verified Shopify account and completes its exact credential draft. */ +export async function completeShopifyOAuthConnection( + params: CompleteShopifyOAuthConnectionParams +): Promise { + const shopResponse = await fetch( + `https://${params.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': params.accessToken, + 'Content-Type': 'application/json', + }, + signal: params.signal, + } + ) + + if (!shopResponse.ok) { + const errorText = await shopResponse.text() + throw new Error(`Shopify token validation failed (${shopResponse.status}): ${errorText}`) + } + + const stableAccountId = getShopifyAccountId(await shopResponse.json()) + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + }) + + const now = new Date() + const accountData = { + accessToken: params.accessToken, + accountId: stableAccountId, + scope: params.scope ?? '', + updatedAt: now, + idToken: params.shopDomain, + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + logger.info('Updated existing Shopify account', { accountId: existing.id }) + } else { + await safeAccountInsert( + { + id: generateId(), + userId: params.userId, + providerId: 'shopify', + accountId: accountData.accountId, + accessToken: accountData.accessToken, + scope: accountData.scope, + idToken: accountData.idToken, + createdAt: now, + updatedAt: now, + }, + { provider: 'Shopify', identifier: params.shopDomain } + ) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + })) + + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) + } + + await processCredentialDraft({ + draftId: params.draftId, + userId: params.userId, + providerId: 'shopify', + accountId: persisted.id, + }) +} diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index ecd0846ccdb..055a45b3586 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -256,10 +256,18 @@ export const SCOPE_DESCRIPTIONS: Record = { 'Sites.ReadWrite.All': 'Read and write Sharepoint sites', 'Sites.Manage.All': 'Manage Sharepoint sites', 'https://dynamics.microsoft.com/user_impersonation': 'Access Microsoft Dataverse on your behalf', - 'User.Read.All': 'Read all user profiles', 'User.ReadWrite.All': 'Read and write all user profiles', 'GroupMember.ReadWrite.All': 'Read and write all group memberships', 'Directory.Read.All': 'Read directory data', + 'LicenseAssignment.ReadWrite.All': 'Assign and remove user licenses', + 'UserAuthenticationMethod.ReadWrite.All': + 'Read and reset authentication methods and passwords for all users', + 'AuditLog.Read.All': 'Read sign-in and directory audit logs', + 'Application.Read.All': 'Read all applications and service principals', + 'AppRoleAssignment.ReadWrite.All': 'Grant and revoke application role assignments', + 'RoleManagement.ReadWrite.Directory': 'Read and manage directory role assignments', + 'Device.Read.All': 'Read all devices', + 'Policy.Read.All': 'Read conditional access and other policies', // Reddit scopes identity: 'Access Reddit identity', diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index ab0303bb014..679c0deb676 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -72,6 +72,14 @@ const internalTableGroupErrorPolicy = extendInternalErrorPolicy( * authorization failures behind the same not-found wording. */ export const internalTableErrorPolicies = { + /** + * Workspace-scoped bulk routes. They name a workspace, not one table, so + * there is no table whose existence a 403 could betray — per-item + * authorization failures are already folded into the response's `notFound` + * list by the use case. A lock that escapes the per-item classifier still + * renders as 423. + */ + bulk: internalTableGroupErrorPolicy, concealTableAuthorization: createInternalResourceConcealmentPolicy({ base: internalOrchestrationErrorPolicy, notFoundMessage: 'Table not found', diff --git a/apps/sim/lib/table/application/batch-policy.ts b/apps/sim/lib/table/application/batch-policy.ts new file mode 100644 index 00000000000..5c5e22c2d45 --- /dev/null +++ b/apps/sim/lib/table/application/batch-policy.ts @@ -0,0 +1,57 @@ +import { + type BatchExecutionResult, + type BatchTerminalFailure, + requireBoundedResourceSelection, + rethrowBatchTerminalFailure, +} from '@/lib/core/application/batch-policy' +import { MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' + +/** + * Bulk table operations run one authorized single-table mutation per item and + * report a per-item outcome, matching the knowledge domain's + * `sequential_best_effort` bulk policy. There is no single-statement archive or + * re-parent primitive that could make the batch atomic: archiving a table + * cascades, and each item is authorized against its own canonical row. + */ +export const BULK_MOVE_TABLES_COST_POLICY = { + maxItems: MAX_TABLE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export const BULK_DELETE_TABLES_COST_POLICY = { + maxItems: MAX_TABLE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +/** Domain names for the shared batch shapes, so call sites read in table terms. */ +export type TableBatchTerminalFailure = BatchTerminalFailure +export type TableBatchExecutionResult = BatchExecutionResult + +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export const rethrowTableBatchTerminalFailure: (result: TableBatchExecutionResult) => void = + rethrowBatchTerminalFailure + +export interface BoundedTableSelection { + tableIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed table/folder selection before any protected + * row is loaded. The cap is on the combined count — see + * {@link requireBoundedResourceSelection}. + */ +export function requireBoundedTableSelection( + tableIds: readonly string[], + folderIds: readonly string[], + maxItems: number +): BoundedTableSelection { + const selection = requireBoundedResourceSelection(tableIds, folderIds, maxItems, { + singular: 'table', + plural: 'tables', + }) + return { tableIds: selection.resourceIds, folderIds: selection.folderIds } +} diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts new file mode 100644 index 00000000000..ddfa56d7ad0 --- /dev/null +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -0,0 +1,458 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + bulkDeleteFolders: vi.fn(), + bulkMoveFolders: vi.fn(), + deleteTable: vi.fn(), + findActiveFolder: vi.fn(), + moveTableToFolder: vi.fn(), + planFolderSelection: vi.fn(), + resolvePermission: vi.fn(), + resolveTableContext: vi.fn(), + resolveWorkspaceContext: vi.fn(), + signal: vi.fn(), + notifyTables: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + TABLE_DELETED: 'table.deleted', + TABLE_UPDATED: 'table.updated', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { TABLE: 'table', FOLDER: 'folder' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/folders/bulk', () => ({ + planFolderSelection: mocks.planFolderSelection, + bulkMoveFolders: mocks.bulkMoveFolders, + bulkDeleteFolders: mocks.bulkDeleteFolders, + /** Pure projection — mirrored here rather than mocked, so outcomes stay realistic. */ + foldFolderPlan: ( + plan: { notFound: string[]; contained: { id: string; name: string }[] }, + outcome: { + notFound: { kind: string; id: string }[] + skipped: { kind: string; id: string; name: string }[] + } + ) => { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) + }, +})) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceTablesChanged: mocks.notifyTables, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/table', () => ({ + deleteTable: mocks.deleteTable, + moveTableToFolder: mocks.moveTableToFolder, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableInWorkspace: mocks.resolveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { bulkDeleteTables, bulkMoveTables } from '@/lib/table/application/bulk' +import { TableLockedError } from '@/lib/table/mutation-locks' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + +function tableContext(id: string, folderId: string | null = null) { + return { + ...workspaceContext, + tableId: id, + table: { id, name: `Table ${id}`, workspaceId: 'workspace-1', folderId }, + } +} + +const emptyPlan = { selected: [], notFound: [], contained: [], covered: new Set() } + +describe('table bulk application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mocks.resolveTableContext.mockImplementation(async (tableId: string) => tableContext(tableId)) + mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' }) + mocks.deleteTable.mockResolvedValue({ + archived: { name: 'Archived', workspaceId: 'workspace-1' }, + }) + mocks.bulkMoveFolders.mockResolvedValue({ succeeded: [], failed: [] }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [], + failed: [], + folderCount: 0, + resourceCount: 0, + }) + }) + + it('rejects an empty selection before the canonical workspace load', async () => { + await expect( + bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.deleteTable).not.toHaveBeenCalled() + }) + + it('bounds tables and folders against one combined cap', async () => { + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: Array.from({ length: 60 }, (_, index) => `table-${index}`), + folderIds: Array.from({ length: 60 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + }) + + it('deletes tables and folders in one operation and audits every affected item', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Reports' }], + notFound: [], + contained: [], + covered: new Set(['folder-1']), + }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Reports' }], + failed: [], + folderCount: 2, + resourceCount: 5, + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.deleted).toEqual([ + { kind: 'table', id: 'table-1', name: 'Archived' }, + { kind: 'folder', id: 'folder-1', name: 'Reports' }, + ]) + expect(result.deletedItems).toEqual({ tables: 6, folders: 2 }) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'table.deleted', resourceId: 'table-1' }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'folder.deleted', resourceId: 'folder-1' }) + ) + }) + + /** + * The whole point of taking both id lists in one request: a table that is + * also inside a selected folder must be archived exactly once, under the + * folder's cascade timestamp, or the folder's restore could never recover it. + */ + it('skips a table that a selected folder already carries', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Reports' }], + notFound: [], + contained: [], + covered: new Set(['folder-1', 'folder-child']), + }) + mocks.resolveTableContext.mockImplementation(async (tableId: string) => + tableContext(tableId, 'folder-child') + ) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.skipped).toEqual([{ kind: 'table', id: 'table-1', name: 'Table table-1' }]) + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalledWith( + expect.objectContaining({ action: 'table.deleted' }) + ) + }) + + it('reports a locked table as a per-item failure without stranding the rest', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-locked') throw new TableLockedError('delete') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-locked', 'table-2'], + folderIds: [], + }, + }) + + expect(result.failed).toHaveLength(1) + expect(result.failed[0]).toMatchObject({ kind: 'table', id: 'table-locked' }) + expect(result.deleted).toEqual([{ kind: 'table', id: 'table-2', name: 'Archived' }]) + }) + + it('conceals an inaccessible table as not-found rather than naming it', async () => { + mocks.resolveTableContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Table not found') + ) + + const result = await bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: ['other-workspace'], folderIds: [] }, + }) + + expect(result.notFound).toEqual([{ kind: 'table', id: 'other-workspace' }]) + expect(result.failed).toEqual([]) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination folder is not in the workspace', async () => { + mocks.findActiveFolder.mockResolvedValue(null) + + await expect( + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: [], + targetFolderId: 'foreign-folder', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.moveTableToFolder).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination sits inside the moving subtree', async () => { + // `covered` is the selected folders plus their descendants. Without an up-front check the + // tables move, the folders then fail their own cycle check, and the caller is left with a + // half-applied selection. + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: [], + contained: [], + covered: new Set(['folder-2', 'folder-2-child']), + }) + + for (const targetFolderId of ['folder-2', 'folder-2-child']) { + await expect( + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-2'], + targetFolderId, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + + expect(mocks.moveTableToFolder).not.toHaveBeenCalled() + expect(mocks.bulkMoveFolders).not.toHaveBeenCalled() + }) + + /** + * The canonical workspace context is what bounded and authorized the request; it cannot differ + * per item, so the batch resolves it once and composes each table onto it. Resolving it per + * item was a whole extra load each. + * + * Note this deliberately does NOT memoize the per-item permission check: each item commits + * independently, so every one of them re-reads the caller's current permission and a + * revocation part-way through a batch stops the rest. + */ + it('loads the workspace context once however many items the batch carries', async () => { + const move = (tableIds: string[]) => + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds, + folderIds: [], + targetFolderId: 'folder-1', + }, + }) + + const small = await move(['table-1', 'table-2', 'table-3']) + expect(small.moved).toHaveLength(3) + expect(mocks.resolveWorkspaceContext).toHaveBeenCalledTimes(1) + + mocks.resolveWorkspaceContext.mockClear() + const large = await move(Array.from({ length: 25 }, (_, index) => `table-${index}`)) + expect(large.moved).toHaveLength(25) + expect(mocks.resolveWorkspaceContext).toHaveBeenCalledTimes(1) + }) + + /** A revocation part-way through a batch must stop the items that have not run yet. */ + it('re-checks the caller permission for every item', async () => { + mocks.resolvePermission.mockResolvedValueOnce('write').mockResolvedValueOnce('write') + mocks.resolvePermission.mockResolvedValue(null) + + const result = await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2', 'table-3'], + folderIds: [], + targetFolderId: 'folder-1', + }, + }) + + expect(result.moved).toHaveLength(1) + expect(result.failed.concat(result.notFound as never[])).toHaveLength(2) + /** One for the operation itself, then one per item — no memo may collapse these. */ + expect(mocks.resolvePermission).toHaveBeenCalledTimes(4) + }) + + it('moves tables and folders in one operation', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: ['ghost-folder'], + contained: [{ id: 'folder-3', name: 'Nested' }], + covered: new Set(['folder-2', 'folder-3']), + }) + mocks.bulkMoveFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-2', name: 'Archive' }], + failed: [], + }) + + const result = await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-2', 'folder-3', 'ghost-folder'], + targetFolderId: 'folder-1', + }, + }) + + expect(result.moved).toEqual([ + { kind: 'table', id: 'table-1', name: 'Moved' }, + { kind: 'folder', id: 'folder-2', name: 'Archive' }, + ]) + expect(result.skipped).toEqual([{ kind: 'folder', id: 'folder-3', name: 'Nested' }]) + expect(result.notFound).toEqual([{ kind: 'folder', id: 'ghost-folder' }]) + expect(mocks.bulkMoveFolders).toHaveBeenCalledWith( + expect.objectContaining({ targetParentId: 'folder-1' }) + ) + expect(mocks.signal).toHaveBeenCalledExactlyOnceWith('table-1') + }) + + /** + * One gesture, one live-list broadcast. A per-item notify is an internal HTTP + * round trip with an identical body, so a 100-item batch would otherwise make + * every connected client refetch the same list 100 times. + */ + it('suppresses the per-table notify and sends exactly one for the batch', async () => { + await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2', 'table-3'], + folderIds: [], + targetFolderId: 'folder-1', + }, + }) + + expect(mocks.moveTableToFolder).toHaveBeenCalledTimes(3) + for (const call of mocks.moveTableToFolder.mock.calls) { + expect(call[4]).toEqual({ notify: false }) + } + expect(mocks.notifyTables).toHaveBeenCalledExactlyOnceWith('workspace-1') + }) + + it('still notifies for the prefix a batch committed before it failed', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-2') throw new Error('connection reset') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.notifyTables).toHaveBeenCalledExactlyOnceWith('workspace-1') + }) + + it('sends no notify when the batch archived nothing', async () => { + mocks.resolveTableContext.mockRejectedValue( + new OrchestrationError('not_found', 'Table not found') + ) + + await bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: ['ghost'], folderIds: [] }, + }) + + expect(mocks.notifyTables).not.toHaveBeenCalled() + }) + + it('records audit for the committed prefix before rethrowing an infrastructure failure', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-2') throw new Error('connection reset') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2', 'table-3'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ action: 'table.deleted', resourceId: 'table-1' }) + ) + expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts new file mode 100644 index 00000000000..ba046391444 --- /dev/null +++ b/apps/sim/lib/table/application/bulk.ts @@ -0,0 +1,439 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + bulkDeleteFolders, + bulkMoveFolders, + foldFolderPlan, + planFolderSelection, +} from '@/lib/folders/bulk' +import { findActiveFolder } from '@/lib/folders/queries' +import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' +import { deleteTable, moveTableToFolder } from '@/lib/table' +import { authorizeTableOperation } from '@/lib/table/application/authorization' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + type BoundedTableSelection, + BULK_DELETE_TABLES_COST_POLICY, + BULK_MOVE_TABLES_COST_POLICY, + requireBoundedTableSelection, + rethrowTableBatchTerminalFailure, + type TableBatchExecutionResult, +} from '@/lib/table/application/batch-policy' +import { + type ActiveTableContext, + resolveActiveTableInWorkspace, + resolveTableWorkspaceContext, + type TableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { TableLockedError } from '@/lib/table/mutation-locks' + +const logger = createLogger('TableBulkApplication') + +const TABLE_FOLDER_RESOURCE_TYPE = 'table' as const + +export type BulkTableItemKind = 'table' | 'folder' + +export interface BulkTableItem { + kind: BulkTableItemKind + id: string + name: string +} + +export interface BulkTableFailure extends BulkTableItem { + reason: string +} + +/** An id the batch could not resolve. No name, because nothing was found to name. */ +export interface BulkTableMissing { + kind: BulkTableItemKind + id: string +} + +interface BulkTablesContext extends TableWorkspaceContext, BoundedTableSelection {} + +export interface BulkMoveTablesInput { + assertedWorkspaceId: string + tableIds: string[] + folderIds: string[] + targetFolderId: string | null +} + +export interface BulkDeleteTablesInput { + assertedWorkspaceId: string + tableIds: string[] + folderIds: string[] +} + +interface BulkTablesOutcome { + skipped: BulkTableItem[] + notFound: BulkTableMissing[] + failed: BulkTableFailure[] +} + +export interface BulkMoveTablesResult extends BulkTablesOutcome { + moved: BulkTableItem[] +} + +export interface BulkDeleteTablesResult extends BulkTablesOutcome { + deleted: BulkTableItem[] + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: { tables: number; folders: number } +} + +interface BulkMoveTablesExecutionResult extends BulkMoveTablesResult, TableBatchExecutionResult {} +interface BulkDeleteTablesExecutionResult + extends BulkDeleteTablesResult, + TableBatchExecutionResult {} + +async function resolveBulkTablesContext( + input: { assertedWorkspaceId: string; tableIds: string[]; folderIds: string[] }, + maxItems: number +): Promise { + const selection = requireBoundedTableSelection(input.tableIds, input.folderIds, maxItems) + return { + ...(await resolveTableWorkspaceContext(input.assertedWorkspaceId)), + ...selection, + } +} + +/** + * A lock is a per-table verdict, not an infrastructure fault: one locked table + * must not strand the rest of the selection. `TableLockedError` is an + * `HttpError`, so it never carries an orchestration code of its own and the + * shared classification cannot see it. + */ +function tableLockVerdict(error: unknown): BulkItemDisposition | undefined { + if (error instanceof TableLockedError) return { kind: 'failed', reason: error.message } + return undefined +} + +/** + * Resolves the destination folder once, before anything is written, so an + * invalid target fails the whole request rather than leaving half the selection + * moved. Scoped to `resourceType: 'table'` so a folder id from another + * resource's tree cannot file tables somewhere the Tables list never renders. + */ +async function requireTableFolder(workspaceId: string, folderId: string | null): Promise { + if (folderId === null) return + if (!(await findActiveFolder(folderId, workspaceId, TABLE_FOLDER_RESOURCE_TYPE))) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } +} + +/** + * Sends ONE live-list notification for the whole batch. + * + * Every per-table notify is an internal HTTP round trip with an identical body + * and broadcasts an identical workspace-wide invalidation, so a per-item + * fan-out would make every connected client refetch the same list once per + * item, and — with a 2s timeout each — could stall a 100-item request for + * minutes when the socket pod is unreachable. The per-item notifies are + * therefore suppressed at the mutation and replaced by this one call, made from + * a `finally` so a batch that ends early still announces what it did commit. + * + * Folder items are excluded: `bulkMoveFolders`/`bulkDeleteFolders` send their + * own single folder-resource notification, which fans out to the same room. + */ +async function notifyBatchedTableChanges( + workspaceId: string, + items: readonly BulkTableItem[] +): Promise { + if (items.some((item) => item.kind === 'table')) { + await notifyWorkspaceTablesChanged(workspaceId) + } +} + +/** + * Walks the table half of the selection. + * + * A table filed inside one of the selected folders is skipped: the folder + * operation already carries it, and acting on it separately would either pull + * it out of the folder it is travelling with or archive it under a second + * timestamp its folder's restore could never recover. + */ +async function runTableItems( + tableIds: readonly string[], + workspace: TableWorkspaceContext, + covered: ReadonlySet, + authorize: (canonical: ActiveTableContext) => Promise, + /** Runs against an already-authorized canonical table. Returns its authoritative name. */ + apply: (canonical: ActiveTableContext) => Promise, + succeeded: BulkTableItem[], + outcome: BulkTablesOutcome +): Promise { + for (const tableId of tableIds) { + let tableName = tableId + try { + const canonical = await resolveActiveTableInWorkspace(tableId, workspace) + tableName = canonical.table.name + if (canonical.table.folderId && covered.has(canonical.table.folderId)) { + outcome.skipped.push({ kind: 'table', id: canonical.table.id, name: tableName }) + continue + } + await authorize(canonical) + succeeded.push({ + kind: 'table', + id: canonical.table.id, + name: await apply(canonical), + }) + } catch (error) { + const disposition = classifyBulkItemError(error, tableLockVerdict) + if (disposition.kind === 'notFound') { + outcome.notFound.push({ kind: 'table', id: tableId }) + continue + } + if (disposition.kind === 'failed') { + outcome.failed.push({ + kind: 'table', + id: tableId, + name: tableName, + reason: disposition.reason, + }) + continue + } + return disposition.error + } + } + return undefined +} + +export const bulkMoveTables = defineAuthorizedTableUseCase({ + operation: tableOperations.bulkMove, + resolveContext: ({ input }: { input: BulkMoveTablesInput }) => + resolveBulkTablesContext(input, BULK_MOVE_TABLES_COST_POLICY.maxItems), + async execute({ principal, input, context }): Promise { + /** + * The destination check and the folder plan read different rows and share + * no data, so they overlap rather than serialize. Both still complete + * before anything is written: an invalid target must fail the whole request + * rather than leave half the selection moved. + */ + const [, plan] = await Promise.all([ + requireTableFolder(context.workspaceId, input.targetFolderId), + planFolderSelection(context.workspaceId, TABLE_FOLDER_RESOURCE_TYPE, context.folderIds), + ]) + + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the tables move, the folders then fail + * their cycle check, and the caller is left with a half-applied selection. + * + * This is a fast-fail optimization, not the enforcement point. It reads a snapshot taken + * outside the folder mutation lock, so a concurrent reparent can invalidate it between the + * check and the write. The invariant itself is enforced where it must be — `updateFolder` + * re-checks `wouldCreateFolderCycle` inside `acquireFolderMutationLock`, so a cycle is never + * created. Losing that race costs a reported per-folder `failed` alongside resources that + * did move, which is the batch's documented `sequential_best_effort` outcome, not corruption. + */ + if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into itself or one of its own subfolders' + ) + } + + const moved: BulkTableItem[] = [] + const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + try { + const terminalError = await runTableItems( + context.tableIds, + context, + plan.covered, + (canonical) => authorizeTableOperation(principal, tableOperations.bulkMove, canonical), + async (canonical) => + ( + await moveTableToFolder( + canonical.table.id, + context.workspaceId, + input.targetFolderId, + generateRequestId(), + { notify: false } + ) + ).name, + moved, + outcome + ) + + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkMoveFolders({ + workspaceId: context.workspaceId, + resourceType: TABLE_FOLDER_RESOURCE_TYPE, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + folders: plan.selected, + targetParentId: input.targetFolderId, + }) + for (const folder of folders.succeeded) moved.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + } + + logger.info('Bulk moved tables and folders', { + workspaceId: context.workspaceId, + moved: moved.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + }) + return { + moved, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + } finally { + await notifyBatchedTableChanges(context.workspaceId, moved) + } + }, + projectAudit: ({ input, result }) => + result.moved.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved table folder "${item.name}" to the workspace root` + : `Moved table folder "${item.name}" into another folder`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + parentId: input.targetFolderId, + bulk: true, + }, + } + : { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved table "${item.name}" to the workspace root` + : `Moved table "${item.name}" into a folder`, + metadata: { op: 'move', folderId: input.targetFolderId, bulk: true }, + } + ), + afterSuccess: ({ result }) => { + try { + for (const item of result.moved) { + if (item.kind === 'table') signalTableSchemaChanged(item.id) + } + } finally { + rethrowTableBatchTerminalFailure(result) + } + }, +}) + +export const bulkDeleteTables = defineAuthorizedTableUseCase({ + operation: tableOperations.bulkDelete, + resolveContext: ({ input }: { input: BulkDeleteTablesInput }) => + resolveBulkTablesContext(input, BULK_DELETE_TABLES_COST_POLICY.maxItems), + async execute({ principal, context }): Promise { + const plan = await planFolderSelection( + context.workspaceId, + TABLE_FOLDER_RESOURCE_TYPE, + context.folderIds + ) + + const deleted: BulkTableItem[] = [] + const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + try { + const terminalError = await runTableItems( + context.tableIds, + context, + plan.covered, + (canonical) => authorizeTableOperation(principal, tableOperations.bulkDelete, canonical), + async (canonical) => { + const { archived } = await deleteTable(canonical.table.id, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + skipNotify: true, + }) + if (!archived) throw new OrchestrationError('not_found', 'Table not found') + return archived.name + }, + deleted, + outcome + ) + + const deletedItems = { tables: deleted.length, folders: 0 } + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkDeleteFolders({ + workspaceId: context.workspaceId, + resourceType: TABLE_FOLDER_RESOURCE_TYPE, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + folders: plan.selected, + countKey: 'tables', + }) + for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + deletedItems.folders = folders.folderCount + deletedItems.tables += folders.resourceCount + } + + logger.info('Bulk archived tables and folders', { + workspaceId: context.workspaceId, + deleted: deleted.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + deletedItems, + }) + return { + deleted, + deletedItems, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + } finally { + await notifyBatchedTableChanges(context.workspaceId, deleted) + } + }, + /** + * One entry per item the batch actually archived. A folder's entry carries + * the cascade counts rather than one entry per cascaded table, matching what + * `DELETE /api/folders/[id]` already records for a single folder — a cascade + * is unbounded, and per-resource entries would let one request write + * thousands of audit rows. + */ + projectAudit: ({ result }) => + result.deleted.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted table folder "${item.name}"`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + affected: result.deletedItems, + bulk: true, + }, + } + : { + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: `Archived table "${item.name}"`, + metadata: { bulk: true }, + } + ), + afterSuccess: ({ result }) => { + rethrowTableBatchTerminalFailure(result) + }, +}) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index d87150c0f50..71e71de49b9 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -18,17 +18,40 @@ export async function resolveTableWorkspaceContext( return canonical } +/** + * Loads a table and asserts it lives in `workspaceId` when the caller named one. + * + * Shared by both resolvers below so the not-found concealment — a table outside the asserted + * workspace is reported as missing, never as forbidden — is written once. + */ +async function requireTable(tableId: string, workspaceId: string | undefined) { + const table = await getTableById(tableId) + if (!table || (workspaceId !== undefined && table.workspaceId !== workspaceId)) { + throw new OrchestrationError('not_found', 'Table not found') + } + return table +} + export async function resolveActiveTableContext(input: { tableId: string assertedWorkspaceId?: string }): Promise { - const table = await getTableById(input.tableId) - if ( - !table || - (input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId) - ) { - throw new OrchestrationError('not_found', 'Table not found') - } + const table = await requireTable(input.tableId, input.assertedWorkspaceId) const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId) return { ...workspaceContext, tableId: table.id, table } } + +/** + * Resolves one table against a workspace context the caller already loaded. + * + * Same result as {@link resolveActiveTableContext}, minus its workspace load. A batch has that + * context in hand before the first item — it is what bounded and authorized the request — and it + * cannot differ per item, so re-resolving it once per table is a whole extra query each. + */ +export async function resolveActiveTableInWorkspace( + tableId: string, + workspaceContext: TableWorkspaceContext +): Promise { + const table = await requireTable(tableId, workspaceContext.workspaceId) + return { ...workspaceContext, tableId: table.id, table } +} diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index dd590edd4f7..42476b3783a 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -80,6 +80,8 @@ export const tableOperations = { create: writeOperation('tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), + bulkMove: writeOperation('tables.bulk_move'), + bulkDelete: writeOperation('tables.bulk_delete'), renameByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.rename', minimumRole: 'write', diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 7ab2e7aa098..b53a1faecee 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -5,6 +5,13 @@ import { randomInt, randomItem } from '@sim/utils/random' import { env, envNumber } from '@/lib/core/config/env' +/** + * Maximum tables addressable by identifier in one bulk request. Matches the + * knowledge domain's `MAX_KNOWLEDGE_BATCH_ITEMS` so a multi-select on either + * list page is capped the same way. + */ +export const MAX_TABLE_BATCH_ITEMS = 100 + export const TABLE_LIMITS = { MAX_TABLES_PER_WORKSPACE: 100, MAX_ROWS_PER_TABLE: 10000, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 842a0a84d75..95d3ff94939 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -945,7 +945,15 @@ export async function moveTableToFolder( tableId: string, workspaceId: string, folderId: string | null, - requestId: string + requestId: string, + /** + * `notify: false` for a caller moving several tables in one gesture that + * sends a single batch notification of its own. Each notify is an internal + * HTTP round trip with an identical body and triggers an identical + * workspace-wide invalidation, so a per-item fan-out makes every connected + * client refetch the same list once per moved table. + */ + options?: { notify?: boolean } ): Promise<{ name: string }> { const updates: Partial = { folderId, @@ -981,7 +989,7 @@ export async function moveTableToFolder( logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) // Live tables list: a move changes each table's folder placement in the list result. - await notifyWorkspaceTablesChanged(workspaceId) + if (options?.notify ?? true) await notifyWorkspaceTablesChanged(workspaceId) return { name } } diff --git a/apps/sim/lib/workflows/comparison/compare.test.ts b/apps/sim/lib/workflows/comparison/compare.test.ts index d78be67c55d..63811a90dc5 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -386,6 +386,39 @@ describe('hasWorkflowChanged', () => { expect(hasWorkflowChanged(unset, withErrorFlag(false))).toBe(false) expect(hasWorkflowChanged(unset, withErrorFlag(true))).toBe(true) }) + + /** + * `setBlockErrorEnabled` leaves existing error edges in place, so a block can + * hold `errorEnabled: false` with a connected error edge. Only the deployed + * side is backfilled (`materializeDeploymentState`), so reading the flag alone + * makes that block differ from itself, and redeploying cannot clear it — the + * snapshot stores the live `false` that the next read backfills to `true`. + */ + const withErrorEdge = (errorEnabled: boolean) => + createWorkflowState({ + blocks: { + block1: { ...createBlock('block1'), errorEnabled }, + block2: createBlock('block2'), + }, + edges: [ + { id: 'e1', source: 'block1', sourceHandle: 'error', target: 'block2' }, + ] as WorkflowState['edges'], + }) + + it.concurrent('treats a live error edge as the flag being on', () => { + expect(hasWorkflowChanged(withErrorEdge(false), withErrorEdge(true))).toBe(false) + expect(hasWorkflowChanged(withErrorEdge(true), withErrorEdge(false))).toBe(false) + }) + + it.concurrent('reports no modified block when only the backfilled flag differs', () => { + const summary = generateWorkflowDiffSummary(withErrorEdge(false), withErrorEdge(true)) + expect(summary.hasChanges).toBe(false) + expect(summary.modifiedBlocks).toEqual([]) + }) + + it.concurrent('still detects the flag turning on when no error edge exists', () => { + expect(hasWorkflowChanged(withErrorFlag(true), withErrorFlag(false))).toBe(true) + }) }) describe('SubBlock Changes', () => { diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index b2a3d300960..e030e1b13aa 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -1,5 +1,9 @@ import { createLogger } from '@sim/logger' -import { blockRetryEquals } from '@sim/workflow-types/workflow' +import { + blockRetryEquals, + collectErrorSourceBlockIds, + resolveEffectiveErrorEnabled, +} from '@sim/workflow-types/workflow' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { extractBlockFieldsForComparison, @@ -132,6 +136,8 @@ export function generateWorkflowDiffSummary( const previousBlocks = previousState.blocks || {} const currentBlockIds = new Set(Object.keys(currentBlocks)) const previousBlockIds = new Set(Object.keys(previousBlocks)) + const currentErrorSources = collectErrorSourceBlockIds(currentState.edges) + const previousErrorSources = collectErrorSourceBlockIds(previousState.edges) for (const id of currentBlockIds) { if (!previousBlockIds.has(id)) { @@ -173,6 +179,25 @@ export function generateWorkflowDiffSummary( subBlocks: previousSubBlocks, } = extractBlockFieldsForComparison(previousBlock) + /** + * Outside the structural gate below: the flag alone can match while the edges + * disagree, and reading it alone pins a block with a stale `errorEnabled: false` + * and a live error edge to "needs redeploy" forever. + */ + const currentErrorEnabled = resolveEffectiveErrorEnabled(currentBlock, id, currentErrorSources) + const previousErrorEnabled = resolveEffectiveErrorEnabled( + previousBlock, + id, + previousErrorSources + ) + if (currentErrorEnabled !== previousErrorEnabled) { + changes.push({ + field: 'errorEnabled', + oldValue: previousErrorEnabled, + newValue: currentErrorEnabled, + }) + } + const normalizedCurrentBlock = { ...currentRest, data: currentDataRest, subBlocks: undefined } const normalizedPreviousBlock = { ...previousRest, @@ -196,12 +221,8 @@ export function generateWorkflowDiffSummary( newValue: currentBlock.enabled, }) } - const blockFields = [ - 'horizontalHandles', - 'advancedMode', - 'triggerMode', - 'errorEnabled', - ] as const + /** `errorEnabled` is compared above, against the edges as well as the flag. */ + const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const for (const field of blockFields) { if (!!currentBlock[field] !== !!previousBlock[field]) { changes.push({ diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 923a6d4976b..2241e189fba 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -423,6 +423,71 @@ describe('migrateSubblockIds', () => { expect(blocks.b3.subBlocks.code).toBeDefined() }) + /** + * The suffixed Cloudflare read-filter ids existed only between #6740 and the + * restore, and never shipped in a release. They are dropped rather than renamed + * onto `name`/`type`/`content`/`proxied`/`tags`, which every Cloudflare block + * already materializes — a rename would hit the collision guard and discard the + * value regardless, while leaving the stale key parked in state. + */ + describe('cloudflare block', () => { + it('drops the staging-only read-filter ids without disturbing the restored ids', () => { + const input: Record = { + b1: makeBlock({ + type: 'cloudflare', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'list_dns_records' }, + zoneNameFilter: { id: 'zoneNameFilter', type: 'short-input', value: 'example.com' }, + dnsNameFilter: { id: 'dnsNameFilter', type: 'short-input', value: 'www' }, + dnsTypeFilter: { id: 'dnsTypeFilter', type: 'dropdown', value: 'A' }, + dnsContentFilter: { id: 'dnsContentFilter', type: 'short-input', value: '1.2.3.4' }, + dnsProxiedFilter: { id: 'dnsProxiedFilter', type: 'dropdown', value: 'true' }, + purgeTags: { id: 'purgeTags', type: 'short-input', value: 'tag-a' }, + cursor: { id: 'cursor', type: 'short-input', value: 'abc' }, + name: { id: 'name', type: 'short-input', value: '' }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(true) + for (const legacyId of [ + 'zoneNameFilter', + 'dnsNameFilter', + 'dnsTypeFilter', + 'dnsContentFilter', + 'dnsProxiedFilter', + 'purgeTags', + 'cursor', + ]) { + expect(blocks.b1.subBlocks[legacyId]).toBeUndefined() + expect(blocks.b1.subBlocks[`_removed_${legacyId}`]).toBeUndefined() + } + expect(blocks.b1.subBlocks.operation.value).toBe('list_dns_records') + expect(blocks.b1.subBlocks.name.value).toBe('') + }) + + it('leaves a workflow saved on the restored ids untouched', () => { + const input: Record = { + b1: makeBlock({ + type: 'cloudflare', + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'list_dns_records' }, + name: { id: 'name', type: 'short-input', value: 'www' }, + type: { id: 'type', type: 'dropdown', value: 'A' }, + }, + }), + } + + const { blocks, migrated } = migrateSubblockIds(input) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.name.value).toBe('www') + expect(blocks.b1.subBlocks.type.value).toBe('A') + }) + }) + it('should handle blocks with empty subBlocks', () => { const input: Record = { b1: makeBlock({ type: 'knowledge', subBlocks: {} }), diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index b5de686bd89..fb2cb8e7661 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -114,6 +114,35 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { host: '_removed_host', apiKey: '_removed_apiKey', }, + /** + * The Cloudflare block briefly gave its DNS/zone read filters and its cache-purge + * tag list operation-suffixed IDs, and added a single shared `cursor`. This PR + * restores the shipped IDs (`name`, `type`, `content`, `proxied`, `tags`) so saved + * workflows keep filtering, and splits the cursor per endpoint. + * + * The suffixed IDs are dropped rather than renamed onto their shipped + * counterparts. They existed only between #6740 and this change and never + * appeared in a release, so no deployed workflow carries them — and a rename + * could not restore a value even for a workflow edited in that window. Block + * state materializes an entry for every subblock the config declares, not just + * the active operation's, so `name`/`type`/`content`/`proxied`/`tags` are always + * already present; {@link migrateBlockSubblockIds} would hit its collision guard + * and discard the source value anyway. Mapping them as renames would therefore + * claim a recovery that never happens, while leaving the stale value parked in + * state and riding along in exports. + * + * `cursor` split into `r2Cursor` and `rulesetCursor`, so there is no single + * replacement to name. + */ + cloudflare: { + zoneNameFilter: '_removed_zoneNameFilter', + dnsNameFilter: '_removed_dnsNameFilter', + dnsTypeFilter: '_removed_dnsTypeFilter', + dnsContentFilter: '_removed_dnsContentFilter', + dnsProxiedFilter: '_removed_dnsProxiedFilter', + purgeTags: '_removed_purgeTags', + cursor: '_removed_cursor', + }, rippling: { action: '_removed_action', candidateDepartment: '_removed_candidateDepartment', diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index c9313ef325d..9150443d5d2 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -17,7 +17,10 @@ import { import { saveWorkflowToNormalizedTables as saveWorkflowToNormalizedTablesRaw } from '@sim/workflow-persistence/save' import type { DbOrTx, NormalizedWorkflowData } from '@sim/workflow-persistence/types' import type { BlockState, Loop, Parallel, WorkflowState } from '@sim/workflow-types/workflow' -import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow' +import { + collectErrorSourceBlockIds, + normalizeWorkflowEdgeHandles, +} from '@sim/workflow-types/workflow' import type { InferSelectModel } from 'drizzle-orm' import { and, desc, eq, inArray, lt, sql } from 'drizzle-orm' import { LRUCache } from 'lru-cache' @@ -181,18 +184,18 @@ async function materializeDeploymentState( */ const edges = normalizeWorkflowEdgeHandles(state.edges) - /* + /** * An error edge means the error output is on. Every version before the toggle * drew that port unconditionally, so a snapshot with such an edge was taken * from a block that had the output — and the migration backfilling the flag * only reaches the live tables, never a version's frozen jsonb. Without this * the deployed side reads `false` against a live `true` and every workflow - * deployed before the toggle asks to be redeployed once. Same rule as - * `workflow-block.tsx` applies at render time; neither may read the flag alone. + * deployed before the toggle asks to be redeployed once. This backfills only + * the deployed side, so change detection must apply `resolveEffectiveErrorEnabled` + * to the live side too — reading the raw flag there compares a block against + * itself forever. Same rule the block renderers apply; none may read the flag alone. */ - const errorSourceBlockIds = new Set( - edges.filter((edge) => edge.sourceHandle === 'error').map((edge) => edge.source) - ) + const errorSourceBlockIds = collectErrorSourceBlockIds(edges) const blocks: DeployedWorkflowData['blocks'] = {} for (const [blockId, block] of Object.entries(migratedBlocks)) { blocks[blockId] = diff --git a/apps/sim/lib/workflows/tool-input/param-visibility.test.ts b/apps/sim/lib/workflows/tool-input/param-visibility.test.ts new file mode 100644 index 00000000000..3b1365928c2 --- /dev/null +++ b/apps/sim/lib/workflows/tool-input/param-visibility.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + isSubBlockRequired, + isToolParamUserRequired, + isUserSuppliedToolParam, +} from '@/lib/workflows/tool-input/param-visibility' + +describe('isUserSuppliedToolParam', () => { + it('is true only for user-only', () => { + expect(isUserSuppliedToolParam({ paramVisibility: 'user-only' })).toBe(true) + expect(isUserSuppliedToolParam({ paramVisibility: 'user-or-llm' })).toBe(false) + expect(isUserSuppliedToolParam({ paramVisibility: 'llm-only' })).toBe(false) + expect(isUserSuppliedToolParam({ paramVisibility: 'hidden' })).toBe(false) + }) + + it('treats an undeclared visibility as user-or-llm', () => { + // Matches the tool-row renderer's fallback: an unannotated param is never user-required. + expect(isUserSuppliedToolParam({})).toBe(false) + }) +}) + +describe('isToolParamUserRequired', () => { + it('requires both user-only visibility and a satisfied required condition', () => { + expect(isToolParamUserRequired({ paramVisibility: 'user-only', required: true }, {})).toBe(true) + expect(isToolParamUserRequired({ paramVisibility: 'user-only', required: false }, {})).toBe( + false + ) + }) + + it('never requires a param the model can supply, even when required is true', () => { + // `required: true` still drives the model-facing schema; it just is not the USER's to fill. + expect(isToolParamUserRequired({ paramVisibility: 'user-or-llm', required: true }, {})).toBe( + false + ) + expect(isToolParamUserRequired({ paramVisibility: 'llm-only', required: true }, {})).toBe(false) + }) + + it('evaluates the condition form against the surrounding values', () => { + const config = { + paramVisibility: 'user-only' as const, + required: { field: 'operation', value: ['write'] }, + } + expect(isToolParamUserRequired(config, { operation: 'write' })).toBe(true) + expect(isToolParamUserRequired(config, { operation: 'read' })).toBe(false) + }) +}) + +describe('isSubBlockRequired', () => { + it('handles the boolean and absent forms', () => { + expect(isSubBlockRequired(true, {})).toBe(true) + expect(isSubBlockRequired(false, {})).toBe(false) + expect(isSubBlockRequired(undefined, {})).toBe(false) + }) + + it('evaluates the condition form', () => { + const required = { field: 'operation', value: ['write', 'read-bulk'] } + expect(isSubBlockRequired(required, { operation: 'write' })).toBe(true) + expect(isSubBlockRequired(required, { operation: 'search' })).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/tool-input/param-visibility.ts b/apps/sim/lib/workflows/tool-input/param-visibility.ts new file mode 100644 index 00000000000..22272e692ec --- /dev/null +++ b/apps/sim/lib/workflows/tool-input/param-visibility.ts @@ -0,0 +1,87 @@ +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import type { SubBlockConfig } from '@/blocks/types' +import type { ParameterVisibility } from '@/tools/types' + +/** + * Visibility assumed for a `tool-input` param that declares none. Matches the tool-row + * renderer's own fallback, so an unannotated param is never treated as user-required. + */ +const DEFAULT_TOOL_PARAM_VISIBILITY: ParameterVisibility = 'user-or-llm' + +/** + * Whether a param nested in a `tool-input` must be supplied by the USER. + * + * Only `user-only` qualifies — it is the one visibility where no other source for the value + * exists. A `user-or-llm` or `llm-only` param is still mandatory at runtime, but the model + * supplies it: `createLLMToolSchema` keeps an empty param in the schema handed to the model + * (and in that schema's `required` list), so a blank value is a deliberate configuration + * state rather than a missing one. + * + * The predicate is deliberately the positive `=== 'user-only'` rather than a + * `user-or-llm || llm-only` denylist: the two non-user visibilities reach the same verdict + * for different reasons, and a denylist would silently mis-classify any visibility added + * later. + */ +export function isToolParamUserRequired( + config: Pick, + values: Record +): boolean { + if (!isUserSuppliedToolParam(config)) return false + return isSubBlockRequired(config.required, values) +} + +/** + * The visibility half of {@link isToolParamUserRequired}, without evaluating `required`. + * + * Callers that let a downstream component resolve `required` in its own value context (the + * tool-row renderer) need exactly this question and must not collapse the condition here — + * doing so would evaluate it against a different value map than the one the field renders + * with. + */ +export function isUserSuppliedToolParam(config: Pick): boolean { + return (config.paramVisibility ?? DEFAULT_TOOL_PARAM_VISIBILITY) === 'user-only' +} + +/** + * Whether a `tool-input` param must be supplied by the user, resolving its visibility from a + * map keyed by sub-block id and canonical param id. + * + * Shared by fork sync's PRE-sync collector (which decides whether a row blocks the Sync + * button) and its POST-sync collector (whose `required` entries make promote SKIP the + * target's redeploy). Those two must agree: if the modal lets a sync through because a param + * is the model's to fill, the promote path must not then withhold the deployment for the + * same param. + * + * `paramVisibilityById` omitted means the caller is not in a tool-input context (a block's + * own sub-blocks), so the plain block-level rule applies. A param absent from the map, or + * present with an `undefined` value, falls back the same way — failing closed. + */ +export function resolveToolParamRequired( + config: Pick, + values: Record, + paramVisibilityById?: ReadonlyMap +): boolean { + if (!paramVisibilityById) return isSubBlockRequired(config.required, values) + const visibility = + paramVisibilityById.get(config.id) ?? + (config.canonicalParamId ? paramVisibilityById.get(config.canonicalParamId) : undefined) + if (visibility === undefined) return isSubBlockRequired(config.required, values) + return isToolParamUserRequired({ required: config.required, paramVisibility: visibility }, values) +} + +/** + * Resolve a sub-block's `required` declaration against the surrounding values. `true` is + * unconditional; the object form is structurally a `SubBlockCondition` evaluated against the + * same value map the editor uses (so an operation-scoped requirement resolves per operation). + */ +export function isSubBlockRequired( + required: SubBlockConfig['required'], + values: Record +): boolean { + if (required === true) return true + if (!required) return false + return evaluateSubBlockCondition( + required as Parameters[0], + values + ) +} diff --git a/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..2f4f26592b8 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -1,5 +1,5 @@ { - "name": "sim", + "name": "@sim/app", "version": "0.1.0", "private": true, "license": "Apache-2.0", @@ -192,6 +192,7 @@ "micromatch": "4.0.8", "monaco-editor": "0.55.1", "mongodb": "6.19.0", + "mssql": "12.7.0", "mysql2": "3.14.3", "neo4j-driver": "6.0.1", "next": "16.2.12", @@ -255,6 +256,7 @@ "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", "@types/micromatch": "4.0.10", + "@types/mssql": "12.3.0", "@types/node": "24.2.1", "@types/nodemailer": "8.0.1", "@types/prismjs": "^1.26.5", diff --git a/apps/sim/public/library/automation-anywhere-alternative/cover.jpg b/apps/sim/public/library/automation-anywhere-alternative/cover.jpg new file mode 100644 index 00000000000..506fa135f9c Binary files /dev/null and b/apps/sim/public/library/automation-anywhere-alternative/cover.jpg differ diff --git a/apps/sim/tools/cloudflare/cloudflare.test.ts b/apps/sim/tools/cloudflare/cloudflare.test.ts new file mode 100644 index 00000000000..04c740078d5 --- /dev/null +++ b/apps/sim/tools/cloudflare/cloudflare.test.ts @@ -0,0 +1,632 @@ +/** + * @vitest-environment node + * + * Guards the per-operation subBlock defaults in the Cloudflare block. + * + * SubBlock initial values are seeded into block state keyed by subBlock id + * (`stores/workflows/utils.ts` and `lib/workflows/defaults.ts` both assign + * `subBlocks[subBlock.id] = ...` in a plain forEach), so two controls sharing an + * id leave a single stored value and the LAST definition in file order wins. + * These tests assert the seeded default reaching each tool for operations whose + * control is deliberately not last, so re-introducing a collision goes red. + */ +import { describe, expect, it } from 'vitest' +import { CloudflareBlock } from '@/blocks/blocks/cloudflare' +import * as cloudflareTools from '@/tools/cloudflare' + +const apiKey = 'cf-token' + +const mapParams = CloudflareBlock.tools.config?.params + +/** + * Reproduces what the stores seed into block state: every subBlock default, + * keyed by id, with later definitions overwriting earlier ones. + */ +function seededDefaults(): Record { + const seeded: Record = {} + for (const subBlock of CloudflareBlock.subBlocks) { + if (typeof subBlock.value === 'function') { + seeded[subBlock.id] = (subBlock.value as (p: Record) => unknown)({}) + } + } + return seeded +} + +/** + * Returns what the tool actually receives. The executor merges the mapper's + * output OVER the raw inputs (`finalInputs = { ...inputs, ...transformedParams }` + * in `executor/handlers/generic/generic-handler.ts`), so a key the mapper merely + * omits survives as its raw subBlock string. Asserting on the mapper's return + * alone would let an alias or a stale filter through unnoticed. + */ +function mapFor(operation: string, extra: Record = {}) { + const inputs = { ...seededDefaults(), apiKey, operation, ...extra } + return { ...inputs, ...(mapParams?.(inputs as never) as Record) } +} + +describe('subBlock ids that share a tool param keep their own default', () => { + it('does not leak the Access application type onto DNS record creation', () => { + // The Access "Application Type" control is defined after every DNS type + // control, so a shared id would seed create_dns_record with self_hosted. + const mapped = mapFor('create_dns_record', { + zoneId: 'zone1', + name: 'www.example.com', + content: '203.0.113.10', + }) + + expect(mapped.type).toBe('A') + expect(mapped.type).not.toBe('self_hosted') + }) + + it('keeps the Access application type when creating an application', () => { + expect(mapFor('create_access_application', { accountId: 'acct1' }).type).toBe('self_hosted') + }) + + it('never seeds a type or decision onto an Access resource it is about to replace', () => { + // Both Access updates are full replacements, so a seeded value rewrites what + // the live resource IS as soon as anything else is edited — a policy would + // flip from deny to allow, an application from saas to self_hosted. + const app = mapFor('update_access_application', { accountId: 'acct1', appId: 'app1' }) + expect(app.type).toBeUndefined() + expect( + mapFor('update_access_application', { + accountId: 'acct1', + appId: 'app1', + updateAppType: 'saas', + }).type + ).toBe('saas') + + const policy = mapFor('update_access_policy', { accountId: 'acct1', policyId: 'p1' }) + expect(policy.decision).toBeUndefined() + expect( + mapFor('update_access_policy', { + accountId: 'acct1', + policyId: 'p1', + updatePolicyDecision: 'deny', + }).decision + ).toBe('deny') + }) + + it('leaves the DNS record type unset on the filter operations', () => { + expect(mapFor('list_dns_records', { zoneId: 'zone1' }).type).toBeUndefined() + expect(mapFor('update_dns_record', { zoneId: 'zone1', recordId: 'rec1' }).type).toBeUndefined() + }) + + it('preserves the certificate status default past the later status filters', () => { + // list_tunnels defines the last `status` control and defaults it to empty. + expect(mapFor('list_certificates', { zoneId: 'zone1' }).status).toBe('all') + expect(mapFor('list_zones').status).toBeUndefined() + expect(mapFor('list_tunnels', { accountId: 'acct1' }).status).toBeUndefined() + }) + + it('preserves the created-record proxied default past the later proxied filters', () => { + const mapped = mapFor('create_dns_record', { + zoneId: 'zone1', + name: 'www.example.com', + content: '203.0.113.10', + }) + + expect(mapped.proxied).toBe(false) + expect(mapFor('list_dns_records', { zoneId: 'zone1' }).proxied).toBeUndefined() + }) + + it('does not seed a block action onto a WAF custom rule', () => { + // The rate limiting action dropdown defaults to block and is defined after + // the ruleset-rule action input; sharing an id would make every new WAF + // custom rule silently default to blocking traffic. + const mapped = mapFor('create_ruleset_rule', { + zoneId: 'zone1', + rulesetId: 'rs1', + expression: 'true', + }) + + expect(mapped.action).toBeUndefined() + }) + + it('keeps the block default when creating a rate limiting rule', () => { + expect(mapFor('create_rate_limit_rule', { zoneId: 'zone1', rulesetId: 'rs1' }).action).toBe( + 'block' + ) + }) + + it('never seeds an action onto a rate limiting rule it is about to replace', () => { + // The update endpoint replaces the rule, so a seeded block would convert a + // live log or challenge rule into a hard block the moment anything else is + // edited. The user has to state the action instead. + expect( + mapFor('update_rate_limit_rule', { zoneId: 'zone1', rulesetId: 'rs1', ruleId: 'r1' }).action + ).toBeUndefined() + + expect( + mapFor('update_rate_limit_rule', { + zoneId: 'zone1', + rulesetId: 'rs1', + ruleId: 'r1', + updateRateLimitAction: 'log', + }).action + ).toBe('log') + }) + + it('strips the aliased control ids so they never reach a tool as params', () => { + const mapped = mapFor('create_dns_record', { zoneId: 'zone1' }) + + for (const alias of [ + 'zoneType', + 'recordType', + 'recordProxied', + 'recordTags', + 'updateRecordType', + 'updateRecordName', + 'updateRecordContent', + 'updateRecordProxied', + 'updateRecordTags', + 'dnsOrder', + 'certificateStatus', + 'rulesetName', + 'updateRuleEnabled', + 'rateLimitAction', + 'updateRateLimitAction', + 'appType', + 'updateAppType', + 'accessAppTags', + 'updatePolicyDecision', + 'listNameFilter', + 'accessAppDomainFilter', + 'workerTagFilter', + 'tunnelStatus', + 'r2Cursor', + 'rulesetCursor', + ]) { + expect(mapped[alias], `alias ${alias} reached the tool`).toBeUndefined() + } + }) + + it('sends the ruleset name as the name param when creating a ruleset', () => { + const mapped = mapFor('create_ruleset', { + zoneId: 'zone1', + phase: 'http_ratelimit', + rulesetName: 'Zone rate limiting ruleset', + }) + + expect(mapped.name).toBe('Zone rate limiting ruleset') + expect(mapped.rulesetName).toBeUndefined() + }) +}) + +/** + * `shouldSerializeSubBlock` (serializer/index.ts) short-circuits on + * `mode: 'advanced'` BEFORE evaluating `condition`, so an advanced control whose + * stored value is non-empty is serialized even when the selected operation does + * not render it. That is harmless while every operation that consumes the id + * also exposes a control for it — the user can see and change the value — and it + * is a silent cross-operation write when it does not. + */ +/** + * Two mechanical guards on subBlock id reuse. Block state is keyed by subBlock + * id, so controls sharing an id share one stored value — fine when they mean the + * same thing, a silent cross-operation bug when they do not. These encode the + * two shapes that divergence takes here. + */ +describe('a shared subBlock id means the same thing everywhere', () => { + /** + * Ids that legitimately span reads and writes because they address the + * resource rather than carry payload: credentials, account/zone scope, the + * R2 jurisdiction routing header, the ruleset phase, and resource ids. + */ + const ADDRESSING_IDS = new Set([ + 'apiKey', + 'operation', + 'accountId', + 'zoneId', + 'jurisdiction', + 'phase', + 'appId', + 'bucketName', + 'rulesetId', + ]) + + /** + * Ids a read filter and a written value share on purpose. Both sides render a + * control the user can see and edit for their own operation, so a carried-over + * value is visible rather than hidden — and these are the ids shipped + * workflows already store, which a rename would strand (see + * `the ids shipped workflows already store are still live`). + */ + const SHARED_VISIBLE_IDS = new Set(['name', 'content']) + + const WRITE_PREFIXES = ['create_', 'update_', 'delete_', 'purge_', 'revoke_'] + + function operationsFor(subBlock: (typeof CloudflareBlock.subBlocks)[number]): string[] { + const condition = subBlock.condition + if (!condition || typeof condition !== 'object' || !('field' in condition)) return [] + if (condition.field !== 'operation') return [] + const value = condition.value + return Array.isArray(value) ? value.map(String) : [String(value)] + } + + it('never shares an id between a read filter and a written value', () => { + const kindsById = new Map>() + + for (const subBlock of CloudflareBlock.subBlocks) { + if (ADDRESSING_IDS.has(subBlock.id) || SHARED_VISIBLE_IDS.has(subBlock.id)) continue + for (const operation of operationsFor(subBlock)) { + const kind = WRITE_PREFIXES.some((prefix) => operation.startsWith(prefix)) + ? 'write' + : 'read' + const kinds = kindsById.get(subBlock.id) ?? new Set() + kinds.add(kind) + kindsById.set(subBlock.id, kinds) + } + } + + const mixed = [...kindsById.entries()] + .filter(([, kinds]) => kinds.size > 1) + .map(([id]) => id) + .sort() + + expect(mixed).toEqual([]) + }) + + it('never gives one dropdown id two different option sets', () => { + const optionsById = new Map>() + + for (const subBlock of CloudflareBlock.subBlocks) { + if (subBlock.type !== 'dropdown' || !Array.isArray(subBlock.options)) continue + const signature = JSON.stringify( + subBlock.options.map((option) => + typeof option === 'string' ? option : ((option as { id?: string }).id ?? '') + ) + ) + const signatures = optionsById.get(subBlock.id) ?? new Set() + signatures.add(signature) + optionsById.set(subBlock.id, signatures) + } + + const divergent = [...optionsById.entries()] + .filter(([, signatures]) => signatures.size > 1) + .map(([id, signatures]) => `${id}: ${[...signatures].join(' vs ')}`) + + expect(divergent).toEqual([]) + }) +}) + +describe('no hidden advanced control feeds an operation that cannot show it', () => { + const STALE = '__stale_value__' + + const toolsByOperation = new Map( + Object.values(cloudflareTools).map((tool) => [tool.id.replace(/^cloudflare_/, ''), tool]) + ) + + /** Operations a subBlock's `condition` makes it visible for. */ + function conditionOperations(subBlock: (typeof CloudflareBlock.subBlocks)[number]): string[] { + const condition = subBlock.condition + if (!condition || typeof condition !== 'object' || !('field' in condition)) return [] + if (condition.field !== 'operation') return [] + const value = condition.value + return Array.isArray(value) ? value.map(String) : [String(value)] + } + + it('every advanced id reaching a tool is either shown or remapped for that operation', () => { + const operations = [...toolsByOperation.keys()] + const visibleIdsByOperation = new Map>( + operations.map((operation) => [operation, new Set()]) + ) + for (const subBlock of CloudflareBlock.subBlocks) { + for (const operation of conditionOperations(subBlock)) { + visibleIdsByOperation.get(operation)?.add(subBlock.id) + } + } + + const leaks: string[] = [] + + for (const subBlock of CloudflareBlock.subBlocks) { + if (subBlock.mode !== 'advanced') continue + const ownOperations = new Set(conditionOperations(subBlock)) + + for (const operation of operations) { + if (ownOperations.has(operation)) continue + const tool = toolsByOperation.get(operation) + if (!tool?.params || !(subBlock.id in tool.params)) continue + if (visibleIdsByOperation.get(operation)?.has(subBlock.id)) continue + + // The mapper must overwrite or clear the stale value for this operation. + const mapped = mapFor(operation, { [subBlock.id]: STALE }) + if (mapped[subBlock.id] === STALE) { + leaks.push( + `"${subBlock.id}" (advanced, shown for ${[...ownOperations].join('/') || 'nothing'}) reaches ${operation} as a param it never renders` + ) + } + } + } + + expect(leaks).toEqual([]) + }) +}) + +/** + * Block state is never migrated, and `extractBlockParams` (`serializer/index.ts`) + * drops a stored value whose id matches no subBlock config — for a non-custom + * block that is a deleted input. So renaming a control strands whatever shipped + * workflows stored under the old id, and no mapper-level fallback can recover + * it: the value is gone before `tools.config.params` runs. + * + * That decides which side of an id collision may be renamed. A read filter that + * loses its value returns the whole zone with `success: true` — which a + * downstream `delete_dns_record` then fans out over — so filters keep the ids + * shipped workflows already hold. A write control that loses its value just + * omits the field from a PATCH, so the new id goes there. + */ +describe('the ids shipped workflows already store are still live', () => { + function operationsFor(id: string): string[] { + return CloudflareBlock.subBlocks.flatMap((subBlock) => { + if (subBlock.id !== id) return [] + const condition = subBlock.condition + if (!condition || typeof condition !== 'object' || !('field' in condition)) return [] + if (condition.field !== 'operation') return [] + const value = condition.value + return Array.isArray(value) ? value.map(String) : [String(value)] + }) + } + + it.each([ + ['list_dns_records', 'type'], + ['list_dns_records', 'name'], + ['list_dns_records', 'content'], + ['list_dns_records', 'proxied'], + ['list_dns_records', 'search'], + ['list_zones', 'name'], + ['list_zones', 'status'], + ['purge_cache', 'tags'], + ])('%s still reads its %s filter from the id it shipped with', (operation, id) => { + expect(operationsFor(id)).toContain(operation) + }) + + it('still passes each of those filters through to the tool', () => { + const records = mapFor('list_dns_records', { + zoneId: 'zone1', + type: 'A', + name: 'www.example.com', + content: '203.0.113.10', + proxied: 'true', + search: 'legacy', + }) + expect(records.type).toBe('A') + expect(records.name).toBe('www.example.com') + expect(records.content).toBe('203.0.113.10') + expect(records.proxied).toBe(true) + expect(records.search).toBe('legacy') + + expect(mapFor('list_zones', { name: 'example.com', status: 'active' })).toMatchObject({ + name: 'example.com', + status: 'active', + }) + expect(mapFor('purge_cache', { zoneId: 'z1', tags: 'a,b' }).tags).toBe('a,b') + }) +}) + +describe('the rule enable switch is split between creating and updating', () => { + it('never carries a create-time enabled onto a rule update', () => { + // `enabled` is advanced, so it serializes on stored value alone, before its + // condition runs. Shared, a `false` chosen while drafting a new rule would + // disable a live WAF or rate limiting rule on a later update. + for (const operation of ['update_ruleset_rule', 'update_rate_limit_rule']) { + const mapped = mapFor(operation, { + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + enabled: 'false', + }) + expect(mapped.enabled, `${operation} took a create-time enabled`).toBeUndefined() + } + }) + + it('sends the update control when the caller sets it', () => { + expect( + mapFor('update_ruleset_rule', { + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + updateRuleEnabled: 'false', + }).enabled + ).toBe(false) + }) + + it('still sends the create control on the create operations', () => { + expect( + mapFor('create_ruleset_rule', { zoneId: 'z1', rulesetId: 'rs1', enabled: 'false' }).enabled + ).toBe(false) + }) +}) + +describe('a DNS record rename cannot be inherited from another operation', () => { + it('ignores a name typed under any other operation', () => { + // `name` is shared by zone, Access application, policy, and service token + // creation. The update control is advanced, so a shared id let any of those + // reach the PATCH and rename the live record. + expect( + mapFor('update_dns_record', { zoneId: 'z1', recordId: 'rec1', name: 'ci-pipeline' }).name + ).toBeUndefined() + }) + + it('renames only when the record-name control is set', () => { + expect( + mapFor('update_dns_record', { + zoneId: 'z1', + recordId: 'rec1', + updateRecordName: 'www.example.com', + }).name + ).toBe('www.example.com') + }) +}) + +describe('Access application types the API can actually build', () => { + const appTypeOptions = (id: string) => { + const subBlock = CloudflareBlock.subBlocks.find((sub) => sub.id === id) + const options = subBlock?.options + return (Array.isArray(options) ? options : []).map((option) => + typeof option === 'string' ? option : ((option as { id?: string }).id ?? '') + ) + } + + it('drops dash_sso, which has no request variant at all', () => { + expect(appTypeOptions('appType')).not.toContain('dash_sso') + expect(appTypeOptions('updateAppType')).not.toContain('dash_sso') + }) + + it('requires a domain exactly for the types whose request schema demands one', () => { + const domain = CloudflareBlock.subBlocks.find((sub) => sub.id === 'domain') + const required = domain?.required + expect(typeof required).toBe('function') + const resolve = required as (values?: Record) => { + field: string + value: string | number | boolean | Array + } + + const onCreate = resolve({ operation: 'create_access_application' }) + expect(onCreate.field).toBe('appType') + expect(onCreate.value).toEqual(['self_hosted', 'ssh', 'vnc', 'rdp']) + + const onUpdate = resolve({ operation: 'update_access_application' }) + expect(onUpdate.field).toBe('updateAppType') + expect(onUpdate.value).toEqual(['self_hosted', 'ssh', 'vnc', 'rdp']) + }) + + it('carries the fields saas, infrastructure, and rdp applications cannot be created without', () => { + for (const tool of [ + cloudflareTools.cloudflareCreateAccessApplicationTool, + cloudflareTools.cloudflareUpdateAccessApplicationTool, + ]) { + const body = tool.request.body?.({ + accountId: 'acct1', + appId: 'app1', + apiKey, + type: 'saas', + saasApp: '{"auth_type":"saml"}', + targetCriteria: '[{"port":22,"protocol":"SSH"}]', + } as never) as Record + + expect(body.saas_app).toEqual({ auth_type: 'saml' }) + expect(body.target_criteria).toEqual([{ port: 22, protocol: 'SSH' }]) + } + }) +}) + +describe('the ruleset kind offered matches the endpoint', () => { + it('does not offer root, which only exists at the account level', () => { + const kind = CloudflareBlock.subBlocks.find((sub) => sub.id === 'kind') + const options = (Array.isArray(kind?.options) ? kind.options : []).map( + (option) => (option as { id?: string }).id ?? '' + ) + + expect(options).not.toContain('root') + expect(options).toContain('zone') + }) +}) + +describe('an update that would tear down the rule it edits is refused', () => { + const updateRule = cloudflareTools.cloudflareUpdateRulesetRuleTool + + it('refuses an execute rule with no action parameters', () => { + // PATCH replaces the rule, so omitting action_parameters resets it to {} and + // unbinds the managed ruleset the rule deploys, plus every override under it. + expect(() => + updateRule.request.body?.({ + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + apiKey, + action: 'execute', + expression: 'true', + } as never) + ).toThrow(/Action Parameters is required/) + }) + + /** + * An explicit `{}` is the same payload Cloudflare's schema default produces, so + * it does the same damage as omitting the field. Checking presence rather than + * emptiness let it through the guard. + */ + it('refuses an execute rule whose action parameters are an empty object', () => { + expect(() => + updateRule.request.body?.({ + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + apiKey, + action: 'execute', + expression: 'true', + actionParameters: '{}', + } as never) + ).toThrow(/Action Parameters is required/) + }) + + it('leaves an empty action parameters object alone on a non-execute action', () => { + const body = updateRule.request.body?.({ + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + apiKey, + action: 'block', + expression: 'true', + actionParameters: '{}', + } as never) as Record + + expect(body.action_parameters).toEqual({}) + }) + + it('accepts an execute rule that resends its action parameters', () => { + const body = updateRule.request.body?.({ + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + apiKey, + action: 'execute', + expression: 'true', + actionParameters: '{"id":"managed-1"}', + } as never) as Record + + expect(body.action_parameters).toEqual({ id: 'managed-1' }) + }) + + it('leaves non-execute actions alone', () => { + expect(() => + updateRule.request.body?.({ + zoneId: 'z1', + rulesetId: 'rs1', + ruleId: 'r1', + apiKey, + action: 'block', + expression: 'true', + } as never) + ).not.toThrow() + }) + + it('warns in both descriptions that omission resets the field', () => { + expect(updateRule.params.actionParameters.description).toMatch(/resets action_parameters/) + expect(updateRule.params.ref.description).toMatch(/omitting it resets/) + }) + + it('requires action parameters in the block whenever the action is execute', () => { + const actionParameters = CloudflareBlock.subBlocks.find((sub) => sub.id === 'actionParameters') + expect(actionParameters?.required).toEqual({ field: 'action', value: 'execute' }) + }) +}) + +describe('optional and per-API pagination params', () => { + it('does not force a metrics list the DNS analytics API treats as optional', () => { + expect(cloudflareTools.cloudflareDnsAnalyticsTool.params.metrics.required).toBe(false) + }) + + it('keeps the R2 and ruleset cursors apart', () => { + // R2 answers with result_info.cursor and the Rulesets API with + // result_info.cursors.after, so a cursor carried across the two 400s. + expect( + mapFor('list_r2_buckets', { accountId: 'a1', rulesetCursor: 'ruleset-1' }).cursor + ).toBeUndefined() + expect(mapFor('list_rulesets', { zoneId: 'z1', r2Cursor: 'r2-1' }).cursor).toBeUndefined() + expect(mapFor('list_r2_buckets', { accountId: 'a1', r2Cursor: 'r2-1' }).cursor).toBe('r2-1') + expect(mapFor('list_rulesets', { zoneId: 'z1', rulesetCursor: 'ruleset-1' }).cursor).toBe( + 'ruleset-1' + ) + }) +}) diff --git a/apps/sim/tools/cloudflare/create_access_application.ts b/apps/sim/tools/cloudflare/create_access_application.ts new file mode 100644 index 00000000000..77550d13061 --- /dev/null +++ b/apps/sim/tools/cloudflare/create_access_application.ts @@ -0,0 +1,258 @@ +import type { + CloudflareAccessApplicationResponse, + CloudflareCreateAccessApplicationParams, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyAccessApplication, + mapAccessApplication, + parseCsvParam, + parseJsonArrayParam, + parseJsonObjectParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const createAccessApplicationTool: ToolConfig< + CloudflareCreateAccessApplicationParams, + CloudflareAccessApplicationResponse +> = { + id: 'cloudflare_create_access_application', + name: 'Cloudflare Create Access Application', + description: + 'Creates a Cloudflare Access (Zero Trust) application that puts an identity check in front of a hostname. Until at least one policy is attached the application denies everyone, so pair this with "Create Access Policy". Requires an API token with Account Access: Apps and Policies Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Application type: self_hosted, saas, ssh, vnc, app_launcher, warp, biso, bookmark, infrastructure, rdp, mcp, mcp_portal, or proxy_endpoint. dash_sso has no request variant and cannot be created through the API', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The primary hostname and path secured by Access, e.g. internal.example.com or example.com/admin. Required for the self_hosted, ssh, vnc, and rdp types; optional for bookmark and mcp_portal; read-only for app_launcher, warp, biso, and proxy_endpoint; and absent from the saas, infrastructure, and mcp variants', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Friendly name shown in the dashboard and App Launcher', + }, + sessionDuration: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How long an Access session stays valid, e.g. 24h or 30m', + }, + allowedIdps: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated identity provider IDs users may authenticate with. Leave empty to allow all configured providers', + }, + appLauncherVisible: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the application is shown in the App Launcher', + }, + autoRedirectToIdentity: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether users skip the identity provider picker', + }, + customDenyMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Message shown to users who are denied access', + }, + customDenyUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL denied users are redirected to', + }, + logoUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Logo image URL shown in the dashboard and App Launcher', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated tag names categorizing the application', + }, + policies: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of policies to attach. Entries may be reusable policy IDs or inline policy objects, e.g. [""]', + }, + saasApp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON SaaS configuration, required for the saas type and rejected on every other type. SAML, e.g. {"auth_type":"saml","consumer_service_url":"https://example.com/acs","sp_entity_id":"https://example.com"}; OIDC, e.g. {"auth_type":"oidc","client_id":"...","redirect_uris":["https://example.com/callback"]}', + }, + targetCriteria: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of infrastructure target criteria, required for the infrastructure and rdp types and rejected on every other type, e.g. [{"port":22,"protocol":"SSH","target_attributes":{"hostname":["production"]}}]', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps`, + method: 'POST', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const body: Record = { type: params.type } + /** + * `domain` is writable only on the self_hosted, ssh, vnc, rdp, bookmark, + * and mcp_portal request variants — required on the first four — read-only + * on app_launcher, warp, biso, and proxy_endpoint, and absent from saas, + * infrastructure, and mcp. Sending a blank one makes those types + * unbuildable, so it is only forwarded when set. + */ + if (params.domain) body.domain = params.domain + if (params.name) body.name = params.name + if (params.sessionDuration) body.session_duration = params.sessionDuration + + const allowedIdps = parseCsvParam(params.allowedIdps) + if (allowedIdps) body.allowed_idps = allowedIdps + + if (params.appLauncherVisible !== undefined) { + body.app_launcher_visible = params.appLauncherVisible + } + if (params.autoRedirectToIdentity !== undefined) { + body.auto_redirect_to_identity = params.autoRedirectToIdentity + } + if (params.customDenyMessage) body.custom_deny_message = params.customDenyMessage + if (params.customDenyUrl) body.custom_deny_url = params.customDenyUrl + if (params.logoUrl) body.logo_url = params.logoUrl + + const tags = parseCsvParam(params.tags) + if (tags) body.tags = tags + + const policies = parseJsonArrayParam(params.policies, 'Policies') + if (policies) body.policies = policies + + const saasApp = parseJsonObjectParam(params.saasApp, 'SaaS Application') + if (saasApp) body.saas_app = saasApp + + const targetCriteria = parseJsonArrayParam(params.targetCriteria, 'Target Criteria') + if (targetCriteria) body.target_criteria = targetCriteria + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyAccessApplication(), + error: cloudflareErrorMessage(data, 'Failed to create Access application'), + } + } + + return { success: true, output: mapAccessApplication(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Created Access application identifier' }, + name: { type: 'string', description: 'Application name', optional: true }, + domain: { + type: 'string', + description: 'Primary hostname and path secured by Access', + optional: true, + }, + type: { type: 'string', description: 'Application type', optional: true }, + aud: { type: 'string', description: 'Audience tag used to verify Access JWTs', optional: true }, + session_duration: { + type: 'string', + description: 'How long an Access session stays valid', + optional: true, + }, + allowed_idps: { + type: 'array', + description: 'Identity provider IDs users may authenticate with', + items: { type: 'string', description: 'Identity provider ID' }, + optional: true, + }, + app_launcher_visible: { + type: 'boolean', + description: 'Whether the app appears in the App Launcher', + optional: true, + }, + auto_redirect_to_identity: { + type: 'boolean', + description: 'Whether users skip the identity provider picker', + optional: true, + }, + custom_deny_message: { + type: 'string', + description: 'Message shown when access is denied', + optional: true, + }, + custom_deny_url: { + type: 'string', + description: 'URL users are redirected to when access is denied', + optional: true, + }, + logo_url: { type: 'string', description: 'Logo image URL', optional: true }, + self_hosted_domains: { + type: 'array', + description: + 'Additional hostnames and paths secured by the application. Cloudflare deprecated this field in favour of destinations, which is the one to read on a current application', + items: { type: 'string', description: 'Hostname and path' }, + optional: true, + }, + destinations: { + type: 'json', + description: 'Public and private destinations secured by the application', + optional: true, + }, + tags: { + type: 'array', + description: 'Tags categorizing the application', + items: { type: 'string', description: 'Tag name' }, + optional: true, + }, + policies: { + type: 'json', + description: 'Access policies attached to the application', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/create_access_policy.ts b/apps/sim/tools/cloudflare/create_access_policy.ts new file mode 100644 index 00000000000..6cb6d6db8ce --- /dev/null +++ b/apps/sim/tools/cloudflare/create_access_policy.ts @@ -0,0 +1,215 @@ +import type { + CloudflareAccessPolicyResponse, + CloudflareCreateAccessPolicyParams, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyAccessPolicy, + mapAccessPolicy, + parseJsonArrayParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const createAccessPolicyTool: ToolConfig< + CloudflareCreateAccessPolicyParams, + CloudflareAccessPolicyResponse +> = { + id: 'cloudflare_create_access_policy', + name: 'Cloudflare Create Access Policy', + description: + 'Creates a Cloudflare Access (Zero Trust) policy on an application, deciding who may reach it. A policy takes effect on live traffic as soon as it is created — an allow policy with a broad include rule grants access immediately. Requires an API token with Account Access: Apps and Policies Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access application ID to attach the policy to', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the policy', + }, + decision: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'What the policy does when it matches: allow, deny, non_identity (service tokens and other non-identity rules), or bypass (skip Access entirely)', + }, + include: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of Access rules evaluated with OR logic — matching any one selects the policy. Example: [{"email":{"email":"user@example.com"}}] or [{"email_domain":{"domain":"example.com"}}]', + }, + exclude: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of Access rules evaluated with NOT logic — matching any one rejects the request', + }, + require: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of Access rules evaluated with AND logic — all of them must match', + }, + precedence: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Evaluation order of the policy within the application', + }, + sessionDuration: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'How long a session granted by this policy stays valid, e.g. 24h. Leave it unset on a policy attached to an infrastructure-typed application — Cloudflare rejects those with error 12130', + }, + approvalRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether an approver must grant each access request', + }, + isolationRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the session must run in a remote isolated browser', + }, + purposeJustificationRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether users must state a reason for access', + }, + purposeJustificationPrompt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prompt shown when a justification is required', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps/${params.appId.trim()}/policies`, + method: 'POST', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const include = parseJsonArrayParam(params.include, 'Include Rules') + if (!include || include.length === 0) { + throw new Error('Include Rules must contain at least one Access rule') + } + + const body: Record = { + name: params.name, + decision: params.decision, + include, + } + + const exclude = parseJsonArrayParam(params.exclude, 'Exclude Rules') + if (exclude) body.exclude = exclude + + const require = parseJsonArrayParam(params.require, 'Require Rules') + if (require) body.require = require + + if (params.precedence !== undefined) body.precedence = params.precedence + if (params.sessionDuration) body.session_duration = params.sessionDuration + if (params.approvalRequired !== undefined) body.approval_required = params.approvalRequired + if (params.isolationRequired !== undefined) body.isolation_required = params.isolationRequired + if (params.purposeJustificationRequired !== undefined) { + body.purpose_justification_required = params.purposeJustificationRequired + } + if (params.purposeJustificationPrompt) { + body.purpose_justification_prompt = params.purposeJustificationPrompt + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyAccessPolicy(), + error: cloudflareErrorMessage(data, 'Failed to create Access policy'), + } + } + + return { success: true, output: mapAccessPolicy(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Created policy identifier' }, + name: { type: 'string', description: 'Policy name', optional: true }, + decision: { + type: 'string', + description: 'Decision the policy applies: allow, deny, non_identity, or bypass', + optional: true, + }, + precedence: { + type: 'number', + description: 'Evaluation order of the policy within the application', + optional: true, + }, + include: { + type: 'json', + description: 'Rules evaluated with OR logic', + optional: true, + }, + exclude: { type: 'json', description: 'Rules evaluated with NOT logic', optional: true }, + require: { type: 'json', description: 'Rules evaluated with AND logic', optional: true }, + session_duration: { + type: 'string', + description: 'How long a session granted by this policy stays valid', + optional: true, + }, + approval_required: { + type: 'boolean', + description: 'Whether an approver must grant each access request', + optional: true, + }, + isolation_required: { + type: 'boolean', + description: 'Whether the session must run in a remote browser', + optional: true, + }, + purpose_justification_required: { + type: 'boolean', + description: 'Whether users must state a reason for access', + optional: true, + }, + purpose_justification_prompt: { + type: 'string', + description: 'Prompt shown when a justification is required', + optional: true, + }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + updated_at: { type: 'string', description: 'Last update timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/cloudflare/create_access_service_token.ts b/apps/sim/tools/cloudflare/create_access_service_token.ts new file mode 100644 index 00000000000..dc6487411c7 --- /dev/null +++ b/apps/sim/tools/cloudflare/create_access_service_token.ts @@ -0,0 +1,123 @@ +import type { + CloudflareCreateAccessServiceTokenParams, + CloudflareCreateAccessServiceTokenResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const createAccessServiceTokenTool: ToolConfig< + CloudflareCreateAccessServiceTokenParams, + CloudflareCreateAccessServiceTokenResponse +> = { + id: 'cloudflare_create_access_service_token', + name: 'Cloudflare Create Access Service Token', + description: + 'Creates a Cloudflare Access (Zero Trust) service token so a machine can authenticate to Access-protected applications. This is the only response that ever contains the client secret — Cloudflare will not return it again, so capture it in the same run. Requires an API token with Account Access: Service Tokens Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Service tokens are account-scoped', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the service token', + }, + duration: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "How long the token stays valid before it expires, e.g. 8760h. Defaults to Cloudflare's standard lifetime", + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/service_tokens`, + method: 'POST', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const body: Record = { name: params.name } + if (params.duration) body.duration = params.duration + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + id: '', + name: null, + client_id: null, + client_secret: null, + duration: null, + enabled: null, + expires_at: null, + last_seen_at: null, + created_at: null, + updated_at: null, + }, + error: cloudflareErrorMessage(data, 'Failed to create Access service token'), + } + } + + const token = data.result + return { + success: true, + output: { + id: token?.id ?? '', + name: token?.name ?? null, + client_id: token?.client_id ?? null, + client_secret: token?.client_secret ?? null, + duration: token?.duration ?? null, + enabled: token?.enabled ?? null, + expires_at: token?.expires_at ?? null, + last_seen_at: token?.last_seen_at ?? null, + created_at: token?.created_at ?? null, + updated_at: token?.updated_at ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Created service token identifier' }, + name: { type: 'string', description: 'Service token name', optional: true }, + client_id: { + type: 'string', + description: 'Client ID sent in the CF-Access-Client-Id header', + optional: true, + }, + client_secret: { + type: 'string', + description: + 'Client secret sent in the CF-Access-Client-Secret header. Returned only once, at creation', + optional: true, + }, + duration: { + type: 'string', + description: 'How long the token stays valid before it expires', + optional: true, + }, + enabled: { type: 'boolean', description: 'Whether the token is active', optional: true }, + expires_at: { type: 'string', description: 'Expiry timestamp', optional: true }, + last_seen_at: { type: 'string', description: 'When the token was last used', optional: true }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + updated_at: { type: 'string', description: 'Last update timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/cloudflare/create_dns_record.ts b/apps/sim/tools/cloudflare/create_dns_record.ts index a962ce7277d..9fb75216ec8 100644 --- a/apps/sim/tools/cloudflare/create_dns_record.ts +++ b/apps/sim/tools/cloudflare/create_dns_record.ts @@ -54,7 +54,8 @@ export const createDnsRecordTool: ToolConfig< type: 'number', required: false, visibility: 'user-or-llm', - description: 'Priority for MX and SRV records', + description: + 'Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority, weight, port, and target inside the record content instead', }, comment: { type: 'string', @@ -77,14 +78,15 @@ export const createDnsRecordTool: ToolConfig< }, request: { - url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/dns_records`, + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/dns_records`, method: 'POST', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, 'Content-Type': 'application/json', }), body: (params) => { - const body: Record = { + const body: Record = { type: params.type, name: params.name, content: params.content, @@ -177,7 +179,11 @@ export const createDnsRecordTool: ToolConfig< proxied: { type: 'boolean', description: 'Whether Cloudflare proxy is enabled' }, ttl: { type: 'number', description: 'Time to live in seconds (1 = automatic)' }, locked: { type: 'boolean', description: 'Whether the record is locked' }, - priority: { type: 'number', description: 'Priority for MX and SRV records', optional: true }, + priority: { + type: 'number', + description: 'Record priority, returned for MX and URI records', + optional: true, + }, comment: { type: 'string', description: 'Comment associated with the record', optional: true }, tags: { type: 'array', diff --git a/apps/sim/tools/cloudflare/create_r2_bucket.ts b/apps/sim/tools/cloudflare/create_r2_bucket.ts new file mode 100644 index 00000000000..66524353208 --- /dev/null +++ b/apps/sim/tools/cloudflare/create_r2_bucket.ts @@ -0,0 +1,125 @@ +import type { + CloudflareCreateR2BucketParams, + CloudflareR2BucketResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const createR2BucketTool: ToolConfig< + CloudflareCreateR2BucketParams, + CloudflareR2BucketResponse +> = { + id: 'cloudflare_create_r2_bucket', + name: 'Cloudflare Create R2 Bucket', + description: + 'Creates an R2 object storage bucket in an account. The location hint and jurisdiction are fixed at creation and cannot be changed later. Requires an API token with Account Workers R2 Storage Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. R2 buckets are account-scoped', + }, + bucketName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name for the new bucket', + }, + locationHint: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Region hint for where the bucket should live: apac, eeur, enam, weur, wnam, or oc. Cannot be changed after creation', + }, + storageClass: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Default storage class for objects: Standard or InfrequentAccess', + }, + jurisdiction: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Data-residency jurisdiction to create the bucket in: default, eu, or fedramp. Cannot be changed after creation', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/r2/buckets`, + method: 'POST', + headers: (params) => { + const headers = cloudflareHeaders(params.apiKey) + if (params.jurisdiction) headers['cf-r2-jurisdiction'] = params.jurisdiction + return headers + }, + body: (params) => { + const body: Record = { name: params.bucketName } + if (params.locationHint) body.locationHint = params.locationHint + if (params.storageClass) body.storageClass = params.storageClass + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + name: '', + creation_date: null, + location: null, + storage_class: null, + jurisdiction: null, + }, + error: cloudflareErrorMessage(data, 'Failed to create R2 bucket'), + } + } + + const bucket = data.result + return { + success: true, + output: { + name: bucket?.name ?? '', + creation_date: bucket?.creation_date ?? null, + location: bucket?.location ?? null, + storage_class: bucket?.storage_class ?? null, + jurisdiction: bucket?.jurisdiction ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Created bucket name' }, + creation_date: { type: 'string', description: 'Creation timestamp', optional: true }, + location: { + type: 'string', + description: 'Location the bucket was created in', + optional: true, + }, + storage_class: { + type: 'string', + description: 'Default storage class (Standard or InfrequentAccess)', + optional: true, + }, + jurisdiction: { + type: 'string', + description: 'Data-residency jurisdiction (default, eu, or fedramp)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/create_rate_limit_rule.ts b/apps/sim/tools/cloudflare/create_rate_limit_rule.ts new file mode 100644 index 00000000000..58e69f7c58b --- /dev/null +++ b/apps/sim/tools/cloudflare/create_rate_limit_rule.ts @@ -0,0 +1,212 @@ +import type { + CloudflareCreateRateLimitRuleParams, + CloudflareRulesetResponse, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, + parseCsvParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const createRateLimitRuleTool: ToolConfig< + CloudflareCreateRateLimitRuleParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_create_rate_limit_rule', + name: 'Cloudflare Create Rate Limiting Rule', + description: + 'Creates a rate limiting rule in the http_ratelimit phase entry point ruleset of a zone, using the current Rulesets-based rate limiting API (the legacy rate_limits endpoint is no longer available). Run "List Rate Limiting Rules" first to get the ruleset ID. Requires an API token with Zone WAF Edit.', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID to add the rate limiting rule to', + }, + rulesetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules"', + }, + expression: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Cloudflare filter expression selecting the requests the rule applies to, e.g. (http.request.uri.path matches "^/api/")', + }, + characteristics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id. Example: cf.colo.id,ip.src', + }, + period: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600', + }, + requestsPerPeriod: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Number of requests allowed within the counting period before the action fires', + }, + action: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Action applied once the limit is exceeded, e.g. block, managed_challenge, js_challenge, challenge, or log. Defaults to block', + }, + mitigationTimeout: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Seconds the action stays applied after the limit is exceeded. Cloudflare accepts only 0, 10, 60, 120, 300, 600, 3600, or 86400', + }, + counting_expression: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Optional expression defining which requests are counted, when it differs from the matching expression', + }, + requestsToOrigin: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'When true, only requests that reach the origin are counted', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Human-readable description of the rule', + }, + enabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the rule is enabled', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/${params.rulesetId.trim()}/rules`, + method: 'POST', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const characteristics = parseCsvParam(params.characteristics) + if (!characteristics) { + throw new Error('Characteristics must list at least one counting characteristic') + } + + const ratelimit: Record = { + characteristics, + period: params.period, + requests_per_period: params.requestsPerPeriod, + } + if (params.mitigationTimeout !== undefined) { + ratelimit.mitigation_timeout = params.mitigationTimeout + } + if (params.counting_expression) ratelimit.counting_expression = params.counting_expression + if (params.requestsToOrigin !== undefined) { + ratelimit.requests_to_origin = params.requestsToOrigin + } + + const body: Record = { + action: params.action || 'block', + expression: params.expression, + ratelimit, + } + if (params.description) body.description = params.description + if (params.enabled !== undefined) body.enabled = params.enabled + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to create rate limiting rule'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Ruleset ID of the http_ratelimit entry point' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind' }, + phase: { type: 'string', description: 'Phase the ruleset runs in (http_ratelimit)' }, + version: { type: 'string', description: 'Ruleset version after the change', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rate limiting rules after the change, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { type: 'string', description: 'Action applied once the limit is exceeded' }, + action_parameters: { + type: 'json', + description: 'Action-specific parameters', + optional: true, + }, + expression: { type: 'string', description: 'Filter expression' }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { type: 'string', description: 'Rule reference tag', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { + type: 'json', + description: 'Rate limiting configuration applied to the rule', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/create_ruleset.ts b/apps/sim/tools/cloudflare/create_ruleset.ts new file mode 100644 index 00000000000..0666c44b499 --- /dev/null +++ b/apps/sim/tools/cloudflare/create_ruleset.ts @@ -0,0 +1,167 @@ +import type { + CloudflareCreateRulesetParams, + CloudflareRulesetResponse, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, + parseJsonArrayParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const createRulesetTool: ToolConfig< + CloudflareCreateRulesetParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_create_ruleset', + name: 'Cloudflare Create Ruleset', + description: + 'Creates a zone ruleset for a phase, optionally seeded with its first rules. Use this when a phase has no entry point ruleset yet — reading the entry point returns 404 on a zone that has never had a rule in that phase, and rules can only be appended to a ruleset that already exists. Create the entry point with kind "zone" and the target phase (for example http_ratelimit for rate limiting rules or http_request_firewall_custom for WAF custom rules), then use the returned ruleset ID for later rule operations. Requires an API token with Zone WAF Edit (or another matching ruleset Edit permission).', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID to create the ruleset in', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Human-readable name for the ruleset', + }, + phase: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ruleset phase, e.g. http_ratelimit, http_request_firewall_custom, http_request_firewall_managed, http_request_transform, http_request_dynamic_redirect', + }, + kind: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Ruleset kind: zone or custom. Use zone to create a phase entry point ruleset and custom for a ruleset an execute rule deploys. Defaults to zone. "root" is the account-level phase entry point and "managed" is Cloudflare-owned, so neither can be created on this zone-scoped endpoint', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the ruleset', + }, + rules: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of rules to seed the ruleset with, in evaluation order. Each rule takes action, expression, and optionally description, enabled, action_parameters, and ratelimit', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets`, + method: 'POST', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const body: Record = { + name: params.name, + kind: params.kind || 'zone', + phase: params.phase, + } + if (params.description) body.description = params.description + + const rules = parseJsonArrayParam(params.rules, 'Rules') + body.rules = rules ?? [] + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to create ruleset'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Ruleset identifier' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind (managed, custom, root, or zone)' }, + phase: { type: 'string', description: 'Phase the ruleset runs in' }, + version: { type: 'string', description: 'Ruleset version', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rules contained in the ruleset, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { + type: 'string', + description: 'Action the rule performs (e.g., block, challenge, log, skip, execute)', + }, + action_parameters: { + type: 'json', + description: + 'Action-specific parameters, including managed-ruleset overrides on execute rules', + optional: true, + }, + expression: { + type: 'string', + description: + 'Filter expression selecting matching requests. Empty on managed-ruleset rules', + }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { + type: 'string', + description: 'Rule reference tag that survives rule updates', + optional: true, + }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { + type: 'json', + description: 'Rate limiting configuration for rules in the http_ratelimit phase', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/create_ruleset_rule.ts b/apps/sim/tools/cloudflare/create_ruleset_rule.ts new file mode 100644 index 00000000000..4ff7bb34156 --- /dev/null +++ b/apps/sim/tools/cloudflare/create_ruleset_rule.ts @@ -0,0 +1,179 @@ +import type { + CloudflareCreateRulesetRuleParams, + CloudflareRulesetResponse, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, + parseJsonObjectParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const createRulesetRuleTool: ToolConfig< + CloudflareCreateRulesetRuleParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_create_ruleset_rule', + name: 'Cloudflare Create Ruleset Rule', + description: + 'Adds a rule to a zone ruleset. Use "Get Phase Entry Point Ruleset" first to find the ruleset ID for the phase you want (for example http_request_firewall_custom for a WAF custom rule, or http_request_firewall_managed with action "execute" to deploy a managed ruleset). The rule is appended to the end of the ruleset unless a position is given. Requires an API token with Zone WAF Edit (or another matching ruleset Write permission).', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID that owns the ruleset', + }, + rulesetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ruleset ID to add the rule to', + }, + action: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The action the rule performs. Valid values depend on the phase — e.g. block, challenge, js_challenge, managed_challenge, log, skip, or execute (to deploy a managed ruleset)', + }, + expression: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Cloudflare filter expression selecting matching requests, e.g. (ip.src.country in {"GB" "FR"}). Use "true" to match every request', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Human-readable description of the rule', + }, + enabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the rule is enabled', + }, + ref: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reference tag that stays stable across rule updates', + }, + actionParameters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of action-specific parameters. For an "execute" rule this carries the managed ruleset id and any overrides, e.g. {"id":"","overrides":{"action":"log"}}', + }, + position: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object placing the rule within the ruleset. Exactly one of {"before":""}, {"after":""}, or {"index":<1-based position>}', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/${params.rulesetId.trim()}/rules`, + method: 'POST', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const body: Record = { + action: params.action, + expression: params.expression, + } + if (params.description) body.description = params.description + if (params.enabled !== undefined) body.enabled = params.enabled + if (params.ref) body.ref = params.ref + + const actionParameters = parseJsonObjectParam(params.actionParameters, 'Action Parameters') + if (actionParameters) body.action_parameters = actionParameters + + const position = parseJsonObjectParam(params.position, 'Position') + if (position) body.position = position + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to create ruleset rule'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Ruleset identifier' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind (managed, custom, root, or zone)' }, + phase: { type: 'string', description: 'Phase the ruleset runs in' }, + version: { type: 'string', description: 'Ruleset version after the change', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rules in the ruleset after the change, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { type: 'string', description: 'Action the rule performs' }, + action_parameters: { + type: 'json', + description: 'Action-specific parameters', + optional: true, + }, + expression: { type: 'string', description: 'Filter expression' }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { type: 'string', description: 'Rule reference tag', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { + type: 'json', + description: 'Rate limiting configuration', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/create_zone.ts b/apps/sim/tools/cloudflare/create_zone.ts index 03fda6fb181..4cb778bd8c0 100644 --- a/apps/sim/tools/cloudflare/create_zone.ts +++ b/apps/sim/tools/cloudflare/create_zone.ts @@ -47,7 +47,7 @@ export const createZoneTool: ToolConfig { - const body: Record = { + const body: Record = { name: params.name, account: { id: params.accountId }, } diff --git a/apps/sim/tools/cloudflare/delete_access_application.ts b/apps/sim/tools/cloudflare/delete_access_application.ts new file mode 100644 index 00000000000..00c03ab1258 --- /dev/null +++ b/apps/sim/tools/cloudflare/delete_access_application.ts @@ -0,0 +1,63 @@ +import type { + CloudflareDeleteAccessApplicationParams, + CloudflareDeletedIdResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteAccessApplicationTool: ToolConfig< + CloudflareDeleteAccessApplicationParams, + CloudflareDeletedIdResponse +> = { + id: 'cloudflare_delete_access_application', + name: 'Cloudflare Delete Access Application', + description: + 'Permanently deletes a Cloudflare Access (Zero Trust) application and every policy attached to it. The hostname it protected is immediately left without an Access identity check, so anyone who can reach it can reach the origin. This cannot be undone. Requires an API token with Account Access: Apps and Policies Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access application ID to delete permanently', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps/${params.appId.trim()}`, + method: 'DELETE', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { id: '' }, + error: cloudflareErrorMessage(data, 'Failed to delete Access application'), + } + } + + return { success: true, output: { id: data.result?.id ?? '' } } + }, + + outputs: { + id: { type: 'string', description: 'Identifier of the deleted Access application' }, + }, +} diff --git a/apps/sim/tools/cloudflare/delete_access_policy.ts b/apps/sim/tools/cloudflare/delete_access_policy.ts new file mode 100644 index 00000000000..f13889c6a3b --- /dev/null +++ b/apps/sim/tools/cloudflare/delete_access_policy.ts @@ -0,0 +1,69 @@ +import type { + CloudflareDeleteAccessPolicyParams, + CloudflareDeletedIdResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteAccessPolicyTool: ToolConfig< + CloudflareDeleteAccessPolicyParams, + CloudflareDeletedIdResponse +> = { + id: 'cloudflare_delete_access_policy', + name: 'Cloudflare Delete Access Policy', + description: + 'Permanently deletes a Cloudflare Access (Zero Trust) policy from an application. This changes who can reach the application the moment it runs: removing an allow policy locks out everyone it covered, and removing a deny or require policy drops that restriction. This cannot be undone. Requires an API token with Account Access: Apps and Policies Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access application ID that owns the policy', + }, + policyId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access policy ID to delete permanently', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps/${params.appId.trim()}/policies/${params.policyId.trim()}`, + method: 'DELETE', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { id: '' }, + error: cloudflareErrorMessage(data, 'Failed to delete Access policy'), + } + } + + return { success: true, output: { id: data.result?.id ?? '' } } + }, + + outputs: { + id: { type: 'string', description: 'Identifier of the deleted Access policy' }, + }, +} diff --git a/apps/sim/tools/cloudflare/delete_dns_record.ts b/apps/sim/tools/cloudflare/delete_dns_record.ts index 4e6bced66f6..d333255f682 100644 --- a/apps/sim/tools/cloudflare/delete_dns_record.ts +++ b/apps/sim/tools/cloudflare/delete_dns_record.ts @@ -36,7 +36,7 @@ export const deleteDnsRecordTool: ToolConfig< request: { url: (params) => - `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/dns_records/${params.recordId}`, + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/dns_records/${params.recordId.trim()}`, method: 'DELETE', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, @@ -47,7 +47,16 @@ export const deleteDnsRecordTool: ToolConfig< transformResponse: async (response: Response) => { const data = await response.json() - if (!data.success) { + /** + * This endpoint is the one Cloudflare v4 response that does NOT carry the + * shared envelope: its documented body is `{ "result": { "id": ... } }` with + * no `success`, `errors`, or `messages`. Branching on `!data.success` would + * therefore report every successful delete as a failure, so the check must + * be an explicit `=== false` — which still fails a real `success: false` + * body if Cloudflare ever starts sending the full envelope here. + * https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/ + */ + if (data.success === false) { return { success: false, output: { id: '' }, diff --git a/apps/sim/tools/cloudflare/delete_r2_bucket.ts b/apps/sim/tools/cloudflare/delete_r2_bucket.ts new file mode 100644 index 00000000000..a239b0f1a35 --- /dev/null +++ b/apps/sim/tools/cloudflare/delete_r2_bucket.ts @@ -0,0 +1,77 @@ +import type { + CloudflareDeleteR2BucketParams, + CloudflareDeleteR2BucketResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteR2BucketTool: ToolConfig< + CloudflareDeleteR2BucketParams, + CloudflareDeleteR2BucketResponse +> = { + id: 'cloudflare_delete_r2_bucket', + name: 'Cloudflare Delete R2 Bucket', + description: + 'Permanently deletes an R2 object storage bucket. Cloudflare only deletes an empty bucket, and the deletion cannot be undone. Requires an API token with Account Workers R2 Storage Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. R2 buckets are account-scoped', + }, + bucketName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the bucket to delete permanently', + }, + jurisdiction: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data-residency jurisdiction the bucket lives in: default, eu, or fedramp', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/r2/buckets/${encodeURIComponent(params.bucketName)}`, + method: 'DELETE', + headers: (params) => { + const headers = cloudflareHeaders(params.apiKey) + if (params.jurisdiction) headers['cf-r2-jurisdiction'] = params.jurisdiction + return headers + }, + }, + + transformResponse: async (response: Response, params) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { name: '' }, + error: cloudflareErrorMessage(data, 'Failed to delete R2 bucket'), + } + } + + return { success: true, output: { name: params?.bucketName ?? '' } } + }, + + outputs: { + name: { + type: 'string', + description: + 'Name of the deleted bucket. Cloudflare returns an empty result body for this endpoint, so the name is echoed from the request', + }, + }, +} diff --git a/apps/sim/tools/cloudflare/delete_ruleset_rule.ts b/apps/sim/tools/cloudflare/delete_ruleset_rule.ts new file mode 100644 index 00000000000..68ec2c45f9b --- /dev/null +++ b/apps/sim/tools/cloudflare/delete_ruleset_rule.ts @@ -0,0 +1,117 @@ +import type { + CloudflareDeleteRulesetRuleParams, + CloudflareRulesetResponse, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteRulesetRuleTool: ToolConfig< + CloudflareDeleteRulesetRuleParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_delete_ruleset_rule', + name: 'Cloudflare Delete Ruleset Rule', + description: + 'Permanently deletes a rule from a zone ruleset. This takes effect immediately on live traffic and cannot be undone — deleting a WAF custom rule, a managed-ruleset deployment, or a rate limiting rule removes that protection from the zone. Also use this to delete rate limiting rules, which live in the http_ratelimit phase ruleset. Requires an API token with Zone WAF Edit (or another matching ruleset Write permission).', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID that owns the ruleset', + }, + rulesetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ruleset ID containing the rule', + }, + ruleId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The rule ID to delete permanently', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/${params.rulesetId.trim()}/rules/${params.ruleId.trim()}`, + method: 'DELETE', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to delete ruleset rule'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Ruleset identifier' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind (managed, custom, root, or zone)' }, + phase: { type: 'string', description: 'Phase the ruleset runs in' }, + version: { type: 'string', description: 'Ruleset version after the change', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rules remaining in the ruleset, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { type: 'string', description: 'Action the rule performs' }, + action_parameters: { + type: 'json', + description: 'Action-specific parameters', + optional: true, + }, + expression: { type: 'string', description: 'Filter expression' }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { type: 'string', description: 'Rule reference tag', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { type: 'json', description: 'Rate limiting configuration', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/delete_zone.ts b/apps/sim/tools/cloudflare/delete_zone.ts index 7cf15229967..06ddf1d42cb 100644 --- a/apps/sim/tools/cloudflare/delete_zone.ts +++ b/apps/sim/tools/cloudflare/delete_zone.ts @@ -27,7 +27,7 @@ export const deleteZoneTool: ToolConfig `https://api.cloudflare.com/client/v4/zones/${params.zoneId}`, + url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}`, method: 'DELETE', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/cloudflare/dns_analytics.ts b/apps/sim/tools/cloudflare/dns_analytics.ts index 93525b6562f..03eae69f8e3 100644 --- a/apps/sim/tools/cloudflare/dns_analytics.ts +++ b/apps/sim/tools/cloudflare/dns_analytics.ts @@ -1,7 +1,9 @@ import type { CloudflareDnsAnalyticsParams, CloudflareDnsAnalyticsResponse, + CloudflareRawDnsAnalyticsReport, } from '@/tools/cloudflare/types' +import { readCloudflareResponse } from '@/tools/cloudflare/utils' import type { ToolConfig } from '@/tools/types' export const dnsAnalyticsTool: ToolConfig< @@ -36,10 +38,10 @@ export const dnsAnalyticsTool: ToolConfig< }, metrics: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', description: - 'Comma-separated metrics to retrieve (e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th")', + 'Comma-separated metrics to retrieve (e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"). Optional — Cloudflare returns its default metric set when it is omitted', }, dimensions: { type: 'string', @@ -78,7 +80,7 @@ export const dnsAnalyticsTool: ToolConfig< request: { url: (params) => { const url = new URL( - `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/dns_analytics/report` + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/dns_analytics/report` ) if (params.since) url.searchParams.append('since', params.since) if (params.until) url.searchParams.append('until', params.until) @@ -97,7 +99,7 @@ export const dnsAnalyticsTool: ToolConfig< }, transformResponse: async (response: Response) => { - const data = await response.json() + const data = await readCloudflareResponse(response) if (!data.success) { return { @@ -179,7 +181,7 @@ export const dnsAnalyticsTool: ToolConfig< responseTime99th: result?.max?.responseTime99th ?? 0, }, data: - result?.data?.map((entry: any) => ({ + result?.data?.map((entry) => ({ dimensions: entry.dimensions ?? [], metrics: entry.metrics ?? [], })) ?? [], diff --git a/apps/sim/tools/cloudflare/get_access_application.ts b/apps/sim/tools/cloudflare/get_access_application.ts new file mode 100644 index 00000000000..71f1989c713 --- /dev/null +++ b/apps/sim/tools/cloudflare/get_access_application.ts @@ -0,0 +1,135 @@ +import type { + CloudflareAccessApplicationResponse, + CloudflareGetAccessApplicationParams, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyAccessApplication, + mapAccessApplication, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const getAccessApplicationTool: ToolConfig< + CloudflareGetAccessApplicationParams, + CloudflareAccessApplicationResponse +> = { + id: 'cloudflare_get_access_application', + name: 'Cloudflare Get Access Application', + description: + 'Reads a single Cloudflare Access (Zero Trust) application, including its attached policies. Requires an API token with Account Access: Apps and Policies Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access application ID (or audience tag) to read', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps/${params.appId.trim()}`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyAccessApplication(), + error: cloudflareErrorMessage(data, 'Failed to get Access application'), + } + } + + return { success: true, output: mapAccessApplication(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Access application identifier' }, + name: { type: 'string', description: 'Application name', optional: true }, + domain: { + type: 'string', + description: 'Primary hostname and path secured by Access', + optional: true, + }, + type: { + type: 'string', + description: 'Application type (e.g., self_hosted, saas, ssh, app_launcher, bookmark)', + optional: true, + }, + aud: { type: 'string', description: 'Audience tag used to verify Access JWTs', optional: true }, + session_duration: { + type: 'string', + description: 'How long an Access session stays valid (e.g., 24h)', + optional: true, + }, + allowed_idps: { + type: 'array', + description: 'Identity provider IDs users may authenticate with', + items: { type: 'string', description: 'Identity provider ID' }, + optional: true, + }, + app_launcher_visible: { + type: 'boolean', + description: 'Whether the app appears in the App Launcher', + optional: true, + }, + auto_redirect_to_identity: { + type: 'boolean', + description: 'Whether users skip the identity provider picker', + optional: true, + }, + custom_deny_message: { + type: 'string', + description: 'Message shown when access is denied', + optional: true, + }, + custom_deny_url: { + type: 'string', + description: 'URL users are redirected to when access is denied', + optional: true, + }, + logo_url: { type: 'string', description: 'Logo image URL', optional: true }, + self_hosted_domains: { + type: 'array', + description: + 'Additional hostnames and paths secured by the application. Cloudflare deprecated this field in favour of destinations, which is the one to read on a current application', + items: { type: 'string', description: 'Hostname and path' }, + optional: true, + }, + destinations: { + type: 'json', + description: 'Public and private destinations secured by the application', + optional: true, + }, + tags: { + type: 'array', + description: 'Tags categorizing the application', + items: { type: 'string', description: 'Tag name' }, + optional: true, + }, + policies: { + type: 'json', + description: 'Access policies attached to the application', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/get_r2_bucket.ts b/apps/sim/tools/cloudflare/get_r2_bucket.ts new file mode 100644 index 00000000000..f144a34f608 --- /dev/null +++ b/apps/sim/tools/cloudflare/get_r2_bucket.ts @@ -0,0 +1,104 @@ +import type { + CloudflareGetR2BucketParams, + CloudflareR2BucketResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const getR2BucketTool: ToolConfig = + { + id: 'cloudflare_get_r2_bucket', + name: 'Cloudflare Get R2 Bucket', + description: + 'Reads the metadata of a single R2 object storage bucket. Requires an API token with Account Workers R2 Storage Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. R2 buckets are account-scoped', + }, + bucketName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the bucket to read', + }, + jurisdiction: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data-residency jurisdiction the bucket lives in: default, eu, or fedramp', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/r2/buckets/${encodeURIComponent(params.bucketName)}`, + method: 'GET', + headers: (params) => { + const headers = cloudflareHeaders(params.apiKey) + if (params.jurisdiction) headers['cf-r2-jurisdiction'] = params.jurisdiction + return headers + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + name: '', + creation_date: null, + location: null, + storage_class: null, + jurisdiction: null, + }, + error: cloudflareErrorMessage(data, 'Failed to get R2 bucket'), + } + } + + const bucket = data.result + return { + success: true, + output: { + name: bucket?.name ?? '', + creation_date: bucket?.creation_date ?? null, + location: bucket?.location ?? null, + storage_class: bucket?.storage_class ?? null, + jurisdiction: bucket?.jurisdiction ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Bucket name' }, + creation_date: { type: 'string', description: 'Creation timestamp', optional: true }, + location: { + type: 'string', + description: + 'Location hint the bucket was created with (apac, eeur, enam, weur, wnam, or oc)', + optional: true, + }, + storage_class: { + type: 'string', + description: 'Default storage class (Standard or InfrequentAccess)', + optional: true, + }, + jurisdiction: { + type: 'string', + description: 'Data-residency jurisdiction (default, eu, or fedramp)', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/cloudflare/get_ruleset.ts b/apps/sim/tools/cloudflare/get_ruleset.ts new file mode 100644 index 00000000000..badc225f26e --- /dev/null +++ b/apps/sim/tools/cloudflare/get_ruleset.ts @@ -0,0 +1,124 @@ +import type { + CloudflareGetRulesetParams, + CloudflareRulesetResponse, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const getRulesetTool: ToolConfig = { + id: 'cloudflare_get_ruleset', + name: 'Cloudflare Get Ruleset', + description: + 'Reads a single zone ruleset including every rule it contains, in evaluation order. Requires an API token with Zone WAF Read (or another matching ruleset Read permission).', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID that owns the ruleset', + }, + rulesetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ruleset ID to read', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/${params.rulesetId.trim()}`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to get ruleset'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Ruleset identifier' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind (managed, custom, root, or zone)' }, + phase: { type: 'string', description: 'Phase the ruleset runs in' }, + version: { type: 'string', description: 'Ruleset version', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rules contained in the ruleset, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { + type: 'string', + description: 'Action the rule performs (e.g., block, challenge, log, skip, execute)', + }, + action_parameters: { + type: 'json', + description: + 'Action-specific parameters, including managed-ruleset overrides on execute rules', + optional: true, + }, + expression: { + type: 'string', + description: + 'Filter expression selecting matching requests. Empty on managed-ruleset rules', + }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { + type: 'string', + description: 'Rule reference tag that survives rule updates', + optional: true, + }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { + type: 'json', + description: 'Rate limiting configuration for rules in the http_ratelimit phase', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/get_ruleset_entrypoint.ts b/apps/sim/tools/cloudflare/get_ruleset_entrypoint.ts new file mode 100644 index 00000000000..87e460390a3 --- /dev/null +++ b/apps/sim/tools/cloudflare/get_ruleset_entrypoint.ts @@ -0,0 +1,128 @@ +import type { + CloudflareGetRulesetEntrypointParams, + CloudflareRulesetResponse, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const getRulesetEntrypointTool: ToolConfig< + CloudflareGetRulesetEntrypointParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_get_ruleset_entrypoint', + name: 'Cloudflare Get Phase Entry Point Ruleset', + description: + 'Reads the entry point ruleset for a phase on a zone, including all of its rules. This is how you find the ruleset ID you need before adding, updating, or deleting a rule — for example http_request_firewall_custom for WAF custom rules, http_request_firewall_managed for managed-ruleset deployments and overrides, or http_ratelimit for rate limiting rules. Requires an API token with Zone WAF Read (or another matching ruleset Read permission).', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID to read the phase entry point for', + }, + phase: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ruleset phase, e.g. http_request_firewall_custom, http_request_firewall_managed, http_ratelimit, http_request_transform, http_request_dynamic_redirect', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/phases/${params.phase.trim()}/entrypoint`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to get phase entry point ruleset'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Entry point ruleset identifier' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind (managed, custom, root, or zone)' }, + phase: { type: 'string', description: 'Phase the ruleset runs in' }, + version: { type: 'string', description: 'Ruleset version', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rules contained in the ruleset, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { + type: 'string', + description: 'Action the rule performs (e.g., block, challenge, log, skip, execute)', + }, + action_parameters: { + type: 'json', + description: + 'Action-specific parameters, including managed-ruleset overrides on execute rules', + optional: true, + }, + expression: { + type: 'string', + description: + 'Filter expression selecting matching requests. Empty on managed-ruleset rules', + }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { + type: 'string', + description: 'Rule reference tag that survives rule updates', + optional: true, + }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { + type: 'json', + description: 'Rate limiting configuration for rules in the http_ratelimit phase', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/get_tunnel.ts b/apps/sim/tools/cloudflare/get_tunnel.ts new file mode 100644 index 00000000000..cf0faf957f0 --- /dev/null +++ b/apps/sim/tools/cloudflare/get_tunnel.ts @@ -0,0 +1,133 @@ +import type { CloudflareGetTunnelParams, CloudflareTunnelResponse } from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const getTunnelTool: ToolConfig = { + id: 'cloudflare_get_tunnel', + name: 'Cloudflare Get Tunnel', + description: + 'Reads a single Cloudflare Tunnel (cloudflared), including its health status and active connector connections. Requires an API token with Account Cloudflare Tunnel Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Tunnels are account-scoped', + }, + tunnelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The tunnel ID to read', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/cfd_tunnel/${params.tunnelId.trim()}`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + id: '', + name: null, + account_tag: null, + config_src: null, + status: null, + tun_type: null, + remote_config: null, + metadata: null, + created_at: null, + deleted_at: null, + conns_active_at: null, + conns_inactive_at: null, + connections: null, + }, + error: cloudflareErrorMessage(data, 'Failed to get tunnel'), + } + } + + const tunnel = data.result + return { + success: true, + output: { + id: tunnel?.id ?? '', + name: tunnel?.name ?? null, + account_tag: tunnel?.account_tag ?? null, + config_src: tunnel?.config_src ?? null, + status: tunnel?.status ?? null, + tun_type: tunnel?.tun_type ?? null, + remote_config: tunnel?.remote_config ?? null, + metadata: tunnel?.metadata ?? null, + created_at: tunnel?.created_at ?? null, + deleted_at: tunnel?.deleted_at ?? null, + conns_active_at: tunnel?.conns_active_at ?? null, + conns_inactive_at: tunnel?.conns_inactive_at ?? null, + connections: tunnel?.connections ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Tunnel identifier' }, + name: { type: 'string', description: 'Tunnel name', optional: true }, + account_tag: { type: 'string', description: 'Account the tunnel belongs to', optional: true }, + config_src: { + type: 'string', + description: 'Where the tunnel configuration lives: local or cloudflare', + optional: true, + }, + status: { + type: 'string', + description: 'Tunnel health: inactive, degraded, healthy, or down', + optional: true, + }, + tun_type: { + type: 'string', + description: 'Tunnel type, e.g. cfd_tunnel, warp_connector, or warp', + optional: true, + }, + remote_config: { + type: 'boolean', + description: 'Whether the tunnel is remotely managed', + optional: true, + }, + metadata: { + type: 'json', + description: 'Metadata associated with the tunnel', + optional: true, + }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + deleted_at: { type: 'string', description: 'Deletion timestamp', optional: true }, + conns_active_at: { + type: 'string', + description: 'When the tunnel last had active connections', + optional: true, + }, + conns_inactive_at: { + type: 'string', + description: 'When the tunnel last lost all connections', + optional: true, + }, + connections: { + type: 'json', + description: 'Active connector connections for the tunnel', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/get_tunnel_configuration.ts b/apps/sim/tools/cloudflare/get_tunnel_configuration.ts new file mode 100644 index 00000000000..8b76c3590a8 --- /dev/null +++ b/apps/sim/tools/cloudflare/get_tunnel_configuration.ts @@ -0,0 +1,99 @@ +import type { + CloudflareGetTunnelConfigurationParams, + CloudflareGetTunnelConfigurationResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const getTunnelConfigurationTool: ToolConfig< + CloudflareGetTunnelConfigurationParams, + CloudflareGetTunnelConfigurationResponse +> = { + id: 'cloudflare_get_tunnel_configuration', + name: 'Cloudflare Get Tunnel Configuration', + description: + 'Reads the configuration of a remotely-managed Cloudflare Tunnel — its ingress rules, origin request settings, and WARP routing. Only tunnels whose configuration source is "cloudflare" have a remote configuration; locally-managed tunnels keep it in their own config file. Requires an API token with Account Cloudflare Tunnel Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Tunnels are account-scoped', + }, + tunnelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The tunnel ID to read the configuration for', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/cfd_tunnel/${params.tunnelId.trim()}/configurations`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + tunnel_id: '', + account_id: '', + version: null, + source: null, + created_at: null, + config: null, + }, + error: cloudflareErrorMessage(data, 'Failed to get tunnel configuration'), + } + } + + const result = data.result + return { + success: true, + output: { + tunnel_id: result?.tunnel_id ?? '', + account_id: result?.account_id ?? '', + version: result?.version ?? null, + source: result?.source ?? null, + created_at: result?.created_at ?? null, + config: result?.config ?? null, + }, + } + }, + + outputs: { + tunnel_id: { type: 'string', description: 'Tunnel the configuration belongs to' }, + account_id: { type: 'string', description: 'Account the tunnel belongs to' }, + version: { + type: 'number', + description: 'Configuration version, incremented on every change', + optional: true, + }, + source: { + type: 'string', + description: 'Where the configuration is managed: local or cloudflare', + optional: true, + }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + config: { + type: 'json', + description: + 'Tunnel configuration with ingress rules, originRequest defaults, and warp-routing settings', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/get_worker_script_settings.ts b/apps/sim/tools/cloudflare/get_worker_script_settings.ts new file mode 100644 index 00000000000..dcc787391cf --- /dev/null +++ b/apps/sim/tools/cloudflare/get_worker_script_settings.ts @@ -0,0 +1,131 @@ +import type { + CloudflareGetWorkerScriptSettingsParams, + CloudflareGetWorkerScriptSettingsResponse, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const getWorkerScriptSettingsTool: ToolConfig< + CloudflareGetWorkerScriptSettingsParams, + CloudflareGetWorkerScriptSettingsResponse +> = { + id: 'cloudflare_get_worker_script_settings', + name: 'Cloudflare Get Worker Script Settings', + description: + 'Reads the deployment settings of a single Workers script — bindings, compatibility date and flags, limits, observability, placement, and tail consumers. The plain "get script" endpoint in the Cloudflare API returns raw JavaScript source rather than JSON, so this settings endpoint is the structured way to inspect one script. Requires an API token with Account Workers Scripts Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Workers scripts are account-scoped', + }, + scriptName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the Workers script to read settings for', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/workers/scripts/${encodeURIComponent(params.scriptName)}/settings`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + bindings: null, + compatibility_date: null, + compatibility_flags: null, + limits: null, + logpush: null, + migrations: null, + observability: null, + placement: null, + tags: null, + tail_consumers: null, + usage_model: null, + }, + error: cloudflareErrorMessage(data, 'Failed to get Worker script settings'), + } + } + + const settings = data.result + return { + success: true, + output: { + bindings: settings?.bindings ?? null, + compatibility_date: settings?.compatibility_date ?? null, + compatibility_flags: settings?.compatibility_flags ?? null, + limits: settings?.limits ?? null, + logpush: settings?.logpush ?? null, + migrations: settings?.migrations ?? null, + observability: settings?.observability ?? null, + placement: settings?.placement ?? null, + tags: settings?.tags ?? null, + tail_consumers: settings?.tail_consumers ?? null, + usage_model: settings?.usage_model ?? null, + }, + } + }, + + outputs: { + bindings: { + type: 'json', + description: 'Resource bindings available to the script (KV, R2, D1, secrets, and more)', + optional: true, + }, + compatibility_date: { + type: 'string', + description: 'Workers runtime compatibility date', + optional: true, + }, + compatibility_flags: { + type: 'array', + description: 'Workers runtime compatibility flags', + items: { type: 'string', description: 'Compatibility flag' }, + optional: true, + }, + limits: { type: 'json', description: 'CPU and other execution limits', optional: true }, + logpush: { type: 'boolean', description: 'Whether Workers Logpush is enabled', optional: true }, + migrations: { type: 'json', description: 'Durable Object migrations', optional: true }, + observability: { + type: 'json', + description: 'Observability and log-sampling configuration', + optional: true, + }, + placement: { type: 'json', description: 'Smart placement configuration', optional: true }, + tags: { + type: 'array', + description: 'Tags attached to the script', + items: { type: 'string', description: 'Tag name' }, + optional: true, + }, + tail_consumers: { + type: 'json', + description: "Workers that consume this script's tail events", + optional: true, + }, + usage_model: { + type: 'string', + description: 'Billing usage model', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/get_zone.ts b/apps/sim/tools/cloudflare/get_zone.ts index 2e926a0f6f5..398c05f6a69 100644 --- a/apps/sim/tools/cloudflare/get_zone.ts +++ b/apps/sim/tools/cloudflare/get_zone.ts @@ -23,7 +23,7 @@ export const getZoneTool: ToolConfig `https://api.cloudflare.com/client/v4/zones/${params.zoneId}`, + url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/cloudflare/get_zone_settings.ts b/apps/sim/tools/cloudflare/get_zone_settings.ts index ac37f47ed6b..cb730d51cc5 100644 --- a/apps/sim/tools/cloudflare/get_zone_settings.ts +++ b/apps/sim/tools/cloudflare/get_zone_settings.ts @@ -30,7 +30,7 @@ export const getZoneSettingsTool: ToolConfig< }, request: { - url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/settings`, + url: (params) => `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/settings`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, @@ -83,8 +83,7 @@ export const getZoneSettingsTool: ToolConfig< }, value: { type: 'string', - description: - 'Setting value as a string. Simple values returned as-is (e.g., "full", "on"). Complex values are JSON-stringified (e.g., \'{"css":"on","html":"on","js":"on"}\').', + description: `Setting value as a string. Simple values returned as-is (e.g., "full", "on"). Complex values are JSON-stringified (e.g., {"css":"on","html":"on","js":"on"}).`, }, editable: { type: 'boolean', diff --git a/apps/sim/tools/cloudflare/index.ts b/apps/sim/tools/cloudflare/index.ts index e3f21d34c9b..d19e5f28ba6 100644 --- a/apps/sim/tools/cloudflare/index.ts +++ b/apps/sim/tools/cloudflare/index.ts @@ -1,27 +1,97 @@ +import { createAccessApplicationTool } from '@/tools/cloudflare/create_access_application' +import { createAccessPolicyTool } from '@/tools/cloudflare/create_access_policy' +import { createAccessServiceTokenTool } from '@/tools/cloudflare/create_access_service_token' import { createDnsRecordTool } from '@/tools/cloudflare/create_dns_record' +import { createR2BucketTool } from '@/tools/cloudflare/create_r2_bucket' +import { createRateLimitRuleTool } from '@/tools/cloudflare/create_rate_limit_rule' +import { createRulesetTool } from '@/tools/cloudflare/create_ruleset' +import { createRulesetRuleTool } from '@/tools/cloudflare/create_ruleset_rule' import { createZoneTool } from '@/tools/cloudflare/create_zone' +import { deleteAccessApplicationTool } from '@/tools/cloudflare/delete_access_application' +import { deleteAccessPolicyTool } from '@/tools/cloudflare/delete_access_policy' import { deleteDnsRecordTool } from '@/tools/cloudflare/delete_dns_record' +import { deleteR2BucketTool } from '@/tools/cloudflare/delete_r2_bucket' +import { deleteRulesetRuleTool } from '@/tools/cloudflare/delete_ruleset_rule' import { deleteZoneTool } from '@/tools/cloudflare/delete_zone' import { dnsAnalyticsTool } from '@/tools/cloudflare/dns_analytics' +import { getAccessApplicationTool } from '@/tools/cloudflare/get_access_application' +import { getR2BucketTool } from '@/tools/cloudflare/get_r2_bucket' +import { getRulesetTool } from '@/tools/cloudflare/get_ruleset' +import { getRulesetEntrypointTool } from '@/tools/cloudflare/get_ruleset_entrypoint' +import { getTunnelTool } from '@/tools/cloudflare/get_tunnel' +import { getTunnelConfigurationTool } from '@/tools/cloudflare/get_tunnel_configuration' +import { getWorkerScriptSettingsTool } from '@/tools/cloudflare/get_worker_script_settings' import { getZoneTool } from '@/tools/cloudflare/get_zone' import { getZoneSettingsTool } from '@/tools/cloudflare/get_zone_settings' +import { listAccessApplicationsTool } from '@/tools/cloudflare/list_access_applications' +import { listAccessGroupsTool } from '@/tools/cloudflare/list_access_groups' +import { listAccessIdentityProvidersTool } from '@/tools/cloudflare/list_access_identity_providers' +import { listAccessPoliciesTool } from '@/tools/cloudflare/list_access_policies' +import { listAccessServiceTokensTool } from '@/tools/cloudflare/list_access_service_tokens' import { listCertificatesTool } from '@/tools/cloudflare/list_certificates' import { listDnsRecordsTool } from '@/tools/cloudflare/list_dns_records' +import { listManagedRulesetOverridesTool } from '@/tools/cloudflare/list_managed_ruleset_overrides' +import { listR2BucketsTool } from '@/tools/cloudflare/list_r2_buckets' +import { listRateLimitRulesTool } from '@/tools/cloudflare/list_rate_limit_rules' +import { listRulesetsTool } from '@/tools/cloudflare/list_rulesets' +import { listTunnelsTool } from '@/tools/cloudflare/list_tunnels' +import { listWorkerRoutesTool } from '@/tools/cloudflare/list_worker_routes' +import { listWorkerScriptsTool } from '@/tools/cloudflare/list_worker_scripts' import { listZonesTool } from '@/tools/cloudflare/list_zones' import { purgeCacheTool } from '@/tools/cloudflare/purge_cache' +import { revokeAccessServiceTokenTool } from '@/tools/cloudflare/revoke_access_service_token' +import { updateAccessApplicationTool } from '@/tools/cloudflare/update_access_application' +import { updateAccessPolicyTool } from '@/tools/cloudflare/update_access_policy' import { updateDnsRecordTool } from '@/tools/cloudflare/update_dns_record' +import { updateRateLimitRuleTool } from '@/tools/cloudflare/update_rate_limit_rule' +import { updateRulesetRuleTool } from '@/tools/cloudflare/update_ruleset_rule' import { updateZoneSettingTool } from '@/tools/cloudflare/update_zone_setting' +export const cloudflareCreateAccessApplicationTool = createAccessApplicationTool +export const cloudflareCreateAccessPolicyTool = createAccessPolicyTool +export const cloudflareCreateAccessServiceTokenTool = createAccessServiceTokenTool export const cloudflareCreateDnsRecordTool = createDnsRecordTool +export const cloudflareCreateR2BucketTool = createR2BucketTool +export const cloudflareCreateRateLimitRuleTool = createRateLimitRuleTool +export const cloudflareCreateRulesetTool = createRulesetTool +export const cloudflareCreateRulesetRuleTool = createRulesetRuleTool export const cloudflareCreateZoneTool = createZoneTool +export const cloudflareDeleteAccessApplicationTool = deleteAccessApplicationTool +export const cloudflareDeleteAccessPolicyTool = deleteAccessPolicyTool export const cloudflareDeleteDnsRecordTool = deleteDnsRecordTool +export const cloudflareDeleteR2BucketTool = deleteR2BucketTool +export const cloudflareDeleteRulesetRuleTool = deleteRulesetRuleTool export const cloudflareDeleteZoneTool = deleteZoneTool export const cloudflareDnsAnalyticsTool = dnsAnalyticsTool +export const cloudflareGetAccessApplicationTool = getAccessApplicationTool +export const cloudflareGetR2BucketTool = getR2BucketTool +export const cloudflareGetRulesetTool = getRulesetTool +export const cloudflareGetRulesetEntrypointTool = getRulesetEntrypointTool +export const cloudflareGetTunnelTool = getTunnelTool +export const cloudflareGetTunnelConfigurationTool = getTunnelConfigurationTool +export const cloudflareGetWorkerScriptSettingsTool = getWorkerScriptSettingsTool export const cloudflareGetZoneTool = getZoneTool export const cloudflareGetZoneSettingsTool = getZoneSettingsTool +export const cloudflareListAccessApplicationsTool = listAccessApplicationsTool +export const cloudflareListAccessGroupsTool = listAccessGroupsTool +export const cloudflareListAccessIdentityProvidersTool = listAccessIdentityProvidersTool +export const cloudflareListAccessPoliciesTool = listAccessPoliciesTool +export const cloudflareListAccessServiceTokensTool = listAccessServiceTokensTool export const cloudflareListCertificatesTool = listCertificatesTool export const cloudflareListDnsRecordsTool = listDnsRecordsTool +export const cloudflareListManagedRulesetOverridesTool = listManagedRulesetOverridesTool +export const cloudflareListR2BucketsTool = listR2BucketsTool +export const cloudflareListRateLimitRulesTool = listRateLimitRulesTool +export const cloudflareListRulesetsTool = listRulesetsTool +export const cloudflareListTunnelsTool = listTunnelsTool +export const cloudflareListWorkerRoutesTool = listWorkerRoutesTool +export const cloudflareListWorkerScriptsTool = listWorkerScriptsTool export const cloudflareListZonesTool = listZonesTool export const cloudflarePurgeCacheTool = purgeCacheTool +export const cloudflareRevokeAccessServiceTokenTool = revokeAccessServiceTokenTool +export const cloudflareUpdateAccessApplicationTool = updateAccessApplicationTool +export const cloudflareUpdateAccessPolicyTool = updateAccessPolicyTool export const cloudflareUpdateDnsRecordTool = updateDnsRecordTool +export const cloudflareUpdateRateLimitRuleTool = updateRateLimitRuleTool +export const cloudflareUpdateRulesetRuleTool = updateRulesetRuleTool export const cloudflareUpdateZoneSettingTool = updateZoneSettingTool diff --git a/apps/sim/tools/cloudflare/list_access_applications.ts b/apps/sim/tools/cloudflare/list_access_applications.ts new file mode 100644 index 00000000000..97938fcd247 --- /dev/null +++ b/apps/sim/tools/cloudflare/list_access_applications.ts @@ -0,0 +1,204 @@ +import type { + CloudflareListAccessApplicationsParams, + CloudflareListAccessApplicationsResponse, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + mapAccessApplication, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listAccessApplicationsTool: ToolConfig< + CloudflareListAccessApplicationsParams, + CloudflareListAccessApplicationsResponse +> = { + id: 'cloudflare_list_access_applications', + name: 'Cloudflare List Access Applications', + description: + 'Lists the Cloudflare Access (Zero Trust) applications protecting an account. Requires an API token with Account Access: Apps and Policies Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by application name', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by the primary hostname the application secures', + }, + aud: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by application audience (AUD) tag', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across applications', + }, + exact: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the name and domain filters must match exactly', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + per_page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of applications per page', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps` + ) + appendParam(url, 'name', params.name) + appendParam(url, 'domain', params.domain) + appendParam(url, 'aud', params.aud) + appendParam(url, 'search', params.search) + if (params.exact !== undefined) url.searchParams.append('exact', String(params.exact)) + appendParam(url, 'page', params.page) + appendParam(url, 'per_page', params.per_page) + return url.toString() + }, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { applications: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list Access applications'), + } + } + + const applications = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + applications: applications.map(mapAccessApplication), + total_count: data.result_info?.total_count ?? applications.length, + }, + } + }, + + outputs: { + applications: { + type: 'array', + description: 'Access applications in the account', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Access application identifier' }, + name: { type: 'string', description: 'Application name', optional: true }, + domain: { + type: 'string', + description: 'Primary hostname and path secured by Access', + optional: true, + }, + type: { + type: 'string', + description: 'Application type (e.g., self_hosted, saas, ssh, app_launcher, bookmark)', + optional: true, + }, + aud: { + type: 'string', + description: 'Audience tag used to verify Access JWTs', + optional: true, + }, + session_duration: { + type: 'string', + description: 'How long an Access session stays valid (e.g., 24h)', + optional: true, + }, + allowed_idps: { + type: 'array', + description: 'Identity provider IDs users may authenticate with', + items: { type: 'string', description: 'Identity provider ID' }, + optional: true, + }, + app_launcher_visible: { + type: 'boolean', + description: 'Whether the app appears in the App Launcher', + optional: true, + }, + auto_redirect_to_identity: { + type: 'boolean', + description: 'Whether users skip the identity provider picker', + optional: true, + }, + custom_deny_message: { + type: 'string', + description: 'Message shown when access is denied', + optional: true, + }, + custom_deny_url: { + type: 'string', + description: 'URL users are redirected to when access is denied', + optional: true, + }, + logo_url: { type: 'string', description: 'Logo image URL', optional: true }, + self_hosted_domains: { + type: 'array', + description: + 'Additional hostnames and paths secured by the application. Cloudflare deprecated this field in favour of destinations, which is the one to read on a current application', + items: { type: 'string', description: 'Hostname and path' }, + optional: true, + }, + destinations: { + type: 'json', + description: 'Public and private destinations secured by the application', + optional: true, + }, + tags: { + type: 'array', + description: 'Tags categorizing the application', + items: { type: 'string', description: 'Tag name' }, + optional: true, + }, + policies: { + type: 'json', + description: 'Access policies attached to the application', + optional: true, + }, + }, + }, + }, + total_count: { type: 'number', description: 'Total number of Access applications' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_access_groups.ts b/apps/sim/tools/cloudflare/list_access_groups.ts new file mode 100644 index 00000000000..65ed2b03cb2 --- /dev/null +++ b/apps/sim/tools/cloudflare/list_access_groups.ts @@ -0,0 +1,138 @@ +import type { + CloudflareListAccessGroupsParams, + CloudflareListAccessGroupsResponse, + CloudflareRawAccessGroup, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listAccessGroupsTool: ToolConfig< + CloudflareListAccessGroupsParams, + CloudflareListAccessGroupsResponse +> = { + id: 'cloudflare_list_access_groups', + name: 'Cloudflare List Access Groups', + description: + 'Lists the reusable Cloudflare Access (Zero Trust) groups in an account. Groups bundle identity rules that policies can reference by ID. Requires an API token with Account Access: Organizations, Identity Providers, and Groups Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access groups are account-scoped', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by group name', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across groups', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + per_page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of groups per page', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/groups` + ) + appendParam(url, 'name', params.name) + appendParam(url, 'search', params.search) + appendParam(url, 'page', params.page) + appendParam(url, 'per_page', params.per_page) + return url.toString() + }, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { groups: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list Access groups'), + } + } + + const groups = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + groups: groups.map((group) => ({ + id: group.id ?? '', + name: group.name ?? null, + is_default: group.is_default ?? null, + include: group.include ?? null, + exclude: group.exclude ?? null, + require: group.require ?? null, + created_at: group.created_at ?? null, + updated_at: group.updated_at ?? null, + })), + total_count: data.result_info?.total_count ?? groups.length, + }, + } + }, + + outputs: { + groups: { + type: 'array', + description: 'Access groups in the account', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Access group identifier' }, + name: { type: 'string', description: 'Group name', optional: true }, + is_default: { + type: 'json', + description: + 'Rules that place this group in every Access application by default. Cloudflare returns an array of rule objects here, not a boolean', + optional: true, + }, + include: { + type: 'json', + description: 'Rules evaluated with OR logic', + optional: true, + }, + exclude: { type: 'json', description: 'Rules evaluated with NOT logic', optional: true }, + require: { type: 'json', description: 'Rules evaluated with AND logic', optional: true }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + updated_at: { type: 'string', description: 'Last update timestamp', optional: true }, + }, + }, + }, + total_count: { type: 'number', description: 'Total number of Access groups' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_access_identity_providers.ts b/apps/sim/tools/cloudflare/list_access_identity_providers.ts new file mode 100644 index 00000000000..c6568fcb499 --- /dev/null +++ b/apps/sim/tools/cloudflare/list_access_identity_providers.ts @@ -0,0 +1,112 @@ +import type { + CloudflareListAccessIdentityProvidersParams, + CloudflareListAccessIdentityProvidersResponse, + CloudflareRawAccessIdentityProvider, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listAccessIdentityProvidersTool: ToolConfig< + CloudflareListAccessIdentityProvidersParams, + CloudflareListAccessIdentityProvidersResponse +> = { + id: 'cloudflare_list_access_identity_providers', + name: 'Cloudflare List Access Identity Providers', + description: + 'Lists the identity providers configured for Cloudflare Access (Zero Trust) in an account, such as Okta, Entra ID, Google Workspace, or a one-time PIN. Use the returned IDs to restrict an application with allowed_idps. Requires an API token with Account Access: Organizations, Identity Providers, and Groups Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Identity providers are account-scoped', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/identity_providers`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { identity_providers: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list Access identity providers'), + } + } + + const providers = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + identity_providers: providers.map((provider) => ({ + id: provider.id ?? '', + name: provider.name ?? null, + type: provider.type ?? null, + read_only: provider.read_only ?? null, + config: provider.config ?? null, + scim_config: provider.scim_config ?? null, + })), + total_count: data.result_info?.total_count ?? providers.length, + }, + } + }, + + outputs: { + identity_providers: { + type: 'array', + description: 'Identity providers configured for Access', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Identity provider identifier' }, + name: { + type: 'string', + description: 'Display name shown to users on the login page', + optional: true, + }, + type: { + type: 'string', + description: 'Provider type, e.g. azureAD, okta, google, saml, oidc, or onetimepin', + optional: true, + }, + read_only: { + type: 'boolean', + description: 'Whether the provider is immutable through the API', + optional: true, + }, + config: { + type: 'json', + description: 'Provider-specific configuration parameters', + optional: true, + }, + scim_config: { + type: 'json', + description: 'SCIM user and group provisioning configuration', + optional: true, + }, + }, + }, + }, + total_count: { type: 'number', description: 'Total number of identity providers' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_access_policies.ts b/apps/sim/tools/cloudflare/list_access_policies.ts new file mode 100644 index 00000000000..0ec5e0c645c --- /dev/null +++ b/apps/sim/tools/cloudflare/list_access_policies.ts @@ -0,0 +1,157 @@ +import type { + CloudflareListAccessPoliciesParams, + CloudflareListAccessPoliciesResponse, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + mapAccessPolicy, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listAccessPoliciesTool: ToolConfig< + CloudflareListAccessPoliciesParams, + CloudflareListAccessPoliciesResponse +> = { + id: 'cloudflare_list_access_policies', + name: 'Cloudflare List Access Policies', + description: + 'Lists the Cloudflare Access (Zero Trust) policies attached to an application, in precedence order. Requires an API token with Account Access: Apps and Policies Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access application ID whose policies should be listed', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + per_page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of policies per page', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps/${params.appId.trim()}/policies` + ) + appendParam(url, 'page', params.page) + appendParam(url, 'per_page', params.per_page) + return url.toString() + }, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { policies: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list Access policies'), + } + } + + const policies = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + policies: policies.map(mapAccessPolicy), + total_count: data.result_info?.total_count ?? policies.length, + }, + } + }, + + outputs: { + policies: { + type: 'array', + description: 'Access policies attached to the application', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Policy identifier' }, + name: { type: 'string', description: 'Policy name', optional: true }, + decision: { + type: 'string', + description: 'Decision the policy applies: allow, deny, non_identity, or bypass', + optional: true, + }, + precedence: { + type: 'number', + description: 'Evaluation order of the policy within the application', + optional: true, + }, + include: { + type: 'json', + description: 'Rules evaluated with OR logic — matching any one selects the policy', + optional: true, + }, + exclude: { + type: 'json', + description: 'Rules evaluated with NOT logic — matching any one rejects the request', + optional: true, + }, + require: { + type: 'json', + description: 'Rules evaluated with AND logic — all must match', + optional: true, + }, + session_duration: { + type: 'string', + description: 'How long a session granted by this policy stays valid', + optional: true, + }, + approval_required: { + type: 'boolean', + description: 'Whether an approver must grant each access request', + optional: true, + }, + isolation_required: { + type: 'boolean', + description: 'Whether the session must run in a remote browser', + optional: true, + }, + purpose_justification_required: { + type: 'boolean', + description: 'Whether users must state a reason for access', + optional: true, + }, + purpose_justification_prompt: { + type: 'string', + description: 'Prompt shown when a justification is required', + optional: true, + }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + updated_at: { type: 'string', description: 'Last update timestamp', optional: true }, + }, + }, + }, + total_count: { type: 'number', description: 'Total number of policies' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_access_service_tokens.ts b/apps/sim/tools/cloudflare/list_access_service_tokens.ts new file mode 100644 index 00000000000..75e0737752a --- /dev/null +++ b/apps/sim/tools/cloudflare/list_access_service_tokens.ts @@ -0,0 +1,143 @@ +import type { + CloudflareListAccessServiceTokensParams, + CloudflareListAccessServiceTokensResponse, + CloudflareRawAccessServiceToken, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listAccessServiceTokensTool: ToolConfig< + CloudflareListAccessServiceTokensParams, + CloudflareListAccessServiceTokensResponse +> = { + id: 'cloudflare_list_access_service_tokens', + name: 'Cloudflare List Access Service Tokens', + description: + 'Lists the Cloudflare Access (Zero Trust) service tokens in an account, which let machines authenticate to Access-protected applications. Client secrets are never returned by this endpoint — only on creation. Requires an API token with Account Access: Service Tokens Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Service tokens are account-scoped', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by service token name', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across service tokens', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + per_page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of service tokens per page', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/service_tokens` + ) + appendParam(url, 'name', params.name) + appendParam(url, 'search', params.search) + appendParam(url, 'page', params.page) + appendParam(url, 'per_page', params.per_page) + return url.toString() + }, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { service_tokens: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list Access service tokens'), + } + } + + const tokens = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + service_tokens: tokens.map((token) => ({ + id: token.id ?? '', + name: token.name ?? null, + client_id: token.client_id ?? null, + duration: token.duration ?? null, + enabled: token.enabled ?? null, + expires_at: token.expires_at ?? null, + last_seen_at: token.last_seen_at ?? null, + created_at: token.created_at ?? null, + updated_at: token.updated_at ?? null, + })), + total_count: data.result_info?.total_count ?? tokens.length, + }, + } + }, + + outputs: { + service_tokens: { + type: 'array', + description: 'Access service tokens in the account', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Service token identifier' }, + name: { type: 'string', description: 'Service token name', optional: true }, + client_id: { + type: 'string', + description: 'Client ID sent in the CF-Access-Client-Id header', + optional: true, + }, + duration: { + type: 'string', + description: 'How long the token stays valid before it expires', + optional: true, + }, + enabled: { type: 'boolean', description: 'Whether the token is active', optional: true }, + expires_at: { type: 'string', description: 'Expiry timestamp', optional: true }, + last_seen_at: { + type: 'string', + description: 'When the token was last used', + optional: true, + }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + updated_at: { type: 'string', description: 'Last update timestamp', optional: true }, + }, + }, + }, + total_count: { type: 'number', description: 'Total number of service tokens' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_certificates.ts b/apps/sim/tools/cloudflare/list_certificates.ts index 41a44803d5c..3bec7e6799a 100644 --- a/apps/sim/tools/cloudflare/list_certificates.ts +++ b/apps/sim/tools/cloudflare/list_certificates.ts @@ -1,7 +1,9 @@ import type { CloudflareListCertificatesParams, CloudflareListCertificatesResponse, + CloudflareRawCertificatePack, } from '@/tools/cloudflare/types' +import { readCloudflareResponse } from '@/tools/cloudflare/utils' import type { ToolConfig } from '@/tools/types' export const listCertificatesTool: ToolConfig< @@ -24,7 +26,8 @@ export const listCertificatesTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Filter certificate packs by status (e.g., "all", "active", "pending")', + description: + 'Set to "all" to include every certificate pack regardless of status. Cloudflare documents no other value for this filter; omitting it returns only active packs', }, page: { type: 'number', @@ -55,7 +58,7 @@ export const listCertificatesTool: ToolConfig< request: { url: (params) => { const url = new URL( - `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/ssl/certificate_packs` + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/ssl/certificate_packs` ) if (params.status) url.searchParams.append('status', params.status) if (params.page) url.searchParams.append('page', String(params.page)) @@ -71,7 +74,7 @@ export const listCertificatesTool: ToolConfig< }, transformResponse: async (response: Response) => { - const data = await response.json() + const data = await readCloudflareResponse(response) if (!data.success) { return { @@ -85,14 +88,14 @@ export const listCertificatesTool: ToolConfig< success: true, output: { certificates: - data.result?.map((cert: any) => ({ + data.result?.map((cert) => ({ id: cert.id ?? '', type: cert.type ?? '', hosts: cert.hosts ?? [], primary_certificate: cert.primary_certificate ?? '', status: cert.status ?? '', certificates: - cert.certificates?.map((c: any) => ({ + cert.certificates?.map((c) => ({ id: c.id ?? '', hosts: c.hosts ?? [], issuer: c.issuer ?? '', @@ -111,11 +114,11 @@ export const listCertificatesTool: ToolConfig< validity_days: cert.validity_days ?? 0, certificate_authority: cert.certificate_authority ?? '', validation_errors: - cert.validation_errors?.map((e: any) => ({ + cert.validation_errors?.map((e) => ({ message: e.message ?? '', })) ?? [], validation_records: - cert.validation_records?.map((r: any) => ({ + cert.validation_records?.map((r) => ({ cname: r.cname ?? '', cname_target: r.cname_target ?? '', emails: r.emails ?? [], @@ -126,7 +129,7 @@ export const listCertificatesTool: ToolConfig< txt_value: r.txt_value ?? '', })) ?? [], dcv_delegation_records: - cert.dcv_delegation_records?.map((r: any) => ({ + cert.dcv_delegation_records?.map((r) => ({ cname: r.cname ?? '', cname_target: r.cname_target ?? '', emails: r.emails ?? [], diff --git a/apps/sim/tools/cloudflare/list_dns_records.ts b/apps/sim/tools/cloudflare/list_dns_records.ts index 28520193bb3..36b91d12dce 100644 --- a/apps/sim/tools/cloudflare/list_dns_records.ts +++ b/apps/sim/tools/cloudflare/list_dns_records.ts @@ -1,7 +1,9 @@ import type { CloudflareListDnsRecordsParams, CloudflareListDnsRecordsResponse, + CloudflareRawDnsRecord, } from '@/tools/cloudflare/types' +import { readCloudflareResponse } from '@/tools/cloudflare/utils' import type { ToolConfig } from '@/tools/types' export const listDnsRecordsTool: ToolConfig< @@ -109,7 +111,9 @@ export const listDnsRecordsTool: ToolConfig< request: { url: (params) => { - const url = new URL(`https://api.cloudflare.com/client/v4/zones/${params.zoneId}/dns_records`) + const url = new URL( + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/dns_records` + ) if (params.type) url.searchParams.append('type', params.type) if (params.name) url.searchParams.append('name.exact', params.name) if (params.content) url.searchParams.append('content.exact', params.content) @@ -133,7 +137,7 @@ export const listDnsRecordsTool: ToolConfig< }, transformResponse: async (response: Response) => { - const data = await response.json() + const data = await readCloudflareResponse(response) if (!data.success) { return { @@ -147,7 +151,7 @@ export const listDnsRecordsTool: ToolConfig< success: true, output: { records: - data.result?.map((record: any) => ({ + data.result?.map((record) => ({ id: record.id ?? '', zone_id: record.zone_id ?? '', zone_name: record.zone_name ?? '', diff --git a/apps/sim/tools/cloudflare/list_managed_ruleset_overrides.ts b/apps/sim/tools/cloudflare/list_managed_ruleset_overrides.ts new file mode 100644 index 00000000000..603a58d2990 --- /dev/null +++ b/apps/sim/tools/cloudflare/list_managed_ruleset_overrides.ts @@ -0,0 +1,116 @@ +import type { + CloudflareListManagedRulesetOverridesParams, + CloudflareListManagedRulesetOverridesResponse, + CloudflareRawRuleset, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listManagedRulesetOverridesTool: ToolConfig< + CloudflareListManagedRulesetOverridesParams, + CloudflareListManagedRulesetOverridesResponse +> = { + id: 'cloudflare_list_managed_ruleset_overrides', + name: 'Cloudflare List Managed Ruleset Overrides', + description: + 'Lists the WAF managed rulesets deployed on a zone together with the overrides applied to each one. Cloudflare has no dedicated overrides endpoint — overrides live on the "execute" rules of the http_request_firewall_managed phase entry point ruleset, which this reads. Requires an API token with Zone WAF Read.', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID to read managed ruleset deployments and overrides for', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/phases/http_request_firewall_managed/entrypoint`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { ruleset_id: '', deployments: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list managed ruleset overrides'), + } + } + + const rules = Array.isArray(data.result?.rules) ? data.result.rules : [] + const deployments = rules + .filter((rule) => rule.action === 'execute') + .map((rule) => ({ + rule_id: rule.id ?? '', + managed_ruleset_id: rule.action_parameters?.id ?? null, + description: rule.description ?? '', + expression: rule.expression ?? '', + enabled: rule.enabled ?? false, + overrides: rule.action_parameters?.overrides ?? null, + })) + + return { + success: true, + output: { + ruleset_id: data.result?.id ?? '', + deployments, + total_count: deployments.length, + }, + } + }, + + outputs: { + ruleset_id: { + type: 'string', + description: + 'Ruleset ID of the http_request_firewall_managed entry point, needed to edit a deployment rule', + }, + deployments: { + type: 'array', + description: 'Managed rulesets deployed on the zone and the overrides applied to each', + items: { + type: 'object', + properties: { + rule_id: { + type: 'string', + description: 'ID of the execute rule that deploys the managed ruleset', + }, + managed_ruleset_id: { + type: 'string', + description: 'ID of the deployed managed ruleset', + optional: true, + }, + description: { type: 'string', description: 'Description of the deployment rule' }, + expression: { + type: 'string', + description: 'Filter expression scoping which requests the managed ruleset runs on', + }, + enabled: { type: 'boolean', description: 'Whether the deployment is enabled' }, + overrides: { + type: 'json', + description: + 'Overrides applied to the managed ruleset, at three levels. Cloudflare documents action, enabled, and sensitivity_level at the ruleset (top) level; category, action, enabled, and sensitivity_level per category; and id, action, enabled, score_threshold, and sensitivity_level per rule. Rule overrides beat category overrides, which beat the ruleset-level override. sensitivity_level applies only to the DDoS phases, so for a WAF managed ruleset the rule-level properties are action, enabled, and score_threshold', + optional: true, + }, + }, + }, + }, + total_count: { type: 'number', description: 'Number of managed ruleset deployments found' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_r2_buckets.ts b/apps/sim/tools/cloudflare/list_r2_buckets.ts new file mode 100644 index 00000000000..cd73043771d --- /dev/null +++ b/apps/sim/tools/cloudflare/list_r2_buckets.ts @@ -0,0 +1,160 @@ +import type { + CloudflareListR2BucketsParams, + CloudflareListR2BucketsResponse, + CloudflareRawR2Bucket, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listR2BucketsTool: ToolConfig< + CloudflareListR2BucketsParams, + CloudflareListR2BucketsResponse +> = { + id: 'cloudflare_list_r2_buckets', + name: 'Cloudflare List R2 Buckets', + description: + 'Lists the R2 object storage buckets in an account. Requires an API token with Account Workers R2 Storage Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. R2 buckets are account-scoped', + }, + name_contains: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return buckets whose name contains this substring', + }, + start_after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Bucket name to start listing after', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor returned by a previous call', + }, + direction: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort direction by bucket name: asc or desc', + }, + per_page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of buckets per page', + }, + jurisdiction: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data-residency jurisdiction to list within: default, eu, or fedramp', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/r2/buckets` + ) + appendParam(url, 'name_contains', params.name_contains) + appendParam(url, 'start_after', params.start_after) + appendParam(url, 'cursor', params.cursor) + appendParam(url, 'direction', params.direction) + appendParam(url, 'per_page', params.per_page) + // `direction` only means something alongside an ordering field, and `name` + // is the sole value Cloudflare documents for `order`. + if (params.direction) url.searchParams.append('order', 'name') + return url.toString() + }, + method: 'GET', + headers: (params) => { + const headers = cloudflareHeaders(params.apiKey) + if (params.jurisdiction) headers['cf-r2-jurisdiction'] = params.jurisdiction + return headers + }, + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse<{ buckets?: CloudflareRawR2Bucket[] }>(response) + + if (!data.success) { + return { + success: false, + output: { buckets: [], cursor: null }, + error: cloudflareErrorMessage(data, 'Failed to list R2 buckets'), + } + } + + const buckets = Array.isArray(data.result?.buckets) ? data.result.buckets : [] + + return { + success: true, + output: { + buckets: buckets.map((bucket) => ({ + name: bucket.name ?? '', + creation_date: bucket.creation_date ?? null, + location: bucket.location ?? null, + storage_class: bucket.storage_class ?? null, + jurisdiction: bucket.jurisdiction ?? null, + })), + cursor: data.result_info?.cursor ?? null, + }, + } + }, + + outputs: { + buckets: { + type: 'array', + description: 'R2 buckets in the account', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Bucket name' }, + creation_date: { type: 'string', description: 'Creation timestamp', optional: true }, + location: { + type: 'string', + description: + 'Location hint the bucket was created with (apac, eeur, enam, weur, wnam, or oc)', + optional: true, + }, + storage_class: { + type: 'string', + description: 'Default storage class (Standard or InfrequentAccess)', + optional: true, + }, + jurisdiction: { + type: 'string', + description: 'Data-residency jurisdiction (default, eu, or fedramp)', + optional: true, + }, + }, + }, + }, + cursor: { + type: 'string', + description: 'Pagination cursor to pass to the next call', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_rate_limit_rules.ts b/apps/sim/tools/cloudflare/list_rate_limit_rules.ts new file mode 100644 index 00000000000..09a723c58ec --- /dev/null +++ b/apps/sim/tools/cloudflare/list_rate_limit_rules.ts @@ -0,0 +1,119 @@ +import type { + CloudflareListRateLimitRulesParams, + CloudflareRulesetResponse, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listRateLimitRulesTool: ToolConfig< + CloudflareListRateLimitRulesParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_list_rate_limit_rules', + name: 'Cloudflare List Rate Limiting Rules', + description: + 'Lists the rate limiting rules on a zone by reading the http_ratelimit phase entry point ruleset. This uses the current Rulesets-based rate limiting API; the legacy rate_limits endpoint is no longer available. The returned ruleset ID is what "Create Rate Limiting Rule", "Update Rate Limiting Rule", and "Delete Ruleset Rule" need. Requires an API token with Zone WAF Read.', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID to list rate limiting rules for', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/phases/http_ratelimit/entrypoint`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to list rate limiting rules'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { + type: 'string', + description: 'Ruleset ID of the http_ratelimit entry point, needed to create or edit rules', + }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind' }, + phase: { type: 'string', description: 'Phase the ruleset runs in (http_ratelimit)' }, + version: { type: 'string', description: 'Ruleset version', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rate limiting rules, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { + type: 'string', + description: 'Action applied once the rate limit is exceeded', + }, + action_parameters: { + type: 'json', + description: 'Action-specific parameters, such as a custom block response', + optional: true, + }, + expression: { + type: 'string', + description: 'Filter expression selecting the requests the rule applies to', + }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { type: 'string', description: 'Rule reference tag', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { + type: 'json', + description: + 'Rate limiting configuration (characteristics, period, requests_per_period, mitigation_timeout, counting_expression, requests_to_origin)', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_rulesets.ts b/apps/sim/tools/cloudflare/list_rulesets.ts new file mode 100644 index 00000000000..25cffb0a1ee --- /dev/null +++ b/apps/sim/tools/cloudflare/list_rulesets.ts @@ -0,0 +1,131 @@ +import type { + CloudflareListRulesetsParams, + CloudflareListRulesetsResponse, + CloudflareRawRuleset, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listRulesetsTool: ToolConfig< + CloudflareListRulesetsParams, + CloudflareListRulesetsResponse +> = { + id: 'cloudflare_list_rulesets', + name: 'Cloudflare List Rulesets', + description: + 'Lists every ruleset defined on a zone across all phases (WAF custom rules, managed rules, rate limiting, transform rules, and more). The list response deliberately omits the rules inside each ruleset — use "Get Ruleset" to read them. Requires an API token with Zone WAF Read (or another matching ruleset Read permission).', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID to list rulesets for', + }, + per_page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of rulesets to return per page', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Cursor for the next page, taken from the cursor output of a previous call. This endpoint paginates by cursor, not by page number', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets` + ) + appendParam(url, 'per_page', params.per_page) + appendParam(url, 'cursor', params.cursor) + return url.toString() + }, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { rulesets: [], total_count: 0, cursor: null }, + error: cloudflareErrorMessage(data, 'Failed to list rulesets'), + } + } + + const rulesets = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + rulesets: rulesets.map((ruleset) => ({ + id: ruleset.id ?? '', + name: ruleset.name ?? '', + description: ruleset.description ?? '', + kind: ruleset.kind ?? '', + phase: ruleset.phase ?? '', + version: ruleset.version ?? null, + last_updated: ruleset.last_updated ?? null, + })), + total_count: rulesets.length, + cursor: data.result_info?.cursors?.after ?? null, + }, + } + }, + + outputs: { + rulesets: { + type: 'array', + description: 'Rulesets defined on the zone', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Ruleset identifier' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { + type: 'string', + description: 'Ruleset kind (managed, custom, root, or zone)', + }, + phase: { + type: 'string', + description: + 'Phase the ruleset runs in (e.g., http_request_firewall_custom, http_request_firewall_managed, http_ratelimit)', + }, + version: { type: 'string', description: 'Ruleset version', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + }, + }, + }, + total_count: { type: 'number', description: 'Number of rulesets returned on this page' }, + cursor: { + type: 'string', + description: 'Cursor to pass to the next call to read the following page, when more remain', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_tunnels.ts b/apps/sim/tools/cloudflare/list_tunnels.ts new file mode 100644 index 00000000000..14f8821e1da --- /dev/null +++ b/apps/sim/tools/cloudflare/list_tunnels.ts @@ -0,0 +1,226 @@ +import type { + CloudflareListTunnelsParams, + CloudflareListTunnelsResponse, + CloudflareRawTunnel, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listTunnelsTool: ToolConfig< + CloudflareListTunnelsParams, + CloudflareListTunnelsResponse +> = { + id: 'cloudflare_list_tunnels', + name: 'Cloudflare List Tunnels', + description: + 'Lists the Cloudflare Tunnels (cloudflared) in an account, with their health status and active connections. Requires an API token with Account Cloudflare Tunnel Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Tunnels are account-scoped', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by exact tunnel name', + }, + status: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by tunnel health: inactive, degraded, healthy, or down', + }, + uuid: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by tunnel UUID', + }, + is_deleted: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return deleted tunnels instead of active ones', + }, + include_prefix: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include tunnels whose name starts with this prefix', + }, + exclude_prefix: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exclude tunnels whose name starts with this prefix', + }, + existed_at: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return tunnels that existed at this RFC 3339 timestamp', + }, + was_active_at: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return tunnels that were active at this RFC 3339 timestamp', + }, + was_inactive_at: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return tunnels that were inactive at this RFC 3339 timestamp', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + per_page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of tunnels per page', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/cfd_tunnel` + ) + appendParam(url, 'name', params.name) + appendParam(url, 'status', params.status) + appendParam(url, 'uuid', params.uuid) + if (params.is_deleted !== undefined) { + url.searchParams.append('is_deleted', String(params.is_deleted)) + } + appendParam(url, 'include_prefix', params.include_prefix) + appendParam(url, 'exclude_prefix', params.exclude_prefix) + appendParam(url, 'existed_at', params.existed_at) + appendParam(url, 'was_active_at', params.was_active_at) + appendParam(url, 'was_inactive_at', params.was_inactive_at) + appendParam(url, 'page', params.page) + appendParam(url, 'per_page', params.per_page) + return url.toString() + }, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { tunnels: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list tunnels'), + } + } + + const tunnels = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + tunnels: tunnels.map((tunnel) => ({ + id: tunnel.id ?? '', + name: tunnel.name ?? null, + account_tag: tunnel.account_tag ?? null, + config_src: tunnel.config_src ?? null, + status: tunnel.status ?? null, + tun_type: tunnel.tun_type ?? null, + remote_config: tunnel.remote_config ?? null, + metadata: tunnel.metadata ?? null, + created_at: tunnel.created_at ?? null, + deleted_at: tunnel.deleted_at ?? null, + conns_active_at: tunnel.conns_active_at ?? null, + conns_inactive_at: tunnel.conns_inactive_at ?? null, + connections: tunnel.connections ?? null, + })), + total_count: data.result_info?.total_count ?? tunnels.length, + }, + } + }, + + outputs: { + tunnels: { + type: 'array', + description: 'Cloudflare Tunnels in the account', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Tunnel identifier' }, + name: { type: 'string', description: 'Tunnel name', optional: true }, + account_tag: { + type: 'string', + description: 'Account the tunnel belongs to', + optional: true, + }, + config_src: { + type: 'string', + description: 'Where the tunnel configuration lives: local or cloudflare', + optional: true, + }, + status: { + type: 'string', + description: 'Tunnel health: inactive, degraded, healthy, or down', + optional: true, + }, + tun_type: { + type: 'string', + description: 'Tunnel type, e.g. cfd_tunnel, warp_connector, or warp', + optional: true, + }, + remote_config: { + type: 'boolean', + description: 'Whether the tunnel is remotely managed', + optional: true, + }, + metadata: { + type: 'json', + description: 'Metadata associated with the tunnel', + optional: true, + }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + deleted_at: { type: 'string', description: 'Deletion timestamp', optional: true }, + conns_active_at: { + type: 'string', + description: 'When the tunnel last had active connections', + optional: true, + }, + conns_inactive_at: { + type: 'string', + description: 'When the tunnel last lost all connections', + optional: true, + }, + connections: { + type: 'json', + description: 'Active connector connections for the tunnel', + optional: true, + }, + }, + }, + }, + total_count: { type: 'number', description: 'Total number of tunnels' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_worker_routes.ts b/apps/sim/tools/cloudflare/list_worker_routes.ts new file mode 100644 index 00000000000..96dffef09f9 --- /dev/null +++ b/apps/sim/tools/cloudflare/list_worker_routes.ts @@ -0,0 +1,94 @@ +import type { + CloudflareListWorkerRoutesParams, + CloudflareListWorkerRoutesResponse, + CloudflareRawWorkerRoute, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listWorkerRoutesTool: ToolConfig< + CloudflareListWorkerRoutesParams, + CloudflareListWorkerRoutesResponse +> = { + id: 'cloudflare_list_worker_routes', + name: 'Cloudflare List Worker Routes', + description: + 'Lists the Workers routes on a zone, showing which URL patterns are handled by which Worker script. Unlike the Workers script endpoints, routes are zone-scoped. Requires an API token with Zone Workers Routes Read.', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The zone ID to list Workers routes for. Routes are zone-scoped, not account-scoped', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/workers/routes`, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { routes: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list Worker routes'), + } + } + + const routes = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + routes: routes.map((route) => ({ + id: route.id ?? '', + pattern: route.pattern ?? '', + script: route.script ?? null, + })), + total_count: routes.length, + }, + } + }, + + outputs: { + routes: { + type: 'array', + description: 'Workers routes on the zone', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Route identifier' }, + pattern: { + type: 'string', + description: 'URL pattern the route matches, e.g. example.com/*', + }, + script: { + type: 'string', + description: 'Name of the Workers script handling the route', + optional: true, + }, + }, + }, + }, + total_count: { type: 'number', description: 'Number of routes returned' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_worker_scripts.ts b/apps/sim/tools/cloudflare/list_worker_scripts.ts new file mode 100644 index 00000000000..c41bfdd2542 --- /dev/null +++ b/apps/sim/tools/cloudflare/list_worker_scripts.ts @@ -0,0 +1,158 @@ +import type { + CloudflareListWorkerScriptsParams, + CloudflareListWorkerScriptsResponse, + CloudflareRawWorkerScript, +} from '@/tools/cloudflare/types' +import { + appendParam, + cloudflareErrorMessage, + cloudflareHeaders, + readCloudflareResponse, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const listWorkerScriptsTool: ToolConfig< + CloudflareListWorkerScriptsParams, + CloudflareListWorkerScriptsResponse +> = { + id: 'cloudflare_list_worker_scripts', + name: 'Cloudflare List Worker Scripts', + description: + 'Lists the Workers scripts deployed in an account. Requires an API token with Account Workers Scripts Read.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Workers scripts are account-scoped', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter scripts by tag. Cloudflare expects a comma-separated list of tag:allowed pairs where allowed is yes or no, e.g. team:core:yes,deprecated:no', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/workers/scripts` + ) + appendParam(url, 'tags', params.tags) + return url.toString() + }, + method: 'GET', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await readCloudflareResponse(response) + + if (!data.success) { + return { + success: false, + output: { scripts: [], total_count: 0 }, + error: cloudflareErrorMessage(data, 'Failed to list Worker scripts'), + } + } + + const scripts = Array.isArray(data.result) ? data.result : [] + + return { + success: true, + output: { + scripts: scripts.map((script) => ({ + id: script.id ?? '', + tag: script.tag ?? null, + etag: script.etag ?? null, + created_on: script.created_on ?? null, + modified_on: script.modified_on ?? null, + usage_model: script.usage_model ?? null, + placement_mode: script.placement_mode ?? null, + logpush: script.logpush ?? null, + has_assets: script.has_assets ?? null, + has_modules: script.has_modules ?? null, + compatibility_date: script.compatibility_date ?? null, + compatibility_flags: script.compatibility_flags ?? null, + routes: script.routes ?? null, + tail_consumers: script.tail_consumers ?? null, + })), + total_count: scripts.length, + }, + } + }, + + outputs: { + scripts: { + type: 'array', + description: 'Workers scripts in the account', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Script name' }, + tag: { + type: 'string', + description: 'Immutable script identifier, distinct from the script name', + optional: true, + }, + etag: { type: 'string', description: 'Hash of the script content', optional: true }, + created_on: { type: 'string', description: 'Creation timestamp', optional: true }, + modified_on: { type: 'string', description: 'Last deployment timestamp', optional: true }, + usage_model: { + type: 'string', + description: 'Billing usage model (standard, bundled, or unbound)', + optional: true, + }, + placement_mode: { + type: 'string', + description: 'Smart placement mode (smart or targeted)', + optional: true, + }, + logpush: { + type: 'boolean', + description: 'Whether Workers Logpush is enabled', + optional: true, + }, + has_assets: { + type: 'boolean', + description: 'Whether the script ships static assets', + optional: true, + }, + has_modules: { + type: 'boolean', + description: 'Whether the script uses ES modules', + optional: true, + }, + compatibility_date: { + type: 'string', + description: 'Workers runtime compatibility date', + optional: true, + }, + compatibility_flags: { + type: 'array', + description: 'Workers runtime compatibility flags', + items: { type: 'string', description: 'Compatibility flag' }, + optional: true, + }, + routes: { type: 'json', description: 'Routes the script is bound to', optional: true }, + tail_consumers: { + type: 'json', + description: "Workers that consume this script's tail events", + optional: true, + }, + }, + }, + }, + total_count: { type: 'number', description: 'Number of scripts returned' }, + }, +} diff --git a/apps/sim/tools/cloudflare/list_zones.ts b/apps/sim/tools/cloudflare/list_zones.ts index 10bfe850b76..f9328127388 100644 --- a/apps/sim/tools/cloudflare/list_zones.ts +++ b/apps/sim/tools/cloudflare/list_zones.ts @@ -1,7 +1,9 @@ import type { CloudflareListZonesParams, CloudflareListZonesResponse, + CloudflareRawZone, } from '@/tools/cloudflare/types' +import { readCloudflareResponse } from '@/tools/cloudflare/utils' import type { ToolConfig } from '@/tools/types' export const listZonesTool: ToolConfig = { @@ -88,7 +90,7 @@ export const listZonesTool: ToolConfig { - const data = await response.json() + const data = await readCloudflareResponse(response) if (!data.success) { return { @@ -102,7 +104,7 @@ export const listZonesTool: ToolConfig ({ + data.result?.map((zone) => ({ id: zone.id ?? '', name: zone.name ?? '', status: zone.status ?? '', diff --git a/apps/sim/tools/cloudflare/purge_cache.ts b/apps/sim/tools/cloudflare/purge_cache.ts index 0eee61ce25d..7acd4155571 100644 --- a/apps/sim/tools/cloudflare/purge_cache.ts +++ b/apps/sim/tools/cloudflare/purge_cache.ts @@ -59,7 +59,8 @@ export const purgeCacheTool: ToolConfig `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/purge_cache`, + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/purge_cache`, method: 'POST', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, @@ -106,6 +107,20 @@ export const purgeCacheTool: ToolConfig 1) { + throw new Error( + `Only one purge target kind is allowed per request, but ${targets.join(' and ')} were provided. Run a separate purge for each.` + ) + } + return body }, }, diff --git a/apps/sim/tools/cloudflare/revoke_access_service_token.ts b/apps/sim/tools/cloudflare/revoke_access_service_token.ts new file mode 100644 index 00000000000..2215d8d777a --- /dev/null +++ b/apps/sim/tools/cloudflare/revoke_access_service_token.ts @@ -0,0 +1,99 @@ +import type { + CloudflareAccessServiceTokenResponse, + CloudflareRevokeAccessServiceTokenParams, +} from '@/tools/cloudflare/types' +import { cloudflareErrorMessage, cloudflareHeaders } from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const revokeAccessServiceTokenTool: ToolConfig< + CloudflareRevokeAccessServiceTokenParams, + CloudflareAccessServiceTokenResponse +> = { + id: 'cloudflare_revoke_access_service_token', + name: 'Cloudflare Revoke Access Service Token', + description: + 'Permanently deletes a Cloudflare Access (Zero Trust) service token, revoking it. Every machine or integration still presenting that client ID and secret is locked out of the Access-protected applications immediately, and the secret cannot be recovered. This cannot be undone. Requires an API token with Account Access: Service Tokens Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Service tokens are account-scoped', + }, + serviceTokenId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The service token ID to revoke permanently', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/service_tokens/${params.serviceTokenId.trim()}`, + method: 'DELETE', + headers: (params) => cloudflareHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + id: '', + name: null, + client_id: null, + duration: null, + enabled: null, + expires_at: null, + last_seen_at: null, + created_at: null, + updated_at: null, + }, + error: cloudflareErrorMessage(data, 'Failed to revoke Access service token'), + } + } + + const token = data.result + return { + success: true, + output: { + id: token?.id ?? '', + name: token?.name ?? null, + client_id: token?.client_id ?? null, + duration: token?.duration ?? null, + enabled: token?.enabled ?? null, + expires_at: token?.expires_at ?? null, + last_seen_at: token?.last_seen_at ?? null, + created_at: token?.created_at ?? null, + updated_at: token?.updated_at ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Identifier of the revoked service token' }, + name: { type: 'string', description: 'Service token name', optional: true }, + client_id: { + type: 'string', + description: 'Client ID that is no longer accepted', + optional: true, + }, + duration: { type: 'string', description: 'Configured token lifetime', optional: true }, + enabled: { type: 'boolean', description: 'Whether the token was active', optional: true }, + expires_at: { type: 'string', description: 'Expiry timestamp', optional: true }, + last_seen_at: { type: 'string', description: 'When the token was last used', optional: true }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + updated_at: { type: 'string', description: 'Last update timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/cloudflare/types.ts b/apps/sim/tools/cloudflare/types.ts index cc52a9b1f36..fa827e17f9c 100644 --- a/apps/sim/tools/cloudflare/types.ts +++ b/apps/sim/tools/cloudflare/types.ts @@ -4,6 +4,327 @@ interface CloudflareBaseParams { apiKey: string } +/** + * Pagination metadata Cloudflare attaches to list endpoints. Page-based + * endpoints populate `page`/`per_page`/`total_count`; the Rulesets engine and + * R2 use cursors instead, so every field is optional. + */ +export interface CloudflareResultInfo { + page?: number + per_page?: number + count?: number + total_count?: number + cursor?: string + cursors?: { + before?: string + after?: string + } +} + +/** + * The Cloudflare v4 response envelope. Every endpoint answers with this shape, + * and a `200 OK` carrying `success: false` is a failure — callers must branch + * on `success` rather than the HTTP status. `result` is absent on failures. + */ +export interface CloudflareEnvelope { + success?: boolean + errors?: Array<{ code?: number; message?: string }> + messages?: unknown[] + result?: TResult + result_info?: CloudflareResultInfo +} + +/** Raw rule payload inside a ruleset, as returned by the Rulesets API. */ +export interface CloudflareRawRule { + id?: string + version?: string + action?: string + action_parameters?: { + id?: string + overrides?: Record + [key: string]: unknown + } + expression?: string + description?: string + enabled?: boolean + ref?: string + last_updated?: string + categories?: string[] + logging?: Record + ratelimit?: Record +} + +/** Raw ruleset payload. `rules` is omitted by the list-rulesets endpoint. */ +export interface CloudflareRawRuleset { + id?: string + name?: string + description?: string + kind?: string + phase?: string + version?: string + last_updated?: string + rules?: CloudflareRawRule[] +} + +/** Raw Cloudflare Access application payload. */ +export interface CloudflareRawAccessApplication { + id?: string + name?: string + domain?: string + type?: string + aud?: string + session_duration?: string + allowed_idps?: string[] + app_launcher_visible?: boolean + auto_redirect_to_identity?: boolean + custom_deny_message?: string + custom_deny_url?: string + logo_url?: string + self_hosted_domains?: string[] + destinations?: unknown[] + tags?: string[] + policies?: unknown[] +} + +/** Raw Cloudflare Access policy payload. */ +export interface CloudflareRawAccessPolicy { + id?: string + name?: string + decision?: string + precedence?: number + include?: unknown[] + exclude?: unknown[] + require?: unknown[] + session_duration?: string + approval_required?: boolean + isolation_required?: boolean + purpose_justification_required?: boolean + purpose_justification_prompt?: string + created_at?: string + updated_at?: string +} + +/** Raw Cloudflare Access group payload. */ +export interface CloudflareRawAccessGroup { + id?: string + name?: string + is_default?: unknown[] + include?: unknown[] + exclude?: unknown[] + require?: unknown[] + created_at?: string + updated_at?: string +} + +/** Raw Cloudflare Access identity provider payload. */ +export interface CloudflareRawAccessIdentityProvider { + id?: string + name?: string + type?: string + read_only?: boolean + config?: Record + scim_config?: Record +} + +/** Raw Cloudflare Access service token payload. `client_secret` only on create. */ +export interface CloudflareRawAccessServiceToken { + id?: string + name?: string + client_id?: string + client_secret?: string + duration?: string + enabled?: boolean + expires_at?: string + last_seen_at?: string + created_at?: string + updated_at?: string +} + +/** Raw R2 bucket payload. */ +export interface CloudflareRawR2Bucket { + name?: string + creation_date?: string + location?: string + storage_class?: string + jurisdiction?: string +} + +/** Raw Workers script payload from the list-scripts endpoint. */ +export interface CloudflareRawWorkerScript { + id?: string + tag?: string + etag?: string + created_on?: string + modified_on?: string + usage_model?: string + placement_mode?: string + logpush?: boolean + has_assets?: boolean + has_modules?: boolean + compatibility_date?: string + compatibility_flags?: string[] + routes?: unknown[] + tail_consumers?: unknown[] +} + +/** Raw Workers route payload. */ +export interface CloudflareRawWorkerRoute { + id?: string + pattern?: string + script?: string +} + +/** Raw Cloudflare Tunnel (cloudflared) payload. */ +export interface CloudflareRawTunnel { + id?: string + name?: string + account_tag?: string + config_src?: string + status?: string + tun_type?: string + remote_config?: boolean + metadata?: Record + created_at?: string + deleted_at?: string + conns_active_at?: string + conns_inactive_at?: string + connections?: unknown[] +} + +/** Raw zone payload from the zones endpoints. */ +export interface CloudflareRawZone { + id?: string + name?: string + status?: string + paused?: boolean + type?: string + name_servers?: string[] + original_name_servers?: string[] + created_on?: string + modified_on?: string + activated_on?: string + development_mode?: number + plan?: { + id?: string + name?: string + price?: number + is_subscribed?: boolean + frequency?: string + currency?: string + legacy_id?: string + } + account?: { id?: string; name?: string } + owner?: { id?: string; name?: string; type?: string } + meta?: { + cdn_only?: boolean + custom_certificate_quota?: number + dns_only?: boolean + foundation_dns?: boolean + page_rule_quota?: number + phishing_detected?: boolean + step?: number + } + vanity_name_servers?: string[] + permissions?: string[] +} + +/** Raw DNS record payload. */ +export interface CloudflareRawDnsRecord { + id?: string + zone_id?: string + zone_name?: string + type?: string + name?: string + content?: string + proxiable?: boolean + proxied?: boolean + ttl?: number + locked?: boolean + priority?: number + comment?: string + tags?: string[] + comment_modified_on?: string + tags_modified_on?: string + meta?: CloudflareDnsRecordMeta + created_on?: string + modified_on?: string +} + +/** Raw hostname-validation record shared by certificate pack validation fields. */ +export interface CloudflareRawValidationRecord { + cname?: string + cname_target?: string + emails?: string[] + http_body?: string + http_url?: string + status?: string + txt_name?: string + txt_value?: string +} + +/** Raw certificate inside a certificate pack. */ +export interface CloudflareRawCertificate { + id?: string + hosts?: string[] + issuer?: string + signature?: string + status?: string + bundle_method?: string + zone_id?: string + uploaded_on?: string + modified_on?: string + expires_on?: string + priority?: number + geo_restrictions?: CloudflareCertificateGeoRestrictions +} + +/** Raw certificate pack payload. */ +export interface CloudflareRawCertificatePack { + id?: string + type?: string + hosts?: string[] + primary_certificate?: string + status?: string + certificates?: CloudflareRawCertificate[] + cloudflare_branding?: boolean + validation_method?: string + validity_days?: number + certificate_authority?: string + validation_errors?: Array<{ message?: string }> + validation_records?: CloudflareRawValidationRecord[] + dcv_delegation_records?: CloudflareRawValidationRecord[] +} + +/** Raw DNS analytics aggregate block (`totals`, `min`, and `max` share this shape). */ +export interface CloudflareRawDnsAnalyticsAggregate { + queryCount?: number + uncachedCount?: number + staleCount?: number + responseTimeAvg?: number + responseTimeMedian?: number + responseTime90th?: number + responseTime99th?: number +} + +/** Raw DNS analytics report payload. */ +export interface CloudflareRawDnsAnalyticsReport { + totals?: CloudflareRawDnsAnalyticsAggregate + min?: CloudflareRawDnsAnalyticsAggregate + max?: CloudflareRawDnsAnalyticsAggregate + data?: Array<{ dimensions?: string[]; metrics?: number[] }> + data_lag?: number + rows?: number + query?: { + since?: string + until?: string + metrics?: string[] + dimensions?: string[] + filters?: string + sort?: string[] + limit?: number + } +} + export interface CloudflareListZonesParams extends CloudflareBaseParams { name?: string status?: string @@ -179,7 +500,7 @@ interface CloudflareDnsRecord { proxied: boolean ttl: number locked: boolean - priority?: number + priority?: number | null comment?: string | null tags: string[] comment_modified_on?: string | null @@ -438,7 +759,593 @@ export interface CloudflareUpdateZoneSettingResponse extends ToolResponse { } } +export interface CloudflareListRulesetsParams extends CloudflareBaseParams { + zoneId: string + per_page?: number + cursor?: string +} + +interface CloudflareRulesetSummary { + id: string + name: string + description: string + kind: string + phase: string + version: string | null + last_updated: string | null +} + +export interface CloudflareListRulesetsResponse extends ToolResponse { + output: { + rulesets: CloudflareRulesetSummary[] + total_count: number + cursor: string | null + } +} + +interface CloudflareRule { + id: string + version: string | null + action: string + action_parameters: Record | null + expression: string + description: string + enabled: boolean + ref: string | null + last_updated: string | null + categories: string[] + logging: Record | null + ratelimit: Record | null +} + +interface CloudflareRuleset extends CloudflareRulesetSummary { + rules: CloudflareRule[] +} + +export interface CloudflareGetRulesetParams extends CloudflareBaseParams { + zoneId: string + rulesetId: string +} + +export interface CloudflareRulesetResponse extends ToolResponse { + output: CloudflareRuleset +} + +export interface CloudflareCreateRulesetRuleParams extends CloudflareBaseParams { + zoneId: string + rulesetId: string + action: string + expression: string + description?: string + enabled?: boolean + ref?: string + position?: string + actionParameters?: string +} + +export interface CloudflareUpdateRulesetRuleParams extends CloudflareBaseParams { + zoneId: string + rulesetId: string + ruleId: string + action: string + expression: string + description?: string + enabled?: boolean + ref?: string + actionParameters?: string + ratelimit?: string + logging?: string +} + +export interface CloudflareDeleteRulesetRuleParams extends CloudflareBaseParams { + zoneId: string + rulesetId: string + ruleId: string +} + +export interface CloudflareGetRulesetEntrypointParams extends CloudflareBaseParams { + zoneId: string + phase: string +} + +export interface CloudflareCreateRulesetParams extends CloudflareBaseParams { + zoneId: string + name: string + phase: string + kind?: string + description?: string + rules?: unknown +} + +export interface CloudflareListRateLimitRulesParams extends CloudflareBaseParams { + zoneId: string +} + +export interface CloudflareListManagedRulesetOverridesParams extends CloudflareBaseParams { + zoneId: string +} + +export interface CloudflareListManagedRulesetOverridesResponse extends ToolResponse { + output: { + ruleset_id: string + deployments: Array<{ + rule_id: string + managed_ruleset_id: string | null + description: string + expression: string + enabled: boolean + overrides: Record | null + }> + total_count: number + } +} + +export interface CloudflareCreateRateLimitRuleParams extends CloudflareBaseParams { + zoneId: string + rulesetId: string + expression: string + characteristics: string + period: number + requestsPerPeriod: number + action?: string + mitigationTimeout?: number + counting_expression?: string + requestsToOrigin?: boolean + description?: string + enabled?: boolean +} + +export interface CloudflareUpdateRateLimitRuleParams extends CloudflareBaseParams { + zoneId: string + rulesetId: string + ruleId: string + expression: string + characteristics: string + period: number + requestsPerPeriod: number + action: string + mitigationTimeout?: number + counting_expression?: string + requestsToOrigin?: boolean + description?: string + enabled?: boolean +} + +interface CloudflareAccessApplication { + id: string + name: string | null + domain: string | null + type: string | null + aud: string | null + session_duration: string | null + allowed_idps: string[] | null + app_launcher_visible: boolean | null + auto_redirect_to_identity: boolean | null + custom_deny_message: string | null + custom_deny_url: string | null + logo_url: string | null + self_hosted_domains: string[] | null + destinations: unknown[] | null + tags: string[] | null + policies: unknown[] | null +} + +export interface CloudflareListAccessApplicationsParams extends CloudflareBaseParams { + accountId: string + name?: string + domain?: string + aud?: string + search?: string + exact?: boolean + page?: number + per_page?: number +} + +export interface CloudflareListAccessApplicationsResponse extends ToolResponse { + output: { + applications: CloudflareAccessApplication[] + total_count: number + } +} + +export interface CloudflareGetAccessApplicationParams extends CloudflareBaseParams { + accountId: string + appId: string +} + +export interface CloudflareAccessApplicationResponse extends ToolResponse { + output: CloudflareAccessApplication +} + +export interface CloudflareCreateAccessApplicationParams extends CloudflareBaseParams { + accountId: string + type: string + domain?: string + name?: string + sessionDuration?: string + allowedIdps?: string + appLauncherVisible?: boolean + autoRedirectToIdentity?: boolean + customDenyMessage?: string + customDenyUrl?: string + logoUrl?: string + tags?: string + policies?: string + saasApp?: string + targetCriteria?: string +} + +export interface CloudflareUpdateAccessApplicationParams + extends CloudflareCreateAccessApplicationParams { + appId: string +} + +export interface CloudflareDeleteAccessApplicationParams extends CloudflareBaseParams { + accountId: string + appId: string +} + +export interface CloudflareDeletedIdResponse extends ToolResponse { + output: { + id: string + } +} + +interface CloudflareAccessPolicy { + id: string + name: string | null + decision: string | null + precedence: number | null + include: unknown[] | null + exclude: unknown[] | null + require: unknown[] | null + session_duration: string | null + approval_required: boolean | null + isolation_required: boolean | null + purpose_justification_required: boolean | null + purpose_justification_prompt: string | null + created_at: string | null + updated_at: string | null +} + +export interface CloudflareListAccessPoliciesParams extends CloudflareBaseParams { + accountId: string + appId: string + page?: number + per_page?: number +} + +export interface CloudflareListAccessPoliciesResponse extends ToolResponse { + output: { + policies: CloudflareAccessPolicy[] + total_count: number + } +} + +export interface CloudflareCreateAccessPolicyParams extends CloudflareBaseParams { + accountId: string + appId: string + name: string + decision: string + include: string + exclude?: string + require?: string + precedence?: number + sessionDuration?: string + approvalRequired?: boolean + isolationRequired?: boolean + purposeJustificationRequired?: boolean + purposeJustificationPrompt?: string +} + +export interface CloudflareUpdateAccessPolicyParams extends CloudflareCreateAccessPolicyParams { + policyId: string +} + +export interface CloudflareAccessPolicyResponse extends ToolResponse { + output: CloudflareAccessPolicy +} + +export interface CloudflareDeleteAccessPolicyParams extends CloudflareBaseParams { + accountId: string + appId: string + policyId: string +} + +export interface CloudflareListAccessGroupsParams extends CloudflareBaseParams { + accountId: string + name?: string + search?: string + page?: number + per_page?: number +} + +export interface CloudflareListAccessGroupsResponse extends ToolResponse { + output: { + groups: Array<{ + id: string + name: string | null + is_default: unknown[] | null + include: unknown[] | null + exclude: unknown[] | null + require: unknown[] | null + created_at: string | null + updated_at: string | null + }> + total_count: number + } +} + +export interface CloudflareListAccessIdentityProvidersParams extends CloudflareBaseParams { + accountId: string +} + +export interface CloudflareListAccessIdentityProvidersResponse extends ToolResponse { + output: { + identity_providers: Array<{ + id: string + name: string | null + type: string | null + read_only: boolean | null + config: Record | null + scim_config: Record | null + }> + total_count: number + } +} + +export interface CloudflareListAccessServiceTokensParams extends CloudflareBaseParams { + accountId: string + name?: string + search?: string + page?: number + per_page?: number +} + +interface CloudflareAccessServiceToken { + id: string + name: string | null + client_id: string | null + duration: string | null + enabled: boolean | null + expires_at: string | null + last_seen_at: string | null + created_at: string | null + updated_at: string | null +} + +export interface CloudflareListAccessServiceTokensResponse extends ToolResponse { + output: { + service_tokens: CloudflareAccessServiceToken[] + total_count: number + } +} + +export interface CloudflareCreateAccessServiceTokenParams extends CloudflareBaseParams { + accountId: string + name: string + duration?: string +} + +export interface CloudflareCreateAccessServiceTokenResponse extends ToolResponse { + output: CloudflareAccessServiceToken & { + client_secret: string | null + } +} + +export interface CloudflareRevokeAccessServiceTokenParams extends CloudflareBaseParams { + accountId: string + serviceTokenId: string +} + +export interface CloudflareAccessServiceTokenResponse extends ToolResponse { + output: CloudflareAccessServiceToken +} + +interface CloudflareR2Bucket { + name: string + creation_date: string | null + location: string | null + storage_class: string | null + jurisdiction: string | null +} + +export interface CloudflareListR2BucketsParams extends CloudflareBaseParams { + accountId: string + name_contains?: string + start_after?: string + cursor?: string + direction?: string + per_page?: number + jurisdiction?: string +} + +export interface CloudflareListR2BucketsResponse extends ToolResponse { + output: { + buckets: CloudflareR2Bucket[] + cursor: string | null + } +} + +export interface CloudflareGetR2BucketParams extends CloudflareBaseParams { + accountId: string + bucketName: string + jurisdiction?: string +} + +export interface CloudflareCreateR2BucketParams extends CloudflareBaseParams { + accountId: string + bucketName: string + locationHint?: string + storageClass?: string + jurisdiction?: string +} + +export interface CloudflareR2BucketResponse extends ToolResponse { + output: CloudflareR2Bucket +} + +export interface CloudflareDeleteR2BucketParams extends CloudflareBaseParams { + accountId: string + bucketName: string + jurisdiction?: string +} + +export interface CloudflareDeleteR2BucketResponse extends ToolResponse { + output: { + name: string + } +} + +export interface CloudflareListWorkerScriptsParams extends CloudflareBaseParams { + accountId: string + tags?: string +} + +export interface CloudflareListWorkerScriptsResponse extends ToolResponse { + output: { + scripts: Array<{ + id: string + tag: string | null + etag: string | null + created_on: string | null + modified_on: string | null + usage_model: string | null + placement_mode: string | null + logpush: boolean | null + has_assets: boolean | null + has_modules: boolean | null + compatibility_date: string | null + compatibility_flags: string[] | null + routes: unknown[] | null + tail_consumers: unknown[] | null + }> + total_count: number + } +} + +export interface CloudflareGetWorkerScriptSettingsParams extends CloudflareBaseParams { + accountId: string + scriptName: string +} + +export interface CloudflareGetWorkerScriptSettingsResponse extends ToolResponse { + output: { + bindings: unknown[] | null + compatibility_date: string | null + compatibility_flags: string[] | null + limits: Record | null + logpush: boolean | null + migrations: Record | null + observability: Record | null + placement: Record | null + tags: string[] | null + tail_consumers: unknown[] | null + usage_model: string | null + } +} + +export interface CloudflareListWorkerRoutesParams extends CloudflareBaseParams { + zoneId: string +} + +export interface CloudflareListWorkerRoutesResponse extends ToolResponse { + output: { + routes: Array<{ + id: string + pattern: string + script: string | null + }> + total_count: number + } +} + +interface CloudflareTunnel { + id: string + name: string | null + account_tag: string | null + config_src: string | null + status: string | null + tun_type: string | null + remote_config: boolean | null + metadata: Record | null + created_at: string | null + deleted_at: string | null + conns_active_at: string | null + conns_inactive_at: string | null + connections: unknown[] | null +} + +export interface CloudflareListTunnelsParams extends CloudflareBaseParams { + accountId: string + name?: string + status?: string + uuid?: string + is_deleted?: boolean + include_prefix?: string + exclude_prefix?: string + existed_at?: string + was_active_at?: string + was_inactive_at?: string + page?: number + per_page?: number +} + +export interface CloudflareListTunnelsResponse extends ToolResponse { + output: { + tunnels: CloudflareTunnel[] + total_count: number + } +} + +export interface CloudflareGetTunnelParams extends CloudflareBaseParams { + accountId: string + tunnelId: string +} + +export interface CloudflareTunnelResponse extends ToolResponse { + output: CloudflareTunnel +} + +export interface CloudflareGetTunnelConfigurationParams extends CloudflareBaseParams { + accountId: string + tunnelId: string +} + +export interface CloudflareGetTunnelConfigurationResponse extends ToolResponse { + output: { + tunnel_id: string + account_id: string + version: number | null + source: string | null + created_at: string | null + config: Record | null + } +} + export type CloudflareResponse = + | CloudflareListRulesetsResponse + | CloudflareRulesetResponse + | CloudflareListManagedRulesetOverridesResponse + | CloudflareListAccessApplicationsResponse + | CloudflareAccessApplicationResponse + | CloudflareListAccessPoliciesResponse + | CloudflareAccessPolicyResponse + | CloudflareListAccessGroupsResponse + | CloudflareListAccessIdentityProvidersResponse + | CloudflareListAccessServiceTokensResponse + | CloudflareCreateAccessServiceTokenResponse + | CloudflareAccessServiceTokenResponse + | CloudflareDeletedIdResponse + | CloudflareListR2BucketsResponse + | CloudflareR2BucketResponse + | CloudflareDeleteR2BucketResponse + | CloudflareListWorkerScriptsResponse + | CloudflareGetWorkerScriptSettingsResponse + | CloudflareListWorkerRoutesResponse + | CloudflareListTunnelsResponse + | CloudflareTunnelResponse + | CloudflareGetTunnelConfigurationResponse | CloudflareListZonesResponse | CloudflareGetZoneResponse | CloudflareCreateZoneResponse diff --git a/apps/sim/tools/cloudflare/update_access_application.ts b/apps/sim/tools/cloudflare/update_access_application.ts new file mode 100644 index 00000000000..8204a2eb317 --- /dev/null +++ b/apps/sim/tools/cloudflare/update_access_application.ts @@ -0,0 +1,263 @@ +import type { + CloudflareAccessApplicationResponse, + CloudflareUpdateAccessApplicationParams, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyAccessApplication, + mapAccessApplication, + parseCsvParam, + parseJsonArrayParam, + parseJsonObjectParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateAccessApplicationTool: ToolConfig< + CloudflareUpdateAccessApplicationParams, + CloudflareAccessApplicationResponse +> = { + id: 'cloudflare_update_access_application', + name: 'Cloudflare Update Access Application', + description: + 'Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access application ID to update', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Application type: self_hosted, saas, ssh, vnc, app_launcher, warp, biso, bookmark, infrastructure, rdp, mcp, mcp_portal, or proxy_endpoint. dash_sso has no request variant and cannot be written through the API', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The primary hostname and path secured by Access. Required for the self_hosted, ssh, vnc, and rdp types; optional for bookmark and mcp_portal; read-only for app_launcher, warp, biso, and proxy_endpoint; and absent from the saas, infrastructure, and mcp variants', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Friendly name shown in the dashboard and App Launcher', + }, + sessionDuration: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How long an Access session stays valid, e.g. 24h or 30m', + }, + allowedIdps: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated identity provider IDs users may authenticate with', + }, + appLauncherVisible: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the application is shown in the App Launcher', + }, + autoRedirectToIdentity: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether users skip the identity provider picker', + }, + customDenyMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Message shown to users who are denied access', + }, + customDenyUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL denied users are redirected to', + }, + logoUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Logo image URL shown in the dashboard and App Launcher', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated tag names categorizing the application', + }, + saasApp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON SaaS configuration, required for the saas type and rejected on every other type. SAML, e.g. {"auth_type":"saml","consumer_service_url":"https://example.com/acs","sp_entity_id":"https://example.com"}; OIDC, e.g. {"auth_type":"oidc","client_id":"...","redirect_uris":["https://example.com/callback"]}', + }, + targetCriteria: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of infrastructure target criteria, required for the infrastructure and rdp types and rejected on every other type, e.g. [{"port":22,"protocol":"SSH","target_attributes":{"hostname":["production"]}}]', + }, + policies: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of policies to attach. Entries may be reusable policy IDs or inline policy objects', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps/${params.appId.trim()}`, + method: 'PUT', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const body: Record = { type: params.type } + /** + * `domain` is writable only on the self_hosted, ssh, vnc, rdp, bookmark, + * and mcp_portal request variants — required on the first four — read-only + * on app_launcher, warp, biso, and proxy_endpoint, and absent from saas, + * infrastructure, and mcp. Sending a blank one makes those types + * unbuildable, so it is only forwarded when set. + */ + if (params.domain) body.domain = params.domain + if (params.name) body.name = params.name + if (params.sessionDuration) body.session_duration = params.sessionDuration + + const allowedIdps = parseCsvParam(params.allowedIdps) + if (allowedIdps) body.allowed_idps = allowedIdps + + if (params.appLauncherVisible !== undefined) { + body.app_launcher_visible = params.appLauncherVisible + } + if (params.autoRedirectToIdentity !== undefined) { + body.auto_redirect_to_identity = params.autoRedirectToIdentity + } + if (params.customDenyMessage) body.custom_deny_message = params.customDenyMessage + if (params.customDenyUrl) body.custom_deny_url = params.customDenyUrl + if (params.logoUrl) body.logo_url = params.logoUrl + + const tags = parseCsvParam(params.tags) + if (tags) body.tags = tags + + const policies = parseJsonArrayParam(params.policies, 'Policies') + if (policies) body.policies = policies + + const saasApp = parseJsonObjectParam(params.saasApp, 'SaaS Application') + if (saasApp) body.saas_app = saasApp + + const targetCriteria = parseJsonArrayParam(params.targetCriteria, 'Target Criteria') + if (targetCriteria) body.target_criteria = targetCriteria + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyAccessApplication(), + error: cloudflareErrorMessage(data, 'Failed to update Access application'), + } + } + + return { success: true, output: mapAccessApplication(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Access application identifier' }, + name: { type: 'string', description: 'Application name', optional: true }, + domain: { + type: 'string', + description: 'Primary hostname and path secured by Access', + optional: true, + }, + type: { type: 'string', description: 'Application type', optional: true }, + aud: { type: 'string', description: 'Audience tag used to verify Access JWTs', optional: true }, + session_duration: { + type: 'string', + description: 'How long an Access session stays valid', + optional: true, + }, + allowed_idps: { + type: 'array', + description: 'Identity provider IDs users may authenticate with', + items: { type: 'string', description: 'Identity provider ID' }, + optional: true, + }, + app_launcher_visible: { + type: 'boolean', + description: 'Whether the app appears in the App Launcher', + optional: true, + }, + auto_redirect_to_identity: { + type: 'boolean', + description: 'Whether users skip the identity provider picker', + optional: true, + }, + custom_deny_message: { + type: 'string', + description: 'Message shown when access is denied', + optional: true, + }, + custom_deny_url: { + type: 'string', + description: 'URL users are redirected to when access is denied', + optional: true, + }, + logo_url: { type: 'string', description: 'Logo image URL', optional: true }, + self_hosted_domains: { + type: 'array', + description: + 'Additional hostnames and paths secured by the application. Cloudflare deprecated this field in favour of destinations, which is the one to read on a current application', + items: { type: 'string', description: 'Hostname and path' }, + optional: true, + }, + destinations: { + type: 'json', + description: 'Public and private destinations secured by the application', + optional: true, + }, + tags: { + type: 'array', + description: 'Tags categorizing the application', + items: { type: 'string', description: 'Tag name' }, + optional: true, + }, + policies: { + type: 'json', + description: 'Access policies attached to the application', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/update_access_policy.ts b/apps/sim/tools/cloudflare/update_access_policy.ts new file mode 100644 index 00000000000..2c6b2357b38 --- /dev/null +++ b/apps/sim/tools/cloudflare/update_access_policy.ts @@ -0,0 +1,216 @@ +import type { + CloudflareAccessPolicyResponse, + CloudflareUpdateAccessPolicyParams, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyAccessPolicy, + mapAccessPolicy, + parseJsonArrayParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateAccessPolicyTool: ToolConfig< + CloudflareUpdateAccessPolicyParams, + CloudflareAccessPolicyResponse +> = { + id: 'cloudflare_update_access_policy', + name: 'Cloudflare Update Access Policy', + description: + 'Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit.', + version: '1.0.0', + + params: { + accountId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Cloudflare account ID. Access applications are account-scoped', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access application ID that owns the policy', + }, + policyId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Access policy ID to update', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the policy', + }, + decision: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'What the policy does when it matches: allow, deny, non_identity, or bypass (skip Access entirely)', + }, + include: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of Access rules evaluated with OR logic. Example: [{"email_domain":{"domain":"example.com"}}]', + }, + exclude: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of Access rules evaluated with NOT logic', + }, + require: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of Access rules evaluated with AND logic', + }, + precedence: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Evaluation order of the policy within the application', + }, + sessionDuration: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'How long a session granted by this policy stays valid, e.g. 24h. Leave it unset on a policy attached to an infrastructure-typed application — Cloudflare rejects those with error 12130', + }, + approvalRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether an approver must grant each access request', + }, + isolationRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the session must run in a remote isolated browser', + }, + purposeJustificationRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether users must state a reason for access', + }, + purposeJustificationPrompt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prompt shown when a justification is required', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/accounts/${params.accountId.trim()}/access/apps/${params.appId.trim()}/policies/${params.policyId.trim()}`, + method: 'PUT', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const include = parseJsonArrayParam(params.include, 'Include Rules') + if (!include || include.length === 0) { + throw new Error('Include Rules must contain at least one Access rule') + } + + const body: Record = { + name: params.name, + decision: params.decision, + include, + } + + const exclude = parseJsonArrayParam(params.exclude, 'Exclude Rules') + if (exclude) body.exclude = exclude + + const require = parseJsonArrayParam(params.require, 'Require Rules') + if (require) body.require = require + + if (params.precedence !== undefined) body.precedence = params.precedence + if (params.sessionDuration) body.session_duration = params.sessionDuration + if (params.approvalRequired !== undefined) body.approval_required = params.approvalRequired + if (params.isolationRequired !== undefined) body.isolation_required = params.isolationRequired + if (params.purposeJustificationRequired !== undefined) { + body.purpose_justification_required = params.purposeJustificationRequired + } + if (params.purposeJustificationPrompt) { + body.purpose_justification_prompt = params.purposeJustificationPrompt + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyAccessPolicy(), + error: cloudflareErrorMessage(data, 'Failed to update Access policy'), + } + } + + return { success: true, output: mapAccessPolicy(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Policy identifier' }, + name: { type: 'string', description: 'Policy name', optional: true }, + decision: { + type: 'string', + description: 'Decision the policy applies: allow, deny, non_identity, or bypass', + optional: true, + }, + precedence: { + type: 'number', + description: 'Evaluation order of the policy within the application', + optional: true, + }, + include: { type: 'json', description: 'Rules evaluated with OR logic', optional: true }, + exclude: { type: 'json', description: 'Rules evaluated with NOT logic', optional: true }, + require: { type: 'json', description: 'Rules evaluated with AND logic', optional: true }, + session_duration: { + type: 'string', + description: 'How long a session granted by this policy stays valid', + optional: true, + }, + approval_required: { + type: 'boolean', + description: 'Whether an approver must grant each access request', + optional: true, + }, + isolation_required: { + type: 'boolean', + description: 'Whether the session must run in a remote browser', + optional: true, + }, + purpose_justification_required: { + type: 'boolean', + description: 'Whether users must state a reason for access', + optional: true, + }, + purpose_justification_prompt: { + type: 'string', + description: 'Prompt shown when a justification is required', + optional: true, + }, + created_at: { type: 'string', description: 'Creation timestamp', optional: true }, + updated_at: { type: 'string', description: 'Last update timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/cloudflare/update_dns_record.ts b/apps/sim/tools/cloudflare/update_dns_record.ts index 7b51dbbfc1a..36c21df8e7b 100644 --- a/apps/sim/tools/cloudflare/update_dns_record.ts +++ b/apps/sim/tools/cloudflare/update_dns_record.ts @@ -84,17 +84,24 @@ export const updateDnsRecordTool: ToolConfig< request: { url: (params) => - `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/dns_records/${params.recordId}`, + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/dns_records/${params.recordId.trim()}`, method: 'PATCH', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, 'Content-Type': 'application/json', }), body: (params) => { - const body: Record = {} - if (params.type !== undefined) body.type = params.type - if (params.name !== undefined) body.name = params.name - if (params.content !== undefined) body.content = params.content + const body: Record = {} + /** + * Cloudflare rejects an empty type, name, or content, so a blank field + * means "leave this alone" rather than "clear it" — the block already + * strips those, and this guard makes a direct tool call behave the same. + * `comment` is deliberately not guarded: an empty comment is how the API + * clears a stored comment, which is a real thing a caller asks for. + */ + if (params.type !== undefined && params.type !== '') body.type = params.type + if (params.name !== undefined && params.name !== '') body.name = params.name + if (params.content !== undefined && params.content !== '') body.content = params.content if (params.ttl !== undefined) body.ttl = Number(params.ttl) if (params.proxied !== undefined) body.proxied = params.proxied if (params.priority !== undefined) body.priority = Number(params.priority) diff --git a/apps/sim/tools/cloudflare/update_rate_limit_rule.ts b/apps/sim/tools/cloudflare/update_rate_limit_rule.ts new file mode 100644 index 00000000000..495cc5f3804 --- /dev/null +++ b/apps/sim/tools/cloudflare/update_rate_limit_rule.ts @@ -0,0 +1,216 @@ +import type { + CloudflareRulesetResponse, + CloudflareUpdateRateLimitRuleParams, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, + parseCsvParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateRateLimitRuleTool: ToolConfig< + CloudflareUpdateRateLimitRuleParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_update_rate_limit_rule', + name: 'Cloudflare Update Rate Limiting Rule', + description: + 'Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of a zone, using the current Rulesets-based rate limiting API. Cloudflare replaces the rule definition rather than merging it, so send the complete rule — every field you omit is reset. Run "List Rate Limiting Rules" first to read the current definition and get the ruleset ID. Requires an API token with Zone WAF Edit.', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID that owns the rule', + }, + rulesetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules"', + }, + ruleId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The rate limiting rule ID to update', + }, + expression: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Cloudflare filter expression selecting the requests the rule applies to', + }, + characteristics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id', + }, + period: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600', + }, + requestsPerPeriod: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Number of requests allowed within the counting period before the action fires', + }, + action: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Action applied once the limit is exceeded: block, managed_challenge, js_challenge, challenge, or log. Required because this endpoint replaces the rule rather than merging into it — a defaulted action would silently convert an existing log or challenge rule into a hard block', + }, + mitigationTimeout: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Seconds the action stays applied. Cloudflare accepts only 0, 10, 60, 120, 300, 600, 3600, or 86400', + }, + counting_expression: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional expression defining which requests are counted', + }, + requestsToOrigin: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'When true, only requests that reach the origin are counted', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Human-readable description of the rule', + }, + enabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the rule is enabled', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/${params.rulesetId.trim()}/rules/${params.ruleId.trim()}`, + method: 'PATCH', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const characteristics = parseCsvParam(params.characteristics) + if (!characteristics) { + throw new Error('Characteristics must list at least one counting characteristic') + } + + const ratelimit: Record = { + characteristics, + period: params.period, + requests_per_period: params.requestsPerPeriod, + } + if (params.mitigationTimeout !== undefined) { + ratelimit.mitigation_timeout = params.mitigationTimeout + } + if (params.counting_expression) ratelimit.counting_expression = params.counting_expression + if (params.requestsToOrigin !== undefined) { + ratelimit.requests_to_origin = params.requestsToOrigin + } + + const body: Record = { + action: params.action, + expression: params.expression, + ratelimit, + } + if (params.description !== undefined) body.description = params.description + if (params.enabled !== undefined) body.enabled = params.enabled + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to update rate limiting rule'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Ruleset ID of the http_ratelimit entry point' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind' }, + phase: { type: 'string', description: 'Phase the ruleset runs in (http_ratelimit)' }, + version: { type: 'string', description: 'Ruleset version after the change', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rate limiting rules after the change, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { type: 'string', description: 'Action applied once the limit is exceeded' }, + action_parameters: { + type: 'json', + description: 'Action-specific parameters', + optional: true, + }, + expression: { type: 'string', description: 'Filter expression' }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { type: 'string', description: 'Rule reference tag', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { + type: 'json', + description: 'Rate limiting configuration applied to the rule', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/update_ruleset_rule.ts b/apps/sim/tools/cloudflare/update_ruleset_rule.ts new file mode 100644 index 00000000000..918dd7e557c --- /dev/null +++ b/apps/sim/tools/cloudflare/update_ruleset_rule.ts @@ -0,0 +1,205 @@ +import type { + CloudflareRulesetResponse, + CloudflareUpdateRulesetRuleParams, +} from '@/tools/cloudflare/types' +import { + cloudflareErrorMessage, + cloudflareHeaders, + emptyRuleset, + mapRuleset, + parseJsonObjectParam, +} from '@/tools/cloudflare/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateRulesetRuleTool: ToolConfig< + CloudflareUpdateRulesetRuleParams, + CloudflareRulesetResponse +> = { + id: 'cloudflare_update_ruleset_rule', + name: 'Cloudflare Update Ruleset Rule', + description: + 'Updates a rule in a zone ruleset. Cloudflare replaces the rule definition rather than merging it, so you must send every field you want the rule to keep — any field you omit is reset to its default. Read the current rule with "Get Ruleset" first. Requires an API token with Zone WAF Edit (or another matching ruleset Write permission).', + version: '1.0.0', + + params: { + zoneId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The zone ID that owns the ruleset', + }, + rulesetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ruleset ID containing the rule', + }, + ruleId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The rule ID to update', + }, + action: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The action the rule performs, e.g. block, challenge, js_challenge, managed_challenge, log, skip, or execute. Required because this endpoint replaces the rule definition — omitting it resets the stored action', + }, + expression: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Cloudflare filter expression selecting matching requests. Required because this endpoint replaces the rule definition — omitting it resets the stored expression', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Human-readable description of the rule', + }, + enabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the rule is enabled', + }, + ref: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Reference tag that stays stable across rule updates. Because the update replaces the rule, omitting it resets the tag to the rule ID and breaks anything matching on the old value', + }, + actionParameters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of action-specific parameters, e.g. {"id":"","overrides":{"rules":[{"id":"","action":"log","enabled":true,"score_threshold":40}]}}. Required on an execute rule and must be sent on every update: the endpoint replaces the rule, so omitting it resets action_parameters to {} — which unbinds the managed ruleset the rule deploys and every override under it', + }, + ratelimit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON rate limiting configuration to preserve on a rule in the http_ratelimit phase, e.g. {"characteristics":["cf.colo.id","ip.src"],"period":60,"requests_per_period":100}. Because the update replaces the rule, omitting this on a rate limiting rule stops it rate limiting', + }, + logging: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON logging configuration to preserve, e.g. {"enabled":true}. Omitting it on a rule that had logging configured resets it to the default', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cloudflare API Token', + }, + }, + + request: { + url: (params) => + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/rulesets/${params.rulesetId.trim()}/rules/${params.ruleId.trim()}`, + method: 'PATCH', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const body: Record = { + action: params.action, + expression: params.expression, + } + if (params.description !== undefined) body.description = params.description + if (params.enabled !== undefined) body.enabled = params.enabled + if (params.ref) body.ref = params.ref + + const actionParameters = parseJsonObjectParam(params.actionParameters, 'Action Parameters') + /** + * PATCH replaces the rule, so action_parameters that is omitted falls back + * to the schema default of {}. On an execute rule that drops the managed + * ruleset ID, unbinding the WAF managed ruleset and every override under + * it. An explicit `{}` is the same payload by a different route and does + * the same damage, so emptiness is what is checked rather than presence. + * Refuse rather than silently tear the rule down. + */ + if (params.action === 'execute' && Object.keys(actionParameters ?? {}).length === 0) { + throw new Error( + 'Action Parameters is required when the action is "execute". This endpoint replaces the rule, so sending it empty or omitting it would reset action_parameters and unbind the managed ruleset. Read the rule with "Get Ruleset" and resend its action_parameters.' + ) + } + if (actionParameters) body.action_parameters = actionParameters + + const ratelimit = parseJsonObjectParam(params.ratelimit, 'Rate Limiting Configuration') + if (ratelimit) body.ratelimit = ratelimit + + const logging = parseJsonObjectParam(params.logging, 'Logging Configuration') + if (logging) body.logging = logging + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: emptyRuleset(), + error: cloudflareErrorMessage(data, 'Failed to update ruleset rule'), + } + } + + return { success: true, output: mapRuleset(data.result) } + }, + + outputs: { + id: { type: 'string', description: 'Ruleset identifier' }, + name: { type: 'string', description: 'Ruleset name' }, + description: { type: 'string', description: 'Ruleset description' }, + kind: { type: 'string', description: 'Ruleset kind (managed, custom, root, or zone)' }, + phase: { type: 'string', description: 'Phase the ruleset runs in' }, + version: { type: 'string', description: 'Ruleset version after the change', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + rules: { + type: 'array', + description: 'Rules in the ruleset after the change, in evaluation order', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule identifier' }, + version: { type: 'string', description: 'Rule version', optional: true }, + action: { type: 'string', description: 'Action the rule performs' }, + action_parameters: { + type: 'json', + description: 'Action-specific parameters', + optional: true, + }, + expression: { type: 'string', description: 'Filter expression' }, + description: { type: 'string', description: 'Rule description' }, + enabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + ref: { type: 'string', description: 'Rule reference tag', optional: true }, + last_updated: { + type: 'string', + description: 'RFC 3339 timestamp of the last change', + optional: true, + }, + categories: { + type: 'array', + description: 'Managed-rule categories', + items: { type: 'string', description: 'Category tag' }, + }, + logging: { type: 'json', description: 'Logging configuration', optional: true }, + ratelimit: { type: 'json', description: 'Rate limiting configuration', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudflare/update_zone_setting.ts b/apps/sim/tools/cloudflare/update_zone_setting.ts index 60fddb11c68..daac2b6c724 100644 --- a/apps/sim/tools/cloudflare/update_zone_setting.ts +++ b/apps/sim/tools/cloudflare/update_zone_setting.ts @@ -32,8 +32,7 @@ export const updateZoneSettingTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: - 'New value for the setting as a string or JSON string for complex values (e.g., "full" for SSL, "medium" for security_level, "aggressive" for cache_level, \'["ECDHE-RSA-AES128-GCM-SHA256"]\' for ciphers)', + description: `New value for the setting as a string, or a JSON string for complex values (e.g., "full" for SSL, "medium" for security_level, "aggressive" for cache_level, ["ECDHE-RSA-AES128-GCM-SHA256"] for ciphers)`, }, apiKey: { type: 'string', @@ -45,7 +44,7 @@ export const updateZoneSettingTool: ToolConfig< request: { url: (params) => - `https://api.cloudflare.com/client/v4/zones/${params.zoneId}/settings/${params.settingId}`, + `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/settings/${params.settingId.trim()}`, method: 'PATCH', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/cloudflare/utils.ts b/apps/sim/tools/cloudflare/utils.ts new file mode 100644 index 00000000000..e9b5678311d --- /dev/null +++ b/apps/sim/tools/cloudflare/utils.ts @@ -0,0 +1,223 @@ +/** + * Shared request/response helpers for the Cloudflare API tools. + * + * Every Cloudflare v4 endpoint answers with the same envelope: + * `{ success, errors, messages, result }`. A `200 OK` carrying + * `success: false` is a failure, so callers must always branch on + * `data.success` rather than on the HTTP status. + */ + +import type { + CloudflareEnvelope, + CloudflareRawAccessApplication, + CloudflareRawAccessPolicy, + CloudflareRawRuleset, +} from '@/tools/cloudflare/types' + +/** + * Reads a Cloudflare v4 response body into the shared envelope shape, so the + * caller branches on a typed `success` flag and reads `result` through a checked + * payload type instead of an implicitly-`any` `response.json()`. Used by the + * tools whose `result` has a payload type in `types.ts`. + */ +export async function readCloudflareResponse( + response: Response +): Promise> { + return (await response.json()) as CloudflareEnvelope +} + +/** Standard bearer-token headers for the Cloudflare v4 API. */ +export function cloudflareHeaders(apiKey: string): Record { + return { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + } +} + +/** Extracts the first error message from a Cloudflare envelope. */ +export function cloudflareErrorMessage(data: CloudflareEnvelope, fallback: string): string { + const message = data.errors?.[0]?.message + return typeof message === 'string' && message.length > 0 ? message : fallback +} + +/** + * Parses a param that may arrive either as a JSON string (typed into a block + * field) or as an already-structured value (piped from an upstream block). + */ +export function parseJsonParam(value: unknown, fieldName: string): unknown { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + throw new Error(`${fieldName} must be valid JSON`) + } +} + +/** Parses a param that must resolve to a JSON array. */ +export function parseJsonArrayParam(value: unknown, fieldName: string): unknown[] | undefined { + const parsed = parseJsonParam(value, fieldName) + if (parsed === undefined) return undefined + if (!Array.isArray(parsed)) throw new Error(`${fieldName} must be a JSON array`) + return parsed +} + +/** Parses a param that must resolve to a JSON object. */ +export function parseJsonObjectParam( + value: unknown, + fieldName: string +): Record | undefined { + const parsed = parseJsonParam(value, fieldName) + if (parsed === undefined) return undefined + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${fieldName} must be a JSON object`) + } + return parsed as Record +} + +/** Splits a comma-separated string param into a trimmed, non-empty list. */ +export function parseCsvParam(value: unknown): string[] | undefined { + if (typeof value !== 'string' || value.trim() === '') return undefined + const items = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + return items.length > 0 ? items : undefined +} + +/** + * Maps a Cloudflare ruleset `result` payload onto the flat ruleset output shape + * shared by the get-ruleset, phase-entrypoint, and rule mutation tools — every + * one of those endpoints answers with the full ruleset object. + */ +export function mapRuleset(ruleset: CloudflareRawRuleset | undefined) { + return { + id: ruleset?.id ?? '', + name: ruleset?.name ?? '', + description: ruleset?.description ?? '', + kind: ruleset?.kind ?? '', + phase: ruleset?.phase ?? '', + version: ruleset?.version ?? null, + last_updated: ruleset?.last_updated ?? null, + rules: Array.isArray(ruleset?.rules) + ? ruleset.rules.map((rule) => ({ + id: rule.id ?? '', + version: rule.version ?? null, + action: rule.action ?? '', + action_parameters: rule.action_parameters ?? null, + expression: rule.expression ?? '', + description: rule.description ?? '', + enabled: rule.enabled ?? false, + ref: rule.ref ?? null, + last_updated: rule.last_updated ?? null, + categories: rule.categories ?? [], + logging: rule.logging ?? null, + ratelimit: rule.ratelimit ?? null, + })) + : [], + } +} + +/** The empty ruleset payload returned alongside an error. */ +export function emptyRuleset() { + return { + id: '', + name: '', + description: '', + kind: '', + phase: '', + version: null, + last_updated: null, + rules: [], + } +} + +/** Maps a Cloudflare Access application `result` payload onto the flat output shape. */ +export function mapAccessApplication(app: CloudflareRawAccessApplication | undefined) { + return { + id: app?.id ?? '', + name: app?.name ?? null, + domain: app?.domain ?? null, + type: app?.type ?? null, + aud: app?.aud ?? null, + session_duration: app?.session_duration ?? null, + allowed_idps: app?.allowed_idps ?? null, + app_launcher_visible: app?.app_launcher_visible ?? null, + auto_redirect_to_identity: app?.auto_redirect_to_identity ?? null, + custom_deny_message: app?.custom_deny_message ?? null, + custom_deny_url: app?.custom_deny_url ?? null, + logo_url: app?.logo_url ?? null, + self_hosted_domains: app?.self_hosted_domains ?? null, + destinations: app?.destinations ?? null, + tags: app?.tags ?? null, + policies: app?.policies ?? null, + } +} + +/** The empty Access application payload returned alongside an error. */ +export function emptyAccessApplication() { + return { + id: '', + name: null, + domain: null, + type: null, + aud: null, + session_duration: null, + allowed_idps: null, + app_launcher_visible: null, + auto_redirect_to_identity: null, + custom_deny_message: null, + custom_deny_url: null, + logo_url: null, + self_hosted_domains: null, + destinations: null, + tags: null, + policies: null, + } +} + +/** Maps a Cloudflare Access policy `result` payload onto the flat output shape. */ +export function mapAccessPolicy(policy: CloudflareRawAccessPolicy | undefined) { + return { + id: policy?.id ?? '', + name: policy?.name ?? null, + decision: policy?.decision ?? null, + precedence: policy?.precedence ?? null, + include: policy?.include ?? null, + exclude: policy?.exclude ?? null, + require: policy?.require ?? null, + session_duration: policy?.session_duration ?? null, + approval_required: policy?.approval_required ?? null, + isolation_required: policy?.isolation_required ?? null, + purpose_justification_required: policy?.purpose_justification_required ?? null, + purpose_justification_prompt: policy?.purpose_justification_prompt ?? null, + created_at: policy?.created_at ?? null, + updated_at: policy?.updated_at ?? null, + } +} + +/** The empty Access policy payload returned alongside an error. */ +export function emptyAccessPolicy() { + return { + id: '', + name: null, + decision: null, + precedence: null, + include: null, + exclude: null, + require: null, + session_duration: null, + approval_required: null, + isolation_required: null, + purpose_justification_required: null, + purpose_justification_prompt: null, + created_at: null, + updated_at: null, + } +} + +/** Appends a query param when the value is present and non-empty. */ +export function appendParam(url: URL, key: string, value: unknown): void { + if (value === undefined || value === null || value === '') return + url.searchParams.append(key, String(value)) +} diff --git a/apps/sim/tools/crowdstrike/create_indicators.ts b/apps/sim/tools/crowdstrike/create_indicators.ts new file mode 100644 index 00000000000..74d4a58e9f6 --- /dev/null +++ b/apps/sim/tools/crowdstrike/create_indicators.ts @@ -0,0 +1,226 @@ +import type { + CrowdStrikeCreateIndicatorsParams, + CrowdStrikeCreateIndicatorsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeCreateIndicatorsTool: ToolConfig< + CrowdStrikeCreateIndicatorsParams, + CrowdStrikeCreateIndicatorsResponse +> = { + id: 'crowdstrike_create_indicators', + name: 'CrowdStrike Create Indicators', + description: + 'Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/indicators/v1). Each indicator can allow, detect, or block activity across the fleet, so a wrong value can suppress detections or break legitimate software. Requires the "IOC Management: Write" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + indicators: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of indicators to create. Each entry requires type, value, and applied_globally (boolean). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent_no_ui, prevent, detect; severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups (array), description, source, tags (array), expiration (ISO 8601), mobile_action, metadata ({ filename }). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list.', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Audit comment explaining why these indicators were created', + }, + retrodetects: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to generate retroactive detections for the new indicators', + }, + ignoreWarnings: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to create the indicators even when CrowdStrike returns warnings', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + comment: params.comment, + ignoreWarnings: params.ignoreWarnings, + indicators: params.indicators, + operation: 'crowdstrike_create_indicators', + retrodetects: params.retrodetects, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to create CrowdStrike indicators') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + indicators: { + type: 'array', + description: 'Created CrowdStrike indicator records', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Indicator identifier', optional: true }, + type: { type: 'string', description: 'Indicator type', optional: true }, + value: { type: 'string', description: 'Indicator value', optional: true }, + action: { + type: 'string', + description: 'Action taken when the indicator matches', + optional: true, + }, + mobileAction: { + type: 'string', + description: 'Action taken on mobile platforms when the indicator matches', + optional: true, + }, + severity: { type: 'string', description: 'Indicator severity', optional: true }, + description: { type: 'string', description: 'Indicator description', optional: true }, + source: { type: 'string', description: 'Indicator source', optional: true }, + appliedGlobally: { + type: 'boolean', + description: 'Whether the indicator applies to all hosts', + optional: true, + }, + platforms: { + type: 'array', + description: 'Platforms the indicator applies to', + optional: true, + items: { type: 'string' }, + }, + hostGroups: { + type: 'array', + description: 'Host group IDs the indicator is scoped to', + optional: true, + items: { type: 'string' }, + }, + tags: { + type: 'array', + description: 'Tags applied to the indicator', + optional: true, + items: { type: 'string' }, + }, + expiration: { + type: 'string', + description: 'Indicator expiration timestamp', + optional: true, + }, + expired: { + type: 'boolean', + description: 'Whether the indicator has expired', + optional: true, + }, + deleted: { + type: 'boolean', + description: 'Whether the indicator is deleted', + optional: true, + }, + fromParent: { + type: 'boolean', + description: 'Whether the indicator was inherited from a parent CID', + optional: true, + }, + parentCidName: { type: 'string', description: 'Parent CID name', optional: true }, + createdBy: { + type: 'string', + description: 'User who created the indicator', + optional: true, + }, + createdOn: { + type: 'string', + description: 'Indicator creation timestamp', + optional: true, + }, + modifiedBy: { + type: 'string', + description: 'User who last modified the indicator', + optional: true, + }, + modifiedOn: { + type: 'string', + description: 'Indicator modification timestamp', + optional: true, + }, + metadata: { + type: 'json', + description: 'File metadata CrowdStrike resolved for the indicator', + optional: true, + properties: { + avHits: { type: 'number', description: 'Antivirus hit count', optional: true }, + companyName: { type: 'string', description: 'Company name', optional: true }, + fileDescription: { type: 'string', description: 'File description', optional: true }, + fileVersion: { type: 'string', description: 'File version', optional: true }, + filename: { type: 'string', description: 'File name', optional: true }, + originalFilename: { + type: 'string', + description: 'Original file name', + optional: true, + }, + productName: { type: 'string', description: 'Product name', optional: true }, + productVersion: { type: 'string', description: 'Product version', optional: true }, + signed: { + type: 'boolean', + description: 'Whether the file is signed', + optional: true, + }, + }, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of indicators created', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/crowdstrike.test.ts b/apps/sim/tools/crowdstrike/crowdstrike.test.ts new file mode 100644 index 00000000000..de133bed54c --- /dev/null +++ b/apps/sim/tools/crowdstrike/crowdstrike.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { crowdstrikeQueryBodySchema } from '@/lib/api/contracts/tools/crowdstrike' +import { CrowdStrikeBlock } from '@/blocks/blocks/crowdstrike' +import { crowdstrikeExecuteRtrCommandTool } from '@/tools/crowdstrike/execute_rtr_command' +import { crowdstrikeGetSensorAggregatesTool } from '@/tools/crowdstrike/get_sensor_aggregates' +import { crowdstrikeGetSensorDetailsTool } from '@/tools/crowdstrike/get_sensor_details' +import { crowdstrikeQueryAlertsTool } from '@/tools/crowdstrike/query_alerts' +import { crowdstrikeQuerySensorsTool } from '@/tools/crowdstrike/query_sensors' +import type { + CrowdStrikeGetSensorAggregatesResponse, + CrowdStrikeGetSensorDetailsResponse, + CrowdStrikeQuerySensorsResponse, +} from '@/tools/crowdstrike/types' +import { crowdstrikeUpdateIndicatorsTool } from '@/tools/crowdstrike/update_indicators' + +const credentials = { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1' as const, +} + +function issueMessages(result: ReturnType) { + return result.success ? [] : result.error.issues.map((issue) => issue.message) +} + +describe('CrowdStrike indicator batch cap', () => { + const oversizedIndicators = { + crowdstrike_create_indicators: Array.from({ length: 201 }, (_, index) => ({ + type: 'sha256', + value: `value-${index}`, + applied_globally: true, + })), + crowdstrike_update_indicators: Array.from({ length: 201 }, (_, index) => ({ + id: `ioc-${index}`, + action: 'prevent', + })), + } as const + + it('attributes the 200-indicator cap to Sim rather than to CrowdStrike', () => { + for (const operation of [ + 'crowdstrike_create_indicators', + 'crowdstrike_update_indicators', + ] as const) { + const messages = issueMessages( + crowdstrikeQueryBodySchema.safeParse({ + ...credentials, + operation, + indicators: oversizedIndicators[operation], + }) + ) + + expect(messages).toContain( + 'Sim caps this request at 200 indicators; CrowdStrike publishes no limit for this endpoint' + ) + expect(messages.join('\n')).not.toMatch(/CrowdStrike accepts at most 200 indicators/) + } + }) +}) + +describe('CrowdStrike update-indicators guidance', () => { + it('states the omitted-field hazard as observed rather than as documented behavior', () => { + const { description } = crowdstrikeUpdateIndicatorsTool + expect(description).not.toMatch(/blanks out any field you omit/) + expect(description).toMatch(/omitted fields may be cleared/) + expect(description).toMatch(/resend its full field set/) + + const indicatorsParam = crowdstrikeUpdateIndicatorsTool.params.indicators + expect(indicatorsParam.description).not.toMatch(/blanks out any updatable field/) + expect(indicatorsParam.description).toMatch(/may be cleared/) + }) +}) + +describe('CrowdStrike alerts API lineage', () => { + it('calls the Detects API decommissioned, not deprecated', () => { + expect(crowdstrikeQueryAlertsTool.description).not.toMatch(/deprecated Detects API/) + expect(crowdstrikeQueryAlertsTool.description).toMatch(/decommissioned on September 30, 2025/) + }) +}) + +describe('CrowdStrike sensor outputs', () => { + it('declares pagination on every sensor-listing tool', () => { + expect(crowdstrikeQuerySensorsTool.outputs.pagination).toBeDefined() + expect(crowdstrikeGetSensorDetailsTool.outputs.pagination).toBeDefined() + }) + + it('declares errors on every sensor tool', () => { + expect(crowdstrikeQuerySensorsTool.outputs.errors).toBeDefined() + expect(crowdstrikeGetSensorDetailsTool.outputs.errors).toBeDefined() + expect(crowdstrikeGetSensorAggregatesTool.outputs.errors).toBeDefined() + }) + + /** + * The route emits `errors` on all three sensor envelopes and the contract requires + * it, so the response interfaces must carry it too. Interfaces are erased at + * runtime, so these literals are the check: dropping `errors` from any of the + * three makes them a compile error (TS2353) in the editor and in any `tsc` run + * that includes test files. `apps/sim/tsconfig.json` excludes test files, so + * `bun run type-check` alone will not catch it. + */ + it('types errors on every sensor response interface', () => { + const querySensors: CrowdStrikeQuerySensorsResponse['output'] = { + count: 0, + errors: [], + pagination: null, + sensors: [], + } + const sensorDetails: CrowdStrikeGetSensorDetailsResponse['output'] = { + count: 0, + errors: [], + pagination: null, + sensors: [], + } + const sensorAggregates: CrowdStrikeGetSensorAggregatesResponse['output'] = { + aggregates: [], + count: 0, + errors: [], + } + + expect(querySensors.errors).toEqual([]) + expect(sensorDetails.errors).toEqual([]) + expect(sensorAggregates.errors).toEqual([]) + }) +}) + +describe('CrowdStrike RTR read-only base commands', () => { + function parseBaseCommand(baseCommand: string) { + return crowdstrikeQueryBodySchema.safeParse({ + ...credentials, + operation: 'crowdstrike_execute_rtr_command', + sessionId: 'session-1', + baseCommand, + commandString: `${baseCommand} `, + }) + } + + it('accepts the non-Windows network and session triage commands', () => { + expect(parseBaseCommand('ifconfig').success).toBe(true) + expect(parseBaseCommand('users').success).toBe(true) + }) + + it('keeps the read-tier commands PSFalcon and Caracara document', () => { + for (const baseCommand of ['csrutil', 'reg', 'eventlog']) { + expect(parseBaseCommand(baseCommand).success).toBe(true) + } + }) + + it('still rejects a write-tier base command', () => { + expect(parseBaseCommand('rm').success).toBe(false) + }) + + it('documents that only reg query is read-tier', () => { + expect(crowdstrikeExecuteRtrCommandTool.params.baseCommand.description).toMatch( + /only reg query is read-tier/ + ) + }) + + it('offers ifconfig and users in the block dropdown', () => { + const baseCommandBlock = CrowdStrikeBlock.subBlocks.find((block) => block.id === 'baseCommand') + const optionIds = (baseCommandBlock?.options as { id: string }[] | undefined)?.map( + (option) => option.id + ) + expect(optionIds).toContain('ifconfig') + expect(optionIds).toContain('users') + }) +}) + +describe('CrowdStrike sort placeholders', () => { + it('shows the dot form for the collections that document it and the pipe form elsewhere', () => { + const sortBlocks = CrowdStrikeBlock.subBlocks.filter((block) => block.id === 'sort') + expect(sortBlocks).toHaveLength(2) + + for (const operation of ['crowdstrike_query_host_groups', 'crowdstrike_query_sensors']) { + const match = sortBlocks.find((block) => + (block.condition as { value: string[] }).value.includes(operation) + ) + expect(match?.placeholder).toBe('name.asc') + } + + for (const operation of [ + 'crowdstrike_query_alerts', + 'crowdstrike_query_indicators', + 'crowdstrike_query_vulnerabilities', + 'crowdstrike_query_cases', + ]) { + const match = sortBlocks.find((block) => + (block.condition as { value: string[] }).value.includes(operation) + ) + expect(match?.placeholder).toBe('created_timestamp|desc') + } + }) +}) diff --git a/apps/sim/tools/crowdstrike/delete_indicators.ts b/apps/sim/tools/crowdstrike/delete_indicators.ts new file mode 100644 index 00000000000..c7773f75151 --- /dev/null +++ b/apps/sim/tools/crowdstrike/delete_indicators.ts @@ -0,0 +1,111 @@ +import type { + CrowdStrikeDeleteIndicatorsParams, + CrowdStrikeDeleteIndicatorsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeDeleteIndicatorsTool: ToolConfig< + CrowdStrikeDeleteIndicatorsParams, + CrowdStrikeDeleteIndicatorsResponse +> = { + id: 'crowdstrike_delete_indicators', + name: 'CrowdStrike Delete Indicators', + description: + 'Permanently delete custom CrowdStrike Falcon indicators of compromise (DELETE /iocs/entities/indicators/v1). Cannot be undone; deleting a blocking indicator removes that protection from every host, and a broad filter can delete far more than intended. Supply an ID list or a filter, never both -- CrowdStrike lets a filter silently override the IDs, so this tool rejects that instead. Requires the "IOC Management: Write" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + indicatorIds: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike IOC IDs to delete. Cannot be combined with a filter.', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Falcon Query Language filter selecting indicators to delete in bulk. Cannot be combined with an ID list.', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Audit comment explaining why these indicators were deleted', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + comment: params.comment, + filter: params.filter, + indicatorIds: params.indicatorIds, + operation: 'crowdstrike_delete_indicators', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to delete CrowdStrike indicators') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + deletedIds: { + type: 'array', + description: 'IOC IDs CrowdStrike deleted', + items: { type: 'string' }, + }, + count: { + type: 'number', + description: 'Number of indicators deleted', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/delete_rtr_session.ts b/apps/sim/tools/crowdstrike/delete_rtr_session.ts new file mode 100644 index 00000000000..23ebdaa9fb9 --- /dev/null +++ b/apps/sim/tools/crowdstrike/delete_rtr_session.ts @@ -0,0 +1,89 @@ +import type { + CrowdStrikeDeleteRtrSessionParams, + CrowdStrikeDeleteRtrSessionResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeDeleteRtrSessionTool: ToolConfig< + CrowdStrikeDeleteRtrSessionParams, + CrowdStrikeDeleteRtrSessionResponse +> = { + id: 'crowdstrike_delete_rtr_session', + name: 'CrowdStrike Delete RTR Session', + description: + 'Close an open CrowdStrike Falcon Real Time Response session (DELETE /real-time-response/entities/sessions/v1). Requires the "Real time response: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + sessionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'RTR session ID to close', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + operation: 'crowdstrike_delete_rtr_session', + sessionId: params.sessionId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to delete CrowdStrike RTR session') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + sessionId: { type: 'string', description: 'RTR session ID that was closed' }, + deleted: { type: 'boolean', description: 'Whether CrowdStrike accepted the session deletion' }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/execute_rtr_command.ts b/apps/sim/tools/crowdstrike/execute_rtr_command.ts new file mode 100644 index 00000000000..45ce5745aa0 --- /dev/null +++ b/apps/sim/tools/crowdstrike/execute_rtr_command.ts @@ -0,0 +1,114 @@ +import type { + CrowdStrikeExecuteRtrCommandParams, + CrowdStrikeExecuteRtrCommandResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeExecuteRtrCommandTool: ToolConfig< + CrowdStrikeExecuteRtrCommandParams, + CrowdStrikeExecuteRtrCommandResponse +> = { + id: 'crowdstrike_execute_rtr_command', + name: 'CrowdStrike Execute RTR Command', + description: + 'Run a read-only Real Time Response command in an open CrowdStrike Falcon session (POST /real-time-response/entities/command/v1). baseCommand names the family only (cat, cd, clear, csrutil, env, eventlog, filehash, getsid, help, history, ifconfig, ipconfig, ls, mount, netstat, ps, reg, users); subcommands go in commandString. Host-modifying commands need the Active Responder or Admin endpoints. Requires the "Real time response: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + sessionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'RTR session ID returned by Init RTR Session', + }, + baseCommand: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Read-only RTR base command family, one of: cat, cd, clear, csrutil, env, eventlog, filehash, getsid, help, history, ifconfig, ipconfig, ls, mount, netstat, ps, reg, users. Subcommands belong in commandString, not here — and only reg query is read-tier, since reg set and reg delete are Active Responder commands.', + }, + commandString: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Full command line to run, such as "ls C:\\Windows" or "reg query HKLM\\Software"', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + baseCommand: params.baseCommand, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + commandString: params.commandString, + operation: 'crowdstrike_execute_rtr_command', + sessionId: params.sessionId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to execute CrowdStrike RTR command') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + cloudRequestId: { + type: 'string', + description: 'Cloud request ID to poll for command output', + optional: true, + }, + sessionId: { type: 'string', description: 'RTR session the command ran in', optional: true }, + queuedCommandOffline: { + type: 'boolean', + description: 'Whether the command was queued for an offline host', + optional: true, + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_alert_details.ts b/apps/sim/tools/crowdstrike/get_alert_details.ts new file mode 100644 index 00000000000..1b7ee1ced28 --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_alert_details.ts @@ -0,0 +1,253 @@ +import type { + CrowdStrikeGetAlertDetailsParams, + CrowdStrikeGetAlertDetailsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetAlertDetailsTool: ToolConfig< + CrowdStrikeGetAlertDetailsParams, + CrowdStrikeGetAlertDetailsResponse +> = { + id: 'crowdstrike_get_alert_details', + name: 'CrowdStrike Get Alert Details', + description: + 'Get full CrowdStrike Falcon alert records for one or more composite alert IDs (POST /alerts/entities/alerts/v2). Requires the "Alerts: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + compositeIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike composite alert IDs', + }, + includeHidden: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include previously hidden alerts (CrowdStrike defaults this to true)', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + compositeIds: params.compositeIds, + includeHidden: params.includeHidden, + operation: 'crowdstrike_get_alert_details', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike alert details') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + alerts: { + type: 'array', + description: 'CrowdStrike alert records', + items: { + type: 'object', + properties: { + compositeId: { type: 'string', description: 'Composite alert ID', optional: true }, + id: { type: 'string', description: 'Alert ID', optional: true }, + cid: { type: 'string', description: 'CrowdStrike customer identifier', optional: true }, + aggregateId: { type: 'string', description: 'Aggregate identifier', optional: true }, + agentId: { type: 'string', description: 'Agent (sensor) identifier', optional: true }, + deviceId: { + type: 'string', + description: 'Device identifier from the alert device', + optional: true, + }, + hostname: { + type: 'string', + description: 'Hostname from the alert device', + optional: true, + }, + name: { type: 'string', description: 'Alert name', optional: true }, + displayName: { type: 'string', description: 'Alert display name', optional: true }, + description: { type: 'string', description: 'Alert description', optional: true }, + type: { type: 'string', description: 'Alert type', optional: true }, + product: { + type: 'string', + description: 'Falcon product that raised the alert', + optional: true, + }, + platform: { + type: 'string', + description: 'Platform the alert was raised on', + optional: true, + }, + severity: { type: 'number', description: 'Numeric severity', optional: true }, + severityName: { type: 'string', description: 'Severity name', optional: true }, + confidence: { type: 'number', description: 'Confidence score', optional: true }, + status: { type: 'string', description: 'Alert status', optional: true }, + assignedToName: { type: 'string', description: 'Assignee display name', optional: true }, + assignedToUid: { type: 'string', description: 'Assignee user ID', optional: true }, + assignedToUuid: { type: 'string', description: 'Assignee user UUID', optional: true }, + tactic: { type: 'string', description: 'MITRE ATT&CK tactic', optional: true }, + tacticId: { type: 'string', description: 'MITRE ATT&CK tactic ID', optional: true }, + technique: { type: 'string', description: 'MITRE ATT&CK technique', optional: true }, + techniqueId: { type: 'string', description: 'MITRE ATT&CK technique ID', optional: true }, + scenario: { type: 'string', description: 'Alert scenario', optional: true }, + objective: { type: 'string', description: 'Adversary objective', optional: true }, + resolution: { type: 'string', description: 'Alert resolution', optional: true }, + showInUi: { + type: 'boolean', + description: 'Whether the alert is shown in Falcon', + optional: true, + }, + tags: { + type: 'array', + description: 'Tags applied to the alert', + optional: true, + items: { type: 'string' }, + }, + filename: { type: 'string', description: 'Triggering file name', optional: true }, + filepath: { type: 'string', description: 'Triggering file path', optional: true }, + cmdline: { type: 'string', description: 'Triggering command line', optional: true }, + sha256: { type: 'string', description: 'SHA256 of the triggering file', optional: true }, + sha1: { type: 'string', description: 'SHA1 of the triggering file', optional: true }, + md5: { type: 'string', description: 'MD5 of the triggering file', optional: true }, + userName: { + type: 'string', + description: 'User name associated with the alert', + optional: true, + }, + userId: { + type: 'string', + description: 'User ID associated with the alert', + optional: true, + }, + patternId: { type: 'number', description: 'Detection pattern ID', optional: true }, + falconHostLink: { + type: 'string', + description: 'Deep link into the Falcon console', + optional: true, + }, + controlGraphId: { + type: 'string', + description: 'Control graph identifier', + optional: true, + }, + external: { + type: 'boolean', + description: 'Whether the alert is external', + optional: true, + }, + emailSent: { + type: 'boolean', + description: 'Whether a notification email was sent', + optional: true, + }, + isAggregated: { + type: 'boolean', + description: 'Whether the alert is aggregated', + optional: true, + }, + isFalconPlatformIoa: { + type: 'boolean', + description: 'Whether the alert is a Falcon platform IOA', + optional: true, + }, + dataDomains: { + type: 'array', + description: 'Data domains the alert belongs to', + optional: true, + items: { type: 'string' }, + }, + iocValues: { + type: 'array', + description: 'Indicator values associated with the alert', + optional: true, + items: { type: 'string' }, + }, + linkedCaseIds: { + type: 'array', + description: 'Case IDs linked to the alert', + optional: true, + items: { type: 'string' }, + }, + linkedBehavioralDetections: { + type: 'array', + description: 'Behavioral detection IDs linked to the alert', + optional: true, + items: { type: 'string' }, + }, + timestamp: { type: 'string', description: 'Alert timestamp', optional: true }, + createdTimestamp: { + type: 'string', + description: 'Alert creation timestamp', + optional: true, + }, + updatedTimestamp: { + type: 'string', + description: 'Alert update timestamp', + optional: true, + }, + crawledTimestamp: { + type: 'string', + description: 'Alert crawl timestamp', + optional: true, + }, + contextTimestamp: { + type: 'string', + description: 'Alert context timestamp', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of alerts returned', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_case_details.ts b/apps/sim/tools/crowdstrike/get_case_details.ts new file mode 100644 index 00000000000..9df58b69e44 --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_case_details.ts @@ -0,0 +1,187 @@ +import type { + CrowdStrikeGetCaseDetailsParams, + CrowdStrikeGetCaseDetailsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetCaseDetailsTool: ToolConfig< + CrowdStrikeGetCaseDetailsParams, + CrowdStrikeGetCaseDetailsResponse +> = { + id: 'crowdstrike_get_case_details', + name: 'CrowdStrike Get Case Details', + description: + 'Get CrowdStrike Falcon Case Management case records for one or more case IDs (POST /cases/entities/cases/v2). Requires the "Cases: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + caseIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike case IDs', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + caseIds: params.caseIds, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + operation: 'crowdstrike_get_case_details', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike case details') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + cases: { + type: 'array', + description: 'CrowdStrike Case Management case records', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Case identifier', optional: true }, + cid: { type: 'string', description: 'CrowdStrike customer identifier', optional: true }, + name: { type: 'string', description: 'Case name', optional: true }, + description: { type: 'string', description: 'Case description', optional: true }, + descriptionFormat: { + type: 'string', + description: 'Format of the case description', + optional: true, + }, + status: { type: 'string', description: 'Case status', optional: true }, + severity: { type: 'number', description: 'Numeric case severity', optional: true }, + severityLevel: { + type: 'string', + description: 'Case severity level name', + optional: true, + }, + referenceId: { + type: 'string', + description: 'Human-readable case reference ID', + optional: true, + }, + version: { + type: 'number', + description: 'Case version for optimistic concurrency', + optional: true, + }, + tags: { + type: 'array', + description: 'Tags applied to the case', + optional: true, + items: { type: 'string' }, + }, + assignedTo: { + type: 'json', + description: 'Falcon user the case is assigned to', + optional: true, + properties: { + uuid: { type: 'string', description: 'Falcon user UUID', optional: true }, + email: { type: 'string', description: 'Falcon user email', optional: true }, + fullName: { type: 'string', description: 'Falcon user full name', optional: true }, + }, + }, + createdBy: { + type: 'json', + description: 'Falcon user who created the case', + optional: true, + properties: { + uuid: { type: 'string', description: 'Falcon user UUID', optional: true }, + email: { type: 'string', description: 'Falcon user email', optional: true }, + fullName: { type: 'string', description: 'Falcon user full name', optional: true }, + }, + }, + lastUpdatedBy: { + type: 'json', + description: 'Falcon user who last updated the case', + optional: true, + properties: { + uuid: { type: 'string', description: 'Falcon user UUID', optional: true }, + email: { type: 'string', description: 'Falcon user email', optional: true }, + fullName: { type: 'string', description: 'Falcon user full name', optional: true }, + }, + }, + createdTimestamp: { + type: 'string', + description: 'Case creation timestamp', + optional: true, + }, + updatedTimestamp: { + type: 'string', + description: 'Case update timestamp', + optional: true, + }, + startTimestamp: { type: 'string', description: 'Case start timestamp', optional: true }, + endTimestamp: { type: 'string', description: 'Case end timestamp', optional: true }, + templateId: { type: 'string', description: 'Case template identifier', optional: true }, + templateName: { type: 'string', description: 'Case template name', optional: true }, + slaId: { + type: 'string', + description: 'SLA identifier applied to the case', + optional: true, + }, + slaName: { type: 'string', description: 'SLA name applied to the case', optional: true }, + isReadOnly: { + type: 'boolean', + description: 'Whether the case is read only', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of cases returned', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_host_group_details.ts b/apps/sim/tools/crowdstrike/get_host_group_details.ts new file mode 100644 index 00000000000..0c411c11311 --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_host_group_details.ts @@ -0,0 +1,129 @@ +import type { + CrowdStrikeGetHostGroupDetailsParams, + CrowdStrikeGetHostGroupDetailsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetHostGroupDetailsTool: ToolConfig< + CrowdStrikeGetHostGroupDetailsParams, + CrowdStrikeGetHostGroupDetailsResponse +> = { + id: 'crowdstrike_get_host_group_details', + name: 'CrowdStrike Get Host Group Details', + description: + 'Get CrowdStrike Falcon host group records for one or more group IDs (GET /devices/entities/host-groups/v1). Requires the "Host groups: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + hostGroupIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike host group IDs', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + hostGroupIds: params.hostGroupIds, + operation: 'crowdstrike_get_host_group_details', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike host group details') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + hostGroups: { + type: 'array', + description: 'CrowdStrike host group records', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Host group identifier', optional: true }, + name: { type: 'string', description: 'Host group name', optional: true }, + description: { type: 'string', description: 'Host group description', optional: true }, + groupType: { + type: 'string', + description: 'Group type (static, dynamic, staticByID)', + optional: true, + }, + assignmentRule: { + type: 'string', + description: 'FQL assignment rule for dynamic groups', + optional: true, + }, + createdBy: { type: 'string', description: 'User who created the group', optional: true }, + createdTimestamp: { + type: 'string', + description: 'Group creation timestamp', + optional: true, + }, + modifiedBy: { + type: 'string', + description: 'User who last modified the group', + optional: true, + }, + modifiedTimestamp: { + type: 'string', + description: 'Group modification timestamp', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of host groups returned', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_indicator_details.ts b/apps/sim/tools/crowdstrike/get_indicator_details.ts new file mode 100644 index 00000000000..abf864239ce --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_indicator_details.ts @@ -0,0 +1,204 @@ +import type { + CrowdStrikeGetIndicatorDetailsParams, + CrowdStrikeGetIndicatorDetailsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetIndicatorDetailsTool: ToolConfig< + CrowdStrikeGetIndicatorDetailsParams, + CrowdStrikeGetIndicatorDetailsResponse +> = { + id: 'crowdstrike_get_indicator_details', + name: 'CrowdStrike Get Indicator Details', + description: + 'Get custom CrowdStrike Falcon indicator of compromise (IOC) records for one or more IOC IDs (GET /iocs/entities/indicators/v1). Requires the "IOC Management: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + indicatorIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike IOC IDs', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + indicatorIds: params.indicatorIds, + operation: 'crowdstrike_get_indicator_details', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike indicator details') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + indicators: { + type: 'array', + description: 'CrowdStrike indicator of compromise records', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Indicator identifier', optional: true }, + type: { type: 'string', description: 'Indicator type', optional: true }, + value: { type: 'string', description: 'Indicator value', optional: true }, + action: { + type: 'string', + description: 'Action taken when the indicator matches', + optional: true, + }, + mobileAction: { + type: 'string', + description: 'Action taken on mobile platforms when the indicator matches', + optional: true, + }, + severity: { type: 'string', description: 'Indicator severity', optional: true }, + description: { type: 'string', description: 'Indicator description', optional: true }, + source: { type: 'string', description: 'Indicator source', optional: true }, + appliedGlobally: { + type: 'boolean', + description: 'Whether the indicator applies to all hosts', + optional: true, + }, + platforms: { + type: 'array', + description: 'Platforms the indicator applies to', + optional: true, + items: { type: 'string' }, + }, + hostGroups: { + type: 'array', + description: 'Host group IDs the indicator is scoped to', + optional: true, + items: { type: 'string' }, + }, + tags: { + type: 'array', + description: 'Tags applied to the indicator', + optional: true, + items: { type: 'string' }, + }, + expiration: { + type: 'string', + description: 'Indicator expiration timestamp', + optional: true, + }, + expired: { + type: 'boolean', + description: 'Whether the indicator has expired', + optional: true, + }, + deleted: { + type: 'boolean', + description: 'Whether the indicator is deleted', + optional: true, + }, + fromParent: { + type: 'boolean', + description: 'Whether the indicator was inherited from a parent CID', + optional: true, + }, + parentCidName: { type: 'string', description: 'Parent CID name', optional: true }, + createdBy: { + type: 'string', + description: 'User who created the indicator', + optional: true, + }, + createdOn: { + type: 'string', + description: 'Indicator creation timestamp', + optional: true, + }, + modifiedBy: { + type: 'string', + description: 'User who last modified the indicator', + optional: true, + }, + modifiedOn: { + type: 'string', + description: 'Indicator modification timestamp', + optional: true, + }, + metadata: { + type: 'json', + description: 'File metadata CrowdStrike resolved for the indicator', + optional: true, + properties: { + avHits: { type: 'number', description: 'Antivirus hit count', optional: true }, + companyName: { type: 'string', description: 'Company name', optional: true }, + fileDescription: { type: 'string', description: 'File description', optional: true }, + fileVersion: { type: 'string', description: 'File version', optional: true }, + filename: { type: 'string', description: 'File name', optional: true }, + originalFilename: { + type: 'string', + description: 'Original file name', + optional: true, + }, + productName: { type: 'string', description: 'Product name', optional: true }, + productVersion: { type: 'string', description: 'Product version', optional: true }, + signed: { + type: 'boolean', + description: 'Whether the file is signed', + optional: true, + }, + }, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of indicators returned', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_rtr_command_status.ts b/apps/sim/tools/crowdstrike/get_rtr_command_status.ts new file mode 100644 index 00000000000..439e3a84924 --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_rtr_command_status.ts @@ -0,0 +1,109 @@ +import type { + CrowdStrikeGetRtrCommandStatusParams, + CrowdStrikeGetRtrCommandStatusResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetRtrCommandStatusTool: ToolConfig< + CrowdStrikeGetRtrCommandStatusParams, + CrowdStrikeGetRtrCommandStatusResponse +> = { + id: 'crowdstrike_get_rtr_command_status', + name: 'CrowdStrike Get RTR Command Status', + description: + 'Get the status and output of a Real Time Response command by cloud request ID (GET /real-time-response/entities/command/v1). Long output is chunked across sequences, so increment the sequence ID to read the next chunk. Requires the "Real time response: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + cloudRequestId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Cloud request ID returned by Execute RTR Command', + }, + sequenceId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Output chunk to retrieve, starting at 0', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + cloudRequestId: params.cloudRequestId, + operation: 'crowdstrike_get_rtr_command_status', + sequenceId: params.sequenceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike RTR command status') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + complete: { + type: 'boolean', + description: 'Whether the command has finished running', + optional: true, + }, + stdout: { type: 'string', description: 'Standard output from the command', optional: true }, + stderr: { type: 'string', description: 'Standard error from the command', optional: true }, + baseCommand: { type: 'string', description: 'Base command that was run', optional: true }, + sessionId: { type: 'string', description: 'RTR session the command ran in', optional: true }, + taskId: { type: 'string', description: 'Task identifier for the command', optional: true }, + sequenceId: { + type: 'number', + description: 'Output chunk sequence this response covers', + optional: true, + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts index 529bd7a5976..3a3deefa173 100644 --- a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts +++ b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts @@ -11,7 +11,7 @@ export const crowdstrikeGetSensorAggregatesTool: ToolConfig< id: 'crowdstrike_get_sensor_aggregates', name: 'CrowdStrike Get Sensor Aggregates', description: - 'Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body', + 'Aggregate CrowdStrike Identity Protection sensors from a JSON aggregate query body (POST /identity-protection/aggregates/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the "Identity Protection Entities: Read" API scope.', version: '1.0.0', params: { @@ -113,9 +113,10 @@ export const crowdstrikeGetSensorAggregatesTool: ToolConfig< optional: true, }, subAggregates: { - type: 'json', + type: 'array', description: 'Nested aggregate results for this bucket', optional: true, + items: { type: 'object' }, }, to: { type: 'number', @@ -157,5 +158,18 @@ export const crowdstrikeGetSensorAggregatesTool: ToolConfig< type: 'number', description: 'Number of aggregate result groups returned', }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, }, } diff --git a/apps/sim/tools/crowdstrike/get_sensor_details.ts b/apps/sim/tools/crowdstrike/get_sensor_details.ts index dcbf44de73a..c9ab95914ab 100644 --- a/apps/sim/tools/crowdstrike/get_sensor_details.ts +++ b/apps/sim/tools/crowdstrike/get_sensor_details.ts @@ -11,7 +11,7 @@ export const crowdstrikeGetSensorDetailsTool: ToolConfig< id: 'crowdstrike_get_sensor_details', name: 'CrowdStrike Get Sensor Details', description: - 'Get documented CrowdStrike Identity Protection sensor details for one or more device IDs', + 'Get CrowdStrike Identity Protection sensor details for one or more device IDs (POST /identity-protection/entities/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the "Identity Protection Entities: Read" API scope.', version: '1.0.0', params: { @@ -181,7 +181,7 @@ export const crowdstrikeGetSensorDetailsTool: ToolConfig< }, pagination: { type: 'json', - description: 'Pagination metadata when returned by the underlying API', + description: 'Pagination metadata (limit, offset, total)', optional: true, properties: { limit: { type: 'number', description: 'Page size used for the query', optional: true }, @@ -189,5 +189,18 @@ export const crowdstrikeGetSensorDetailsTool: ToolConfig< total: { type: 'number', description: 'Total records available', optional: true }, }, }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, }, } diff --git a/apps/sim/tools/crowdstrike/get_vulnerability_details.ts b/apps/sim/tools/crowdstrike/get_vulnerability_details.ts new file mode 100644 index 00000000000..f874b72f314 --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_vulnerability_details.ts @@ -0,0 +1,277 @@ +import type { + CrowdStrikeGetVulnerabilityDetailsParams, + CrowdStrikeGetVulnerabilityDetailsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetVulnerabilityDetailsTool: ToolConfig< + CrowdStrikeGetVulnerabilityDetailsParams, + CrowdStrikeGetVulnerabilityDetailsResponse +> = { + id: 'crowdstrike_get_vulnerability_details', + name: 'CrowdStrike Get Vulnerability Details', + description: + 'Get CrowdStrike Falcon Spotlight vulnerability records for one or more vulnerability IDs, including CVE, affected host, application, and remediation details (GET /spotlight/entities/vulnerabilities/v2). Requires the spotlight-vulnerabilities:read API scope, shown as "Vulnerabilities: Read" in the Falcon API client UI.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + vulnerabilityIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of Spotlight vulnerability IDs (maximum 400 per request)', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + operation: 'crowdstrike_get_vulnerability_details', + vulnerabilityIds: params.vulnerabilityIds, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike vulnerability details') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + vulnerabilities: { + type: 'array', + description: 'CrowdStrike Spotlight vulnerability records', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Vulnerability identifier', optional: true }, + aid: { + type: 'string', + description: 'Agent identifier of the affected host', + optional: true, + }, + cid: { type: 'string', description: 'CrowdStrike customer identifier', optional: true }, + status: { + type: 'string', + description: 'Vulnerability status (open, closed, reopen)', + optional: true, + }, + confidence: { type: 'string', description: 'Detection confidence', optional: true }, + vulnerabilityId: { + type: 'string', + description: 'Underlying vulnerability ID', + optional: true, + }, + createdTimestamp: { type: 'string', description: 'Creation timestamp', optional: true }, + updatedTimestamp: { + type: 'string', + description: 'Last update timestamp', + optional: true, + }, + closedTimestamp: { type: 'string', description: 'Closure timestamp', optional: true }, + cve: { + type: 'json', + description: 'CVE details for the vulnerability', + optional: true, + properties: { + id: { type: 'string', description: 'CVE identifier', optional: true }, + baseScore: { type: 'number', description: 'CVSS base score', optional: true }, + severity: { type: 'string', description: 'CVE severity', optional: true }, + exprtRating: { + type: 'string', + description: 'CrowdStrike ExPRT rating', + optional: true, + }, + exploitStatus: { type: 'number', description: 'Exploit status code', optional: true }, + exploitabilityScore: { + type: 'number', + description: 'CVSS exploitability score', + optional: true, + }, + impactScore: { type: 'number', description: 'CVSS impact score', optional: true }, + remediationLevel: { + type: 'string', + description: 'CVSS remediation level', + optional: true, + }, + description: { type: 'string', description: 'CVE description', optional: true }, + publishedDate: { + type: 'string', + description: 'CVE publication date', + optional: true, + }, + vector: { type: 'string', description: 'CVSS vector string', optional: true }, + types: { + type: 'array', + description: 'CVE types', + optional: true, + items: { type: 'string' }, + }, + isCisaKev: { + type: 'boolean', + description: + 'Whether the CVE is in the CISA Known Exploited Vulnerabilities catalog', + optional: true, + }, + cisaDueDate: { + type: 'string', + description: 'CISA remediation due date', + optional: true, + }, + }, + }, + app: { + type: 'json', + description: 'Affected application', + optional: true, + properties: { + productNameNormalized: { + type: 'string', + description: 'Normalized product name', + optional: true, + }, + productNameVersion: { + type: 'string', + description: 'Product name and version', + optional: true, + }, + vendorNormalized: { + type: 'string', + description: 'Normalized vendor name', + optional: true, + }, + }, + }, + hostInfo: { + type: 'json', + description: 'Affected host details', + optional: true, + properties: { + hostname: { type: 'string', description: 'Host name', optional: true }, + localIp: { type: 'string', description: 'Local IP address', optional: true }, + machineDomain: { type: 'string', description: 'Machine domain', optional: true }, + osVersion: { + type: 'string', + description: 'Operating system version', + optional: true, + }, + platform: { type: 'string', description: 'Platform name', optional: true }, + productTypeDesc: { + type: 'string', + description: 'Product type description', + optional: true, + }, + assetCriticality: { + type: 'string', + description: 'Asset criticality', + optional: true, + }, + internetExposure: { + type: 'string', + description: 'Internet exposure', + optional: true, + }, + tags: { + type: 'array', + description: 'Host tags', + optional: true, + items: { type: 'string' }, + }, + groups: { + type: 'array', + description: 'Host group names the host belongs to', + optional: true, + items: { type: 'string' }, + }, + }, + }, + remediationIds: { + type: 'array', + description: 'Remediation IDs for the vulnerability', + optional: true, + items: { type: 'string' }, + }, + remediations: { + type: 'array', + description: 'Remediation entities for the vulnerability', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Remediation identifier', optional: true }, + title: { type: 'string', description: 'Remediation title', optional: true }, + action: { type: 'string', description: 'Remediation action', optional: true }, + type: { type: 'string', description: 'Remediation type', optional: true }, + link: { type: 'string', description: 'Remediation link', optional: true }, + reference: { type: 'string', description: 'Remediation reference', optional: true }, + vendorUrl: { type: 'string', description: 'Vendor advisory URL', optional: true }, + }, + }, + }, + suppressionInfo: { + type: 'json', + description: 'Suppression state for the vulnerability', + optional: true, + properties: { + isSuppressed: { + type: 'boolean', + description: 'Whether the finding is suppressed', + optional: true, + }, + reason: { type: 'string', description: 'Suppression reason', optional: true }, + }, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of vulnerabilities returned', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/index.ts b/apps/sim/tools/crowdstrike/index.ts index e0878239332..d54ffe63c87 100644 --- a/apps/sim/tools/crowdstrike/index.ts +++ b/apps/sim/tools/crowdstrike/index.ts @@ -1,4 +1,24 @@ +export { crowdstrikeCreateIndicatorsTool } from './create_indicators' +export { crowdstrikeDeleteIndicatorsTool } from './delete_indicators' +export { crowdstrikeDeleteRtrSessionTool } from './delete_rtr_session' +export { crowdstrikeExecuteRtrCommandTool } from './execute_rtr_command' +export { crowdstrikeGetAlertDetailsTool } from './get_alert_details' +export { crowdstrikeGetCaseDetailsTool } from './get_case_details' +export { crowdstrikeGetHostGroupDetailsTool } from './get_host_group_details' +export { crowdstrikeGetIndicatorDetailsTool } from './get_indicator_details' +export { crowdstrikeGetRtrCommandStatusTool } from './get_rtr_command_status' export { crowdstrikeGetSensorAggregatesTool } from './get_sensor_aggregates' export { crowdstrikeGetSensorDetailsTool } from './get_sensor_details' +export { crowdstrikeGetVulnerabilityDetailsTool } from './get_vulnerability_details' +export { crowdstrikeInitRtrSessionTool } from './init_rtr_session' +export { crowdstrikePerformHostActionTool } from './perform_host_action' +export { crowdstrikePerformHostGroupActionTool } from './perform_host_group_action' +export { crowdstrikeQueryAlertsTool } from './query_alerts' +export { crowdstrikeQueryCasesTool } from './query_cases' +export { crowdstrikeQueryHostGroupsTool } from './query_host_groups' +export { crowdstrikeQueryIndicatorsTool } from './query_indicators' export { crowdstrikeQuerySensorsTool } from './query_sensors' +export { crowdstrikeQueryVulnerabilitiesTool } from './query_vulnerabilities' export * from './types' +export { crowdstrikeUpdateAlertsTool } from './update_alerts' +export { crowdstrikeUpdateIndicatorsTool } from './update_indicators' diff --git a/apps/sim/tools/crowdstrike/init_rtr_session.ts b/apps/sim/tools/crowdstrike/init_rtr_session.ts new file mode 100644 index 00000000000..4cc7344a3ce --- /dev/null +++ b/apps/sim/tools/crowdstrike/init_rtr_session.ts @@ -0,0 +1,124 @@ +import type { + CrowdStrikeInitRtrSessionParams, + CrowdStrikeInitRtrSessionResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeInitRtrSessionTool: ToolConfig< + CrowdStrikeInitRtrSessionParams, + CrowdStrikeInitRtrSessionResponse +> = { + id: 'crowdstrike_init_rtr_session', + name: 'CrowdStrike Init RTR Session', + description: + 'Open a CrowdStrike Falcon Real Time Response session against a host so read-only commands can be run on it (POST /real-time-response/entities/sessions/v1). This connects a live remote shell to the endpoint. Requires the "Real time response: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + deviceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'CrowdStrike host agent ID (AID) to open the session against', + }, + queueOffline: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Queue the session so it runs when an offline host comes back online', + }, + origin: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional session origin string recorded by CrowdStrike', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + deviceId: params.deviceId, + operation: 'crowdstrike_init_rtr_session', + origin: params.origin, + queueOffline: params.queueOffline, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to initialize CrowdStrike RTR session') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + sessionId: { + type: 'string', + description: 'RTR session ID to use for subsequent commands', + optional: true, + }, + deviceId: { type: 'string', description: 'Host agent ID for the session', optional: true }, + platform: { type: 'string', description: 'Platform of the connected host', optional: true }, + pwd: { + type: 'string', + description: 'Working directory the session started in', + optional: true, + }, + offlineQueued: { + type: 'boolean', + description: 'Whether the session was queued for an offline host', + optional: true, + }, + existingAidSessions: { + type: 'number', + description: 'Number of sessions already open against this host', + optional: true, + }, + createdAt: { type: 'string', description: 'Session creation timestamp', optional: true }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/perform_host_action.ts b/apps/sim/tools/crowdstrike/perform_host_action.ts new file mode 100644 index 00000000000..95b2f080f03 --- /dev/null +++ b/apps/sim/tools/crowdstrike/perform_host_action.ts @@ -0,0 +1,110 @@ +import type { + CrowdStrikePerformHostActionParams, + CrowdStrikePerformHostActionResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikePerformHostActionTool: ToolConfig< + CrowdStrikePerformHostActionParams, + CrowdStrikePerformHostActionResponse +> = { + id: 'crowdstrike_perform_host_action', + name: 'CrowdStrike Perform Host Action', + description: + 'Act on CrowdStrike Falcon hosts (POST /devices/entities/devices-actions/v2). Actions: contain, lift_containment, hide_host, unhide_host, detection_suppress, detection_unsuppress. contain network-isolates the host so it can only reach the Falcon cloud; hide_host removes the host record from the console. Both are immediately disruptive. Up to 100 host IDs per call. Requires the "Hosts: Write" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + actionName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Action to take: contain, lift_containment, hide_host, unhide_host, detection_suppress, or detection_unsuppress. "contain" network-isolates the host; "hide_host" removes it from the Falcon console.', + }, + deviceIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of up to 100 CrowdStrike host agent IDs (AIDs) to act on', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + actionName: params.actionName, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + deviceIds: params.deviceIds, + operation: 'crowdstrike_perform_host_action', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to perform CrowdStrike host action') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + affected: { + type: 'array', + description: 'Entities affected by the action', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Affected entity identifier', optional: true }, + path: { type: 'string', description: 'API path of the affected entity', optional: true }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of hosts the action was applied to', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/perform_host_group_action.ts b/apps/sim/tools/crowdstrike/perform_host_group_action.ts new file mode 100644 index 00000000000..fa39b26c91b --- /dev/null +++ b/apps/sim/tools/crowdstrike/perform_host_group_action.ts @@ -0,0 +1,144 @@ +import type { + CrowdStrikePerformHostGroupActionParams, + CrowdStrikePerformHostGroupActionResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikePerformHostGroupActionTool: ToolConfig< + CrowdStrikePerformHostGroupActionParams, + CrowdStrikePerformHostGroupActionResponse +> = { + id: 'crowdstrike_perform_host_group_action', + name: 'CrowdStrike Perform Host Group Action', + description: + 'Add hosts to or remove hosts from a CrowdStrike Falcon static host group (POST /devices/entities/host-group-actions/v1). Group membership drives policy assignment, so changing it changes which policies apply to those hosts. Requires the "Host groups: Write" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + actionName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Action to take: add-hosts or remove-hosts', + }, + hostGroupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'CrowdStrike host group ID to modify (static groups only)', + }, + deviceIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of CrowdStrike host agent IDs (AIDs) to add to or remove from the group', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + actionName: params.actionName, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + deviceIds: params.deviceIds, + hostGroupId: params.hostGroupId, + operation: 'crowdstrike_perform_host_group_action', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to perform CrowdStrike host group action') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + hostGroups: { + type: 'array', + description: 'Host group records returned after the action', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Host group identifier', optional: true }, + name: { type: 'string', description: 'Host group name', optional: true }, + description: { type: 'string', description: 'Host group description', optional: true }, + groupType: { + type: 'string', + description: 'Group type (static, dynamic, staticByID)', + optional: true, + }, + assignmentRule: { + type: 'string', + description: 'FQL assignment rule for dynamic groups', + optional: true, + }, + createdBy: { type: 'string', description: 'User who created the group', optional: true }, + createdTimestamp: { + type: 'string', + description: 'Group creation timestamp', + optional: true, + }, + modifiedBy: { + type: 'string', + description: 'User who last modified the group', + optional: true, + }, + modifiedTimestamp: { + type: 'string', + description: 'Group modification timestamp', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of host group records returned', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_alerts.ts b/apps/sim/tools/crowdstrike/query_alerts.ts new file mode 100644 index 00000000000..73734f54287 --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_alerts.ts @@ -0,0 +1,128 @@ +import type { + CrowdStrikeQueryAlertsParams, + CrowdStrikeQueryAlertsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryAlertsTool: ToolConfig< + CrowdStrikeQueryAlertsParams, + CrowdStrikeQueryAlertsResponse +> = { + id: 'crowdstrike_query_alerts', + name: 'CrowdStrike Query Alerts', + description: + 'Search CrowdStrike Falcon alerts with a Falcon Query Language filter and return their composite IDs. Uses the current Alerts API (GET /alerts/queries/alerts/v2), which replaced the Detects API decommissioned on September 30, 2025. Requires the "Alerts: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter over alert fields', + }, + q: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across all alert metadata', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of alert IDs to return (max 10000)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for the alert query', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression such as "created_timestamp|desc"', + }, + includeHidden: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include previously hidden alerts (CrowdStrike defaults this to true)', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + includeHidden: params.includeHidden, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_alerts', + q: params.q, + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike alerts') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + alertIds: { + type: 'array', + description: 'Composite alert IDs matching the query, ready for Get Alert Details', + items: { type: 'string' }, + }, + count: { + type: 'number', + description: 'Number of alert IDs returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (limit, offset, total)', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_cases.ts b/apps/sim/tools/crowdstrike/query_cases.ts new file mode 100644 index 00000000000..4cfd9fb57bd --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_cases.ts @@ -0,0 +1,122 @@ +import type { + CrowdStrikeQueryCasesParams, + CrowdStrikeQueryCasesResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryCasesTool: ToolConfig< + CrowdStrikeQueryCasesParams, + CrowdStrikeQueryCasesResponse +> = { + id: 'crowdstrike_query_cases', + name: 'CrowdStrike Query Cases', + description: + 'Search CrowdStrike Falcon Case Management cases with a Falcon Query Language filter and return their IDs (GET /cases/queries/cases/v1). Case Management supersedes the CrowdScore Incidents API, which CrowdStrike has removed from its published API spec. Requires the "Cases: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Falcon Query Language filter. Exact-match fields include cid and id; wildcard fields include assigned_to_name and assigned_to_uuid; range fields include created_timestamp and updated_timestamp.', + }, + q: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across all case metadata', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of case IDs to return (max 10000, default 100)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for the case query', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression such as "created_timestamp|desc" or "status|asc"', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_cases', + q: params.q, + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike cases') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + caseIds: { + type: 'array', + description: 'Case IDs matching the query', + items: { type: 'string' }, + }, + count: { + type: 'number', + description: 'Number of case IDs returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (limit, offset, total)', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_host_groups.ts b/apps/sim/tools/crowdstrike/query_host_groups.ts new file mode 100644 index 00000000000..4b73dc4d300 --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_host_groups.ts @@ -0,0 +1,114 @@ +import type { + CrowdStrikeQueryHostGroupsParams, + CrowdStrikeQueryHostGroupsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryHostGroupsTool: ToolConfig< + CrowdStrikeQueryHostGroupsParams, + CrowdStrikeQueryHostGroupsResponse +> = { + id: 'crowdstrike_query_host_groups', + name: 'CrowdStrike Query Host Groups', + description: + 'Search CrowdStrike Falcon host groups with a Falcon Query Language filter and return their IDs (GET /devices/queries/host-groups/v1). Requires the "Host groups: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter over host group fields', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of host group IDs to return (1-5000)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for the host group query', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression such as "name.asc" or "modified_timestamp.desc"', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_host_groups', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike host groups') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + hostGroupIds: { + type: 'array', + description: 'Host group IDs matching the query', + items: { type: 'string' }, + }, + count: { + type: 'number', + description: 'Number of host group IDs returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (limit, offset, total)', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_indicators.ts b/apps/sim/tools/crowdstrike/query_indicators.ts new file mode 100644 index 00000000000..2e36d7547f1 --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_indicators.ts @@ -0,0 +1,124 @@ +import type { + CrowdStrikeQueryIndicatorsParams, + CrowdStrikeQueryIndicatorsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryIndicatorsTool: ToolConfig< + CrowdStrikeQueryIndicatorsParams, + CrowdStrikeQueryIndicatorsResponse +> = { + id: 'crowdstrike_query_indicators', + name: 'CrowdStrike Query Indicators', + description: + 'Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Query Language filter and return their IDs (GET /iocs/queries/indicators/v1). Requires the "IOC Management: Read" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter over IOC fields', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of IOC IDs to return (1-500, default 100)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Pagination offset. Mutually exclusive with the after cursor; use after beyond 10,000 IOCs.', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous response. Mutually exclusive with offset.', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Sort expression. Supported fields include action, applied_globally, created_by, created_on, expiration, expired, modified_by, modified_on, severity_number, source, type, and value.', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + after: params.after, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_indicators', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike indicators') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + indicatorIds: { + type: 'array', + description: 'IOC IDs matching the query', + items: { type: 'string' }, + }, + count: { + type: 'number', + description: 'Number of IOC IDs returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (limit, offset, total, after)', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + after: { type: 'string', description: 'Cursor for the next page', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_sensors.ts b/apps/sim/tools/crowdstrike/query_sensors.ts index d6547815906..1068a34023b 100644 --- a/apps/sim/tools/crowdstrike/query_sensors.ts +++ b/apps/sim/tools/crowdstrike/query_sensors.ts @@ -10,7 +10,8 @@ export const crowdstrikeQuerySensorsTool: ToolConfig< > = { id: 'crowdstrike_query_sensors', name: 'CrowdStrike Query Sensors', - description: 'Search CrowdStrike identity protection sensors by hostname, IP, or related fields', + description: + 'Search CrowdStrike Identity Protection sensors -- the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors -- and return their device IDs (GET /identity-protection/queries/devices/v1). Sort uses the dot form, for example status.desc. Requires the "Identity Protection Entities: Read" API scope, a separate entitlement from Hosts and Alerts.', version: '1.0.0', params: { @@ -209,5 +210,18 @@ export const crowdstrikeQuerySensorsTool: ToolConfig< total: { type: 'number', description: 'Total records available', optional: true }, }, }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, }, } diff --git a/apps/sim/tools/crowdstrike/query_vulnerabilities.ts b/apps/sim/tools/crowdstrike/query_vulnerabilities.ts new file mode 100644 index 00000000000..b79c9493e97 --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_vulnerabilities.ts @@ -0,0 +1,115 @@ +import type { + CrowdStrikeQueryVulnerabilitiesParams, + CrowdStrikeQueryVulnerabilitiesResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryVulnerabilitiesTool: ToolConfig< + CrowdStrikeQueryVulnerabilitiesParams, + CrowdStrikeQueryVulnerabilitiesResponse +> = { + id: 'crowdstrike_query_vulnerabilities', + name: 'CrowdStrike Query Vulnerabilities', + description: + 'Search CrowdStrike Falcon Spotlight vulnerabilities with a required Falcon Query Language filter and return their IDs (GET /spotlight/queries/vulnerabilities/v1). Requires the spotlight-vulnerabilities:read API scope, shown as "Vulnerabilities: Read" in the Falcon API client UI.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Falcon Query Language filter (required by Spotlight). Filterable fields include status, aid, cid, last_seen_within, cve.id, cve.severity, cve.exprt_rating, cve.is_cisa_kev, cve.base_score, host_info.platform_name, host_info.groups, host_info.tags, host_info.internet_exposure, and suppression_info.is_suppressed.', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of vulnerability IDs to return (1-400, default 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous response. Spotlight does not support offset.', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression such as "updated_timestamp|desc" or "closed_timestamp|asc"', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + after: params.after, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + operation: 'crowdstrike_query_vulnerabilities', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike vulnerabilities') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + vulnerabilityIds: { + type: 'array', + description: 'Spotlight vulnerability IDs matching the query', + items: { type: 'string' }, + }, + count: { + type: 'number', + description: 'Number of vulnerability IDs returned', + }, + pagination: { + type: 'json', + description: 'Cursor pagination metadata (limit, total, after)', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + after: { type: 'string', description: 'Cursor for the next page', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/types.ts b/apps/sim/tools/crowdstrike/types.ts index 6fc3634597d..4dc0a753c58 100644 --- a/apps/sim/tools/crowdstrike/types.ts +++ b/apps/sim/tools/crowdstrike/types.ts @@ -1,6 +1,6 @@ import type { ToolResponse } from '@/tools/types' -export type CrowdStrikeCloud = 'us-1' | 'us-2' | 'eu-1' | 'us-gov-1' | 'us-gov-2' +export type CrowdStrikeCloud = 'us-1' | 'us-2' | 'us-3' | 'eu-1' | 'us-gov-1' | 'us-gov-2' export interface CrowdStrikeBaseParams { clientId: string @@ -29,9 +29,17 @@ interface CrowdStrikeAggregateExtendedBoundsSpec { min: string } +/** CrowdStrike's `MsaRangeSpec` serializes its bounds capitalized, unlike every sibling spec. */ interface CrowdStrikeAggregateRangeSpec { - from: number - to: number + From: number + To: number +} + +/** CrowdStrike's `MsaAPIFiltersSpec`: an FQL-per-bucket map plus the catch-all bucket controls. */ +export interface CrowdStrikeAggregateFiltersSpec { + filters: Record + other_bucket?: boolean + other_bucket_key?: string } export interface CrowdStrikeAggregateQuery { @@ -40,6 +48,7 @@ export interface CrowdStrikeAggregateQuery { extended_bounds?: CrowdStrikeAggregateExtendedBoundsSpec field?: string filter?: string + filters_spec?: CrowdStrikeAggregateFiltersSpec from?: number include?: string interval?: string @@ -47,6 +56,7 @@ export interface CrowdStrikeAggregateQuery { min_doc_count?: number missing?: string name?: string + percents?: number[] q?: string ranges?: CrowdStrikeAggregateRangeSpec[] size?: number @@ -91,6 +101,7 @@ interface CrowdStrikeSensor { export interface CrowdStrikeQuerySensorsResponse extends ToolResponse { output: { count: number + errors: CrowdStrikeApiError[] pagination: CrowdStrikePagination | null sensors: CrowdStrikeSensor[] } @@ -99,6 +110,7 @@ export interface CrowdStrikeQuerySensorsResponse extends ToolResponse { export interface CrowdStrikeGetSensorDetailsResponse extends ToolResponse { output: { count: number + errors: CrowdStrikeApiError[] pagination: CrowdStrikePagination | null sensors: CrowdStrikeSensor[] } @@ -108,7 +120,7 @@ export interface CrowdStrikeSensorAggregateBucket { count: number | null from: number | null keyAsString: string | null - label: Record | null + label: unknown stringFrom: string | null stringTo: string | null subAggregates: CrowdStrikeSensorAggregateResult[] @@ -128,6 +140,521 @@ export interface CrowdStrikeGetSensorAggregatesResponse extends ToolResponse { output: { aggregates: CrowdStrikeSensorAggregateResult[] count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeApiError { + code: number | null + id: string | null + message: string | null +} + +interface CrowdStrikeCursorPagination extends CrowdStrikePagination { + after: string | null +} + +interface CrowdStrikeSpotlightPagination { + after: string | null + limit: number | null + total: number | null +} + +export interface CrowdStrikeQueryAlertsParams extends CrowdStrikeBaseParams { + filter?: string + q?: string + limit?: number + offset?: number + sort?: string + includeHidden?: boolean +} + +export interface CrowdStrikeGetAlertDetailsParams extends CrowdStrikeBaseParams { + compositeIds: string[] + includeHidden?: boolean +} + +export interface CrowdStrikeUpdateAlertsParams extends CrowdStrikeBaseParams { + compositeIds: string[] + updateStatus?: string + assignToUuid?: string + assignToUserId?: string + assignToName?: string + unassign?: boolean + appendComment?: string + addTag?: string + removeTag?: string + removeTagsByPrefix?: string + showInUi?: boolean + actionParameters?: CrowdStrikeActionParameter[] + includeHidden?: boolean +} + +export interface CrowdStrikeActionParameter { + name: string + value: string +} + +export interface CrowdStrikeAlert { + compositeId: string | null + id: string | null + cid: string | null + aggregateId: string | null + agentId: string | null + deviceId: string | null + hostname: string | null + name: string | null + displayName: string | null + description: string | null + type: string | null + product: string | null + platform: string | null + severity: number | null + severityName: string | null + confidence: number | null + status: string | null + assignedToName: string | null + assignedToUid: string | null + assignedToUuid: string | null + tactic: string | null + tacticId: string | null + technique: string | null + techniqueId: string | null + scenario: string | null + objective: string | null + resolution: string | null + showInUi: boolean | null + tags: string[] + filename: string | null + filepath: string | null + cmdline: string | null + sha256: string | null + sha1: string | null + md5: string | null + userName: string | null + userId: string | null + patternId: number | null + falconHostLink: string | null + controlGraphId: string | null + external: boolean | null + emailSent: boolean | null + isAggregated: boolean | null + isFalconPlatformIoa: boolean | null + dataDomains: string[] + iocValues: string[] + linkedCaseIds: string[] + linkedBehavioralDetections: string[] + timestamp: string | null + createdTimestamp: string | null + updatedTimestamp: string | null + crawledTimestamp: string | null + contextTimestamp: string | null +} + +export interface CrowdStrikeQueryAlertsResponse extends ToolResponse { + output: { + alertIds: string[] + count: number + pagination: CrowdStrikePagination | null + } +} + +export interface CrowdStrikeGetAlertDetailsResponse extends ToolResponse { + output: { + alerts: CrowdStrikeAlert[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeUpdateAlertsResponse extends ToolResponse { + output: { + updatedIds: string[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikePerformHostActionParams extends CrowdStrikeBaseParams { + actionName: string + deviceIds: string[] +} + +export interface CrowdStrikeAffectedEntity { + id: string | null + path: string | null +} + +export interface CrowdStrikePerformHostActionResponse extends ToolResponse { + output: { + affected: CrowdStrikeAffectedEntity[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeQueryHostGroupsParams extends CrowdStrikeBaseParams { + filter?: string + limit?: number + offset?: number + sort?: string +} + +export interface CrowdStrikeGetHostGroupDetailsParams extends CrowdStrikeBaseParams { + hostGroupIds: string[] +} + +export interface CrowdStrikePerformHostGroupActionParams extends CrowdStrikeBaseParams { + actionName: string + hostGroupId: string + deviceIds: string[] +} + +export interface CrowdStrikeHostGroup { + id: string | null + name: string | null + description: string | null + groupType: string | null + assignmentRule: string | null + createdBy: string | null + createdTimestamp: string | null + modifiedBy: string | null + modifiedTimestamp: string | null +} + +export interface CrowdStrikeQueryHostGroupsResponse extends ToolResponse { + output: { + hostGroupIds: string[] + count: number + pagination: CrowdStrikePagination | null + } +} + +export interface CrowdStrikeGetHostGroupDetailsResponse extends ToolResponse { + output: { + hostGroups: CrowdStrikeHostGroup[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikePerformHostGroupActionResponse extends ToolResponse { + output: { + hostGroups: CrowdStrikeHostGroup[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeQueryIndicatorsParams extends CrowdStrikeBaseParams { + filter?: string + limit?: number + offset?: number + after?: string + sort?: string +} + +export interface CrowdStrikeGetIndicatorDetailsParams extends CrowdStrikeBaseParams { + indicatorIds: string[] +} + +export interface CrowdStrikeCreateIndicatorsParams extends CrowdStrikeBaseParams { + indicators: Record[] + comment?: string + retrodetects?: boolean + ignoreWarnings?: boolean +} + +export interface CrowdStrikeUpdateIndicatorsParams extends CrowdStrikeBaseParams { + indicators: Record[] + comment?: string + retrodetects?: boolean + ignoreWarnings?: boolean +} + +export interface CrowdStrikeDeleteIndicatorsParams extends CrowdStrikeBaseParams { + indicatorIds?: string[] + filter?: string + comment?: string +} + +export interface CrowdStrikeIndicatorMetadata { + avHits: number | null + companyName: string | null + fileDescription: string | null + fileVersion: string | null + filename: string | null + originalFilename: string | null + productName: string | null + productVersion: string | null + signed: boolean | null +} + +export interface CrowdStrikeIndicator { + id: string | null + type: string | null + value: string | null + action: string | null + mobileAction: string | null + severity: string | null + description: string | null + source: string | null + appliedGlobally: boolean | null + platforms: string[] + hostGroups: string[] + tags: string[] + expiration: string | null + expired: boolean | null + deleted: boolean | null + fromParent: boolean | null + parentCidName: string | null + createdBy: string | null + createdOn: string | null + modifiedBy: string | null + modifiedOn: string | null + metadata: CrowdStrikeIndicatorMetadata | null +} + +export interface CrowdStrikeQueryIndicatorsResponse extends ToolResponse { + output: { + indicatorIds: string[] + count: number + pagination: CrowdStrikeCursorPagination | null + } +} + +export interface CrowdStrikeIndicatorListResponse extends ToolResponse { + output: { + indicators: CrowdStrikeIndicator[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export type CrowdStrikeGetIndicatorDetailsResponse = CrowdStrikeIndicatorListResponse +export type CrowdStrikeCreateIndicatorsResponse = CrowdStrikeIndicatorListResponse +export type CrowdStrikeUpdateIndicatorsResponse = CrowdStrikeIndicatorListResponse + +export interface CrowdStrikeDeleteIndicatorsResponse extends ToolResponse { + output: { + deletedIds: string[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeQueryVulnerabilitiesParams extends CrowdStrikeBaseParams { + filter: string + limit?: number + after?: string + sort?: string +} + +export interface CrowdStrikeGetVulnerabilityDetailsParams extends CrowdStrikeBaseParams { + vulnerabilityIds: string[] +} + +export interface CrowdStrikeVulnerabilityCve { + id: string | null + baseScore: number | null + severity: string | null + exprtRating: string | null + exploitStatus: number | null + exploitabilityScore: number | null + impactScore: number | null + remediationLevel: string | null + description: string | null + publishedDate: string | null + vector: string | null + types: string[] + isCisaKev: boolean | null + cisaDueDate: string | null +} + +export interface CrowdStrikeVulnerabilityApp { + productNameNormalized: string | null + productNameVersion: string | null + vendorNormalized: string | null +} + +export interface CrowdStrikeVulnerabilityHostInfo { + hostname: string | null + localIp: string | null + machineDomain: string | null + osVersion: string | null + platform: string | null + productTypeDesc: string | null + assetCriticality: string | null + internetExposure: string | null + tags: string[] + groups: string[] +} + +export interface CrowdStrikeVulnerabilityRemediation { + id: string | null + title: string | null + action: string | null + type: string | null + link: string | null + reference: string | null + vendorUrl: string | null +} + +export interface CrowdStrikeVulnerability { + id: string | null + aid: string | null + cid: string | null + status: string | null + confidence: string | null + vulnerabilityId: string | null + createdTimestamp: string | null + updatedTimestamp: string | null + closedTimestamp: string | null + cve: CrowdStrikeVulnerabilityCve | null + app: CrowdStrikeVulnerabilityApp | null + hostInfo: CrowdStrikeVulnerabilityHostInfo | null + remediationIds: string[] + remediations: CrowdStrikeVulnerabilityRemediation[] + suppressionInfo: { isSuppressed: boolean | null; reason: string | null } | null +} + +export interface CrowdStrikeQueryVulnerabilitiesResponse extends ToolResponse { + output: { + vulnerabilityIds: string[] + count: number + pagination: CrowdStrikeSpotlightPagination | null + } +} + +export interface CrowdStrikeGetVulnerabilityDetailsResponse extends ToolResponse { + output: { + vulnerabilities: CrowdStrikeVulnerability[] + count: number + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeInitRtrSessionParams extends CrowdStrikeBaseParams { + deviceId: string + queueOffline?: boolean + origin?: string +} + +export interface CrowdStrikeExecuteRtrCommandParams extends CrowdStrikeBaseParams { + sessionId: string + baseCommand: string + commandString: string +} + +export interface CrowdStrikeGetRtrCommandStatusParams extends CrowdStrikeBaseParams { + cloudRequestId: string + sequenceId?: number +} + +export interface CrowdStrikeDeleteRtrSessionParams extends CrowdStrikeBaseParams { + sessionId: string +} + +export interface CrowdStrikeInitRtrSessionResponse extends ToolResponse { + output: { + sessionId: string | null + deviceId: string | null + platform: string | null + pwd: string | null + offlineQueued: boolean | null + existingAidSessions: number | null + createdAt: string | null + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeExecuteRtrCommandResponse extends ToolResponse { + output: { + cloudRequestId: string | null + sessionId: string | null + queuedCommandOffline: boolean | null + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeGetRtrCommandStatusResponse extends ToolResponse { + output: { + complete: boolean | null + stdout: string | null + stderr: string | null + baseCommand: string | null + sessionId: string | null + taskId: string | null + sequenceId: number | null + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeDeleteRtrSessionResponse extends ToolResponse { + output: { + sessionId: string + deleted: boolean + errors: CrowdStrikeApiError[] + } +} + +export interface CrowdStrikeQueryCasesParams extends CrowdStrikeBaseParams { + filter?: string + q?: string + limit?: number + offset?: number + sort?: string +} + +export interface CrowdStrikeGetCaseDetailsParams extends CrowdStrikeBaseParams { + caseIds: string[] +} + +export interface CrowdStrikeFalconUser { + uuid: string | null + email: string | null + fullName: string | null +} + +export interface CrowdStrikeCase { + id: string | null + cid: string | null + name: string | null + description: string | null + descriptionFormat: string | null + status: string | null + severity: number | null + severityLevel: string | null + referenceId: string | null + version: number | null + tags: string[] + assignedTo: CrowdStrikeFalconUser | null + createdBy: CrowdStrikeFalconUser | null + lastUpdatedBy: CrowdStrikeFalconUser | null + createdTimestamp: string | null + updatedTimestamp: string | null + startTimestamp: string | null + endTimestamp: string | null + templateId: string | null + templateName: string | null + slaId: string | null + slaName: string | null + isReadOnly: boolean | null +} + +export interface CrowdStrikeQueryCasesResponse extends ToolResponse { + output: { + caseIds: string[] + count: number + pagination: CrowdStrikePagination | null + } +} + +export interface CrowdStrikeGetCaseDetailsResponse extends ToolResponse { + output: { + cases: CrowdStrikeCase[] + count: number + errors: CrowdStrikeApiError[] } } @@ -135,3 +662,21 @@ export type CrowdStrikeResponse = | CrowdStrikeQuerySensorsResponse | CrowdStrikeGetSensorDetailsResponse | CrowdStrikeGetSensorAggregatesResponse + | CrowdStrikeQueryAlertsResponse + | CrowdStrikeGetAlertDetailsResponse + | CrowdStrikeUpdateAlertsResponse + | CrowdStrikePerformHostActionResponse + | CrowdStrikeQueryHostGroupsResponse + | CrowdStrikeGetHostGroupDetailsResponse + | CrowdStrikePerformHostGroupActionResponse + | CrowdStrikeQueryIndicatorsResponse + | CrowdStrikeIndicatorListResponse + | CrowdStrikeDeleteIndicatorsResponse + | CrowdStrikeQueryVulnerabilitiesResponse + | CrowdStrikeGetVulnerabilityDetailsResponse + | CrowdStrikeInitRtrSessionResponse + | CrowdStrikeExecuteRtrCommandResponse + | CrowdStrikeGetRtrCommandStatusResponse + | CrowdStrikeDeleteRtrSessionResponse + | CrowdStrikeQueryCasesResponse + | CrowdStrikeGetCaseDetailsResponse diff --git a/apps/sim/tools/crowdstrike/update_alerts.ts b/apps/sim/tools/crowdstrike/update_alerts.ts new file mode 100644 index 00000000000..dcc69ee96c4 --- /dev/null +++ b/apps/sim/tools/crowdstrike/update_alerts.ts @@ -0,0 +1,181 @@ +import type { + CrowdStrikeUpdateAlertsParams, + CrowdStrikeUpdateAlertsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeUpdateAlertsTool: ToolConfig< + CrowdStrikeUpdateAlertsParams, + CrowdStrikeUpdateAlertsResponse +> = { + id: 'crowdstrike_update_alerts', + name: 'CrowdStrike Update Alerts', + description: + 'Update CrowdStrike Falcon alerts by composite ID: change status, assign or unassign an analyst, add or remove tags, append a comment, or toggle visibility (PATCH /alerts/entities/alerts/v3). This modifies live alerts in the Falcon console. Requires the "Alerts: Write" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + compositeIds: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike composite alert IDs to update', + }, + updateStatus: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New alert status: new, in_progress, reopened, or closed', + }, + assignToUuid: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Assign the alert to this Falcon user UUID', + }, + assignToUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Assign the alert to this Falcon user ID, such as user@example.com', + }, + assignToName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Assign the alert to this Falcon username, such as John Doe', + }, + unassign: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Clear the assigned user UUID, user ID, and username from the alert', + }, + appendComment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comment to append to the alert in the Falcon console', + }, + addTag: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag to add to the alert', + }, + removeTag: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag to remove from the alert', + }, + removeTagsByPrefix: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Remove every tag on the alert that starts with this prefix', + }, + showInUi: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the alert is displayed in the Falcon console', + }, + actionParameters: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Raw JSON array of additional CrowdStrike action parameters, each shaped { "name": string, "value": string }', + }, + includeHidden: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include previously hidden alerts (CrowdStrike defaults this to true)', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + actionParameters: params.actionParameters, + addTag: params.addTag, + appendComment: params.appendComment, + assignToName: params.assignToName, + assignToUserId: params.assignToUserId, + assignToUuid: params.assignToUuid, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + compositeIds: params.compositeIds, + includeHidden: params.includeHidden, + operation: 'crowdstrike_update_alerts', + removeTag: params.removeTag, + removeTagsByPrefix: params.removeTagsByPrefix, + showInUi: params.showInUi, + unassign: params.unassign, + updateStatus: params.updateStatus, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to update CrowdStrike alerts') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + updatedIds: { + type: 'array', + description: 'Composite alert IDs the update was submitted for', + items: { type: 'string' }, + }, + count: { + type: 'number', + description: 'Number of alerts the update was submitted for', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/update_indicators.ts b/apps/sim/tools/crowdstrike/update_indicators.ts new file mode 100644 index 00000000000..709b09d588c --- /dev/null +++ b/apps/sim/tools/crowdstrike/update_indicators.ts @@ -0,0 +1,226 @@ +import type { + CrowdStrikeUpdateIndicatorsParams, + CrowdStrikeUpdateIndicatorsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeUpdateIndicatorsTool: ToolConfig< + CrowdStrikeUpdateIndicatorsParams, + CrowdStrikeUpdateIndicatorsResponse +> = { + id: 'crowdstrike_update_indicators', + name: 'CrowdStrike Update Indicators', + description: + 'Update custom CrowdStrike Falcon indicators of compromise by ID (PATCH /iocs/entities/indicators/v1). DESTRUCTIVE: omitted fields may be cleared, so read each indicator with crowdstrike_get_indicator_details first and resend its full field set with your edits applied. Changing action or scope changes prevention behavior fleet-wide. type and value are immutable. Requires the "IOC Management: Write" API scope.', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + indicators: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of indicators to update. Each entry requires id, and should also repeat every field it wants to keep: an updatable field the entry omits may be cleared. Updatable fields: action, severity, description, source, tags (array), platforms (array), applied_globally (boolean), host_groups (array), expiration (ISO 8601), mobile_action, metadata ({ filename }). type and value cannot be changed.', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Audit comment explaining why these indicators were updated', + }, + retrodetects: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to generate retroactive detections for the updated indicators', + }, + ignoreWarnings: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to apply the updates even when CrowdStrike returns warnings', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + comment: params.comment, + ignoreWarnings: params.ignoreWarnings, + indicators: params.indicators, + operation: 'crowdstrike_update_indicators', + retrodetects: params.retrodetects, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to update CrowdStrike indicators') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + indicators: { + type: 'array', + description: 'Updated CrowdStrike indicator records', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Indicator identifier', optional: true }, + type: { type: 'string', description: 'Indicator type', optional: true }, + value: { type: 'string', description: 'Indicator value', optional: true }, + action: { + type: 'string', + description: 'Action taken when the indicator matches', + optional: true, + }, + mobileAction: { + type: 'string', + description: 'Action taken on mobile platforms when the indicator matches', + optional: true, + }, + severity: { type: 'string', description: 'Indicator severity', optional: true }, + description: { type: 'string', description: 'Indicator description', optional: true }, + source: { type: 'string', description: 'Indicator source', optional: true }, + appliedGlobally: { + type: 'boolean', + description: 'Whether the indicator applies to all hosts', + optional: true, + }, + platforms: { + type: 'array', + description: 'Platforms the indicator applies to', + optional: true, + items: { type: 'string' }, + }, + hostGroups: { + type: 'array', + description: 'Host group IDs the indicator is scoped to', + optional: true, + items: { type: 'string' }, + }, + tags: { + type: 'array', + description: 'Tags applied to the indicator', + optional: true, + items: { type: 'string' }, + }, + expiration: { + type: 'string', + description: 'Indicator expiration timestamp', + optional: true, + }, + expired: { + type: 'boolean', + description: 'Whether the indicator has expired', + optional: true, + }, + deleted: { + type: 'boolean', + description: 'Whether the indicator is deleted', + optional: true, + }, + fromParent: { + type: 'boolean', + description: 'Whether the indicator was inherited from a parent CID', + optional: true, + }, + parentCidName: { type: 'string', description: 'Parent CID name', optional: true }, + createdBy: { + type: 'string', + description: 'User who created the indicator', + optional: true, + }, + createdOn: { + type: 'string', + description: 'Indicator creation timestamp', + optional: true, + }, + modifiedBy: { + type: 'string', + description: 'User who last modified the indicator', + optional: true, + }, + modifiedOn: { + type: 'string', + description: 'Indicator modification timestamp', + optional: true, + }, + metadata: { + type: 'json', + description: 'File metadata CrowdStrike resolved for the indicator', + optional: true, + properties: { + avHits: { type: 'number', description: 'Antivirus hit count', optional: true }, + companyName: { type: 'string', description: 'Company name', optional: true }, + fileDescription: { type: 'string', description: 'File description', optional: true }, + fileVersion: { type: 'string', description: 'File version', optional: true }, + filename: { type: 'string', description: 'File name', optional: true }, + originalFilename: { + type: 'string', + description: 'Original file name', + optional: true, + }, + productName: { type: 'string', description: 'Product name', optional: true }, + productVersion: { type: 'string', description: 'Product version', optional: true }, + signed: { + type: 'boolean', + description: 'Whether the file is signed', + optional: true, + }, + }, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of indicators updated', + }, + errors: { + type: 'array', + description: 'Errors CrowdStrike returned alongside a partially successful response', + optional: true, + items: { + type: 'object', + properties: { + code: { type: 'number', description: 'CrowdStrike error code', optional: true }, + id: { type: 'string', description: 'Identifier the error applies to', optional: true }, + message: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/add_incident_todo.ts b/apps/sim/tools/datadog/add_incident_todo.ts new file mode 100644 index 00000000000..c82dc16b63e --- /dev/null +++ b/apps/sim/tools/datadog/add_incident_todo.ts @@ -0,0 +1,127 @@ +import type { AddIncidentTodoParams, AddIncidentTodoResponse } from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const addIncidentTodoTool: ToolConfig = { + id: 'datadog_add_incident_todo', + name: 'Datadog Add Incident Todo', + description: + 'Add a follow-up task (todo) to an incident. Requires the Incident Management `incident_write` permission; the Incidents API is in public beta.', + version: '1.0.0', + + params: { + incidentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the incident the todo belongs to', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The follow-up task content (e.g., "Restore lost data")', + }, + assignees: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated assignee handles (e.g., "@jane@example.com,@on-call"). Datadog requires at least one assignee', + }, + dueDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO-8601 timestamp for when the todo should be completed', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl( + params.site, + `/api/v2/incidents/${encodeURIComponent(params.incidentId)}/relationships/todos` + ), + method: 'POST', + headers: datadogHeaders, + body: (params) => { + const attributes: Record = { + content: params.content, + assignees: splitCommaList(params.assignees) ?? [], + incident_id: params.incidentId, + } + if (params.dueDate) attributes.due_date = params.dueDate + + return { data: { type: 'incident_todos', attributes } } + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { todo: { attributes: {} } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + todo: { + id: data.data?.id, + type: data.data?.type, + attributes: data.data?.attributes ?? {}, + }, + }, + } + }, + + outputs: { + todo: { + type: 'object', + description: 'The created incident todo', + properties: { + id: { type: 'string', description: 'Todo UUID' }, + type: { type: 'string', description: 'Resource type (incident_todos)' }, + attributes: { + type: 'object', + description: 'Todo attributes', + properties: { + content: { type: 'string', description: 'Task content' }, + assignees: { type: 'array', description: 'Assignee handles' }, + due_date: { type: 'string', description: 'Due date' }, + completed: { type: 'string', description: 'Completion timestamp' }, + incident_id: { type: 'string', description: 'UUID of the parent incident' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/cancel_downtime.ts b/apps/sim/tools/datadog/cancel_downtime.ts index 89b701fa96d..892ddbc60bc 100644 --- a/apps/sim/tools/datadog/cancel_downtime.ts +++ b/apps/sim/tools/datadog/cancel_downtime.ts @@ -1,4 +1,5 @@ import type { CancelDowntimeParams, CancelDowntimeResponse } from '@/tools/datadog/types' +import { datadogErrorMessage } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const cancelDowntimeTool: ToolConfig = { @@ -37,7 +38,8 @@ export const cancelDowntimeTool: ToolConfig { const site = params.site || 'datadoghq.com' - return `https://api.${site}/api/v2/downtime/${params.downtimeId}` + const downtimeId = encodeURIComponent(String(params.downtimeId).trim()) + return `https://api.${site}/api/v2/downtime/${downtimeId}` }, method: 'DELETE', headers: (params) => ({ @@ -49,13 +51,13 @@ export const cancelDowntimeTool: ToolConfig { if (!response.ok && response.status !== 204) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { success: false, }, - error: errorData.errors?.[0]?.detail || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } diff --git a/apps/sim/tools/datadog/create_dashboard.ts b/apps/sim/tools/datadog/create_dashboard.ts new file mode 100644 index 00000000000..bf0951cb2d2 --- /dev/null +++ b/apps/sim/tools/datadog/create_dashboard.ts @@ -0,0 +1,149 @@ +import type { CreateDashboardParams, CreateDashboardResponse } from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + parseJsonParam, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const createDashboardTool: ToolConfig = { + id: 'datadog_create_dashboard', + name: 'Datadog Create Dashboard', + description: 'Create a dashboard from a title, layout type, and widget definitions.', + version: '1.0.0', + + params: { + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Title of the dashboard', + }, + layoutType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Layout type: "ordered" or "free"', + }, + widgets: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of widget definitions, e.g. [{"definition": {"type": "timeseries", "requests": [{"q": "avg:system.cpu.user{*}"}]}}]', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the dashboard', + }, + notifyList: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated user handles to notify on dashboard changes', + }, + templateVariables: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of template variable definitions, e.g. [{"name": "env", "prefix": "env", "available_values": ["prod"]}]', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated dashboard tags in the form "team:" (max 5)', + }, + reflowType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reflow type for ordered layouts: "auto" or "fixed"', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => datadogApiUrl(params.site, '/api/v1/dashboard'), + method: 'POST', + headers: datadogHeaders, + body: (params) => { + const body: Record = { + title: params.title, + layout_type: params.layoutType, + widgets: parseJsonParam(params.widgets, 'widgets parameter') ?? [], + } + + if (params.description) body.description = params.description + if (params.reflowType) body.reflow_type = params.reflowType + + const notifyList = splitCommaList(params.notifyList) + if (notifyList) body.notify_list = notifyList + + const tags = splitCommaList(params.tags) + if (tags) body.tags = tags + + const templateVariables = parseJsonParam( + params.templateVariables, + 'templateVariables parameter' + ) + if (templateVariables) body.template_variables = templateVariables + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { dashboard: {} }, + error: await datadogErrorMessage(response), + } + } + + return { + success: true, + output: { dashboard: await response.json() }, + } + }, + + outputs: { + dashboard: { + type: 'object', + description: 'The created dashboard', + properties: { + id: { type: 'string', description: 'Dashboard ID' }, + title: { type: 'string', description: 'Dashboard title' }, + layout_type: { type: 'string', description: 'Layout type: ordered or free' }, + url: { type: 'string', description: 'Dashboard URL path' }, + author_handle: { type: 'string', description: 'Handle of the dashboard author' }, + created_at: { type: 'string', description: 'Creation timestamp' }, + modified_at: { type: 'string', description: 'Modification timestamp' }, + widgets: { type: 'array', description: 'Widget definitions' }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/create_downtime.ts b/apps/sim/tools/datadog/create_downtime.ts index 56f53349395..4d9f235d79d 100644 --- a/apps/sim/tools/datadog/create_downtime.ts +++ b/apps/sim/tools/datadog/create_downtime.ts @@ -1,4 +1,9 @@ -import type { CreateDowntimeParams, CreateDowntimeResponse } from '@/tools/datadog/types' +import type { + CreateDowntimeParams, + CreateDowntimeResponse, + DowntimeAttributes, +} from '@/tools/datadog/types' +import { datadogErrorMessage, parseMonitorIds, splitCommaList } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const createDowntimeTool: ToolConfig = { @@ -90,57 +95,62 @@ export const createDowntimeTool: ToolConfig { - const schedule: Record = {} + // A one-time schedule accepts only `start` and `end` (`additionalProperties: false`); + // the timezone is a display-only attribute on the downtime itself. + const schedule: { start?: string; end?: string } = {} if (params.start) schedule.start = new Date(params.start * 1000).toISOString() if (params.end) schedule.end = new Date(params.end * 1000).toISOString() - if (params.timezone) schedule.timezone = params.timezone - const body: Record = { - data: { - type: 'downtime', - attributes: { - scope: params.scope, - schedule: Object.keys(schedule).length > 0 ? schedule : undefined, - }, - }, - } + const monitorTags = splitCommaList(params.monitorTags) + const monitorId = parseMonitorIds(params.monitorId)?.[0] - if (params.message) body.data.attributes.message = params.message - if (params.muteFirstRecoveryNotification !== undefined) { - body.data.attributes.mute_first_recovery_notification = params.muteFirstRecoveryNotification + /** + * `monitor_identifier` is required and is a `oneOf`: a downtime targets either a + * single monitor or a tag set, never both. Accepting both silently would drop one + * of them and mute a different set of monitors than the caller asked for. Both + * sides are compared after parsing so a blank or whitespace-only input, which is + * how an untouched field arrives, does not read as a chosen target. + */ + if (monitorId !== undefined && monitorTags) { + throw new Error( + 'Supply either a monitor ID or monitor tags, not both — a downtime targets one or the other' + ) } - if (params.monitorId) { - body.data.attributes.monitor_identifier = { - monitor_id: Number.parseInt(params.monitorId, 10), - } - } else if (params.monitorTags) { - body.data.attributes.monitor_identifier = { - monitor_tags: params.monitorTags - .split(',') - .map((t: string) => t.trim()) - .filter((t: string) => t.length > 0), - } + // Datadog expresses "every monitor in scope" as the `*` monitor tag, which is the + // fallback when no monitor is named. + const monitorIdentifier = + monitorId !== undefined ? { monitor_id: monitorId } : { monitor_tags: monitorTags ?? ['*'] } + + const attributes: Record = { + scope: params.scope, + monitor_identifier: monitorIdentifier, + } + if (Object.keys(schedule).length > 0) attributes.schedule = schedule + if (params.timezone) attributes.display_timezone = params.timezone + if (params.message) attributes.message = params.message + if (params.muteFirstRecoveryNotification !== undefined) { + attributes.mute_first_recovery_notification = params.muteFirstRecoveryNotification } - return body + return { data: { type: 'downtime', attributes } } }, }, transformResponse: async (response: Response) => { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { - downtime: {} as any, + downtime: { scope: [] }, }, - error: errorData.errors?.[0]?.detail || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } const data = await response.json() - const attrs = data.data?.attributes || {} + const attrs: DowntimeAttributes = data.data?.attributes || {} return { success: true, output: { @@ -152,8 +162,7 @@ export const createDowntimeTool: ToolConfig = { @@ -91,7 +97,17 @@ export const createEventTool: ToolConfig 'DD-API-KEY': params.apiKey, }), body: (params) => { - const body: Record = { + const body: { + title: string + text: string + alert_type?: EventAlertType + priority?: EventPriority + host?: string + aggregation_key?: string + source_type_name?: string + date_happened?: number + tags?: string[] + } = { title: params.title, text: params.text, } @@ -116,13 +132,13 @@ export const createEventTool: ToolConfig transformResponse: async (response: Response) => { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { - event: {} as any, + event: {}, }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } diff --git a/apps/sim/tools/datadog/create_incident.ts b/apps/sim/tools/datadog/create_incident.ts new file mode 100644 index 00000000000..5e6a2d71148 --- /dev/null +++ b/apps/sim/tools/datadog/create_incident.ts @@ -0,0 +1,162 @@ +import type { CreateIncidentParams, CreateIncidentResponse } from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + parseJsonParam, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const createIncidentTool: ToolConfig = { + id: 'datadog_create_incident', + name: 'Datadog Create Incident', + description: + 'Declare a new incident. Requires the Incident Management `incident_write` permission; the Incidents API is in public beta.', + version: '1.0.0', + + params: { + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Title of the incident summarizing what happened', + }, + customerImpacted: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'Whether the incident caused customer impact', + }, + severity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Incident severity: UNKNOWN, SEV-0, SEV-1, SEV-2, SEV-3, SEV-4, or SEV-5', + }, + customerImpactScope: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Summary of the customer impact. Required when customerImpacted is true', + }, + incidentTypeUuid: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'UUID of the incident type. The default incident type is used when omitted', + }, + isTest: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether this is a test incident', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of user-defined incident fields, e.g. {"severity": {"type": "dropdown", "value": "SEV-2"}}', + }, + notificationHandles: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated handles to notify on creation (e.g., "@slack-incidents,@user@example.com")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => datadogApiUrl(params.site, '/api/v2/incidents'), + method: 'POST', + headers: datadogHeaders, + body: (params) => { + const fields = + parseJsonParam>(params.fields, 'fields parameter') ?? {} + if (params.severity) { + fields.severity = { type: 'dropdown', value: params.severity } + } + + const attributes: Record = { + title: params.title, + customer_impacted: params.customerImpacted, + } + if (params.customerImpactScope) attributes.customer_impact_scope = params.customerImpactScope + if (params.incidentTypeUuid) attributes.incident_type_uuid = params.incidentTypeUuid + if (params.isTest !== undefined) attributes.is_test = params.isTest + if (Object.keys(fields).length > 0) attributes.fields = fields + + const handles = splitCommaList(params.notificationHandles) + if (handles) { + attributes.notification_handles = handles.map((handle) => ({ handle })) + } + + return { data: { type: 'incidents', attributes } } + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { incident: { id: '', attributes: {} } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + incident: { + id: data.data?.id, + type: data.data?.type, + attributes: data.data?.attributes ?? {}, + }, + }, + } + }, + + outputs: { + incident: { + type: 'object', + description: 'The created incident', + properties: { + id: { type: 'string', description: 'Incident UUID' }, + type: { type: 'string', description: 'Resource type (incidents)' }, + attributes: { + type: 'object', + description: 'Incident attributes', + properties: { + title: { type: 'string', description: 'Incident title' }, + public_id: { type: 'number', description: 'Incremental public incident ID' }, + customer_impacted: { type: 'boolean', description: 'Whether customers were impacted' }, + created: { type: 'string', description: 'Creation timestamp' }, + modified: { type: 'string', description: 'Last modification timestamp' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/create_monitor.ts b/apps/sim/tools/datadog/create_monitor.ts index d08da95eae4..d2d99e3882c 100644 --- a/apps/sim/tools/datadog/create_monitor.ts +++ b/apps/sim/tools/datadog/create_monitor.ts @@ -1,4 +1,5 @@ -import type { CreateMonitorParams, CreateMonitorResponse } from '@/tools/datadog/types' +import type { CreateMonitorParams, CreateMonitorResponse, MonitorType } from '@/tools/datadog/types' +import { datadogErrorMessage, parseJsonParam } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const createMonitorTool: ToolConfig = { @@ -86,7 +87,15 @@ export const createMonitorTool: ToolConfig { - const body: Record = { + const body: { + name: string + type: MonitorType + query: string + message?: string + priority?: number + tags?: string[] + options?: unknown + } = { name: params.name, type: params.type, query: params.query, @@ -102,14 +111,8 @@ export const createMonitorTool: ToolConfig t.length > 0) } - if (params.options) { - try { - body.options = - typeof params.options === 'string' ? JSON.parse(params.options) : params.options - } catch { - // If options parsing fails, skip it - } - } + const options = parseJsonParam>(params.options, 'options parameter') + if (options) body.options = options return body }, @@ -117,13 +120,13 @@ export const createMonitorTool: ToolConfig { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { - monitor: {} as any, + monitor: {}, }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } @@ -164,6 +167,11 @@ export const createMonitorTool: ToolConfig = { + id: 'datadog_create_slo', + name: 'Datadog Create SLO', + description: + 'Create a service level objective from a metric query, monitors, or a time-slice condition.', + version: '1.0.0', + + params: { + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the SLO (e.g., "Checkout API availability")', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'SLO type: "metric" (supply query) or "monitor" (supply monitorIds). Time-slice SLOs are not supported here because they need an SLI specification this tool does not send.', + }, + thresholds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of thresholds, e.g. [{"timeframe": "30d", "target": 99.9, "warning": 99.95}]', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the SLO', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated tags (e.g., "env:prod,team:core")', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'For metric SLOs, JSON with numerator and denominator, e.g. {"numerator": "sum:requests{status:ok}.as_count()", "denominator": "sum:requests{*}.as_count()"}', + }, + monitorIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'For monitor SLOs, comma-separated monitor IDs (e.g., "123,456")', + }, + groups: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'For monitor SLOs with a single monitor, comma-separated monitor groups (e.g., "env:prod,role:mysql")', + }, + targetThreshold: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Primary target threshold (e.g., 99.9)', + }, + warningThreshold: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Primary warning threshold, must be greater than the target (e.g., 99.95)', + }, + timeframe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Primary timeframe: "7d", "30d", or "90d"', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => datadogApiUrl(params.site, '/api/v1/slo'), + method: 'POST', + headers: datadogHeaders, + body: buildSloPayload, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { slo: data.data?.[0] ?? { id: '', name: '', type: '' } }, + } + }, + + outputs: { + slo: { + type: 'object', + description: 'The created service level objective', + properties: { + id: { type: 'string', description: 'SLO ID' }, + name: { type: 'string', description: 'SLO name' }, + type: { type: 'string', description: 'SLO type' }, + description: { type: 'string', description: 'SLO description' }, + tags: { type: 'array', description: 'SLO tags' }, + thresholds: { type: 'array', description: 'Timeframe targets and warnings' }, + created_at: { type: 'number', description: 'Creation timestamp (Unix seconds)' }, + modified_at: { type: 'number', description: 'Modification timestamp (Unix seconds)' }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/datadog.test.ts b/apps/sim/tools/datadog/datadog.test.ts new file mode 100644 index 00000000000..e8b31df7daa --- /dev/null +++ b/apps/sim/tools/datadog/datadog.test.ts @@ -0,0 +1,533 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelDowntimeTool } from '@/tools/datadog/cancel_downtime' +import { createDowntimeTool } from '@/tools/datadog/create_downtime' +import { createEventTool } from '@/tools/datadog/create_event' +import { createMonitorTool } from '@/tools/datadog/create_monitor' +import { getIncidentTool } from '@/tools/datadog/get_incident' +import { getMonitorTool } from '@/tools/datadog/get_monitor' +import { listDashboardsTool } from '@/tools/datadog/list_dashboards' +import { listDowntimesTool } from '@/tools/datadog/list_downtimes' +import { listIncidentsTool } from '@/tools/datadog/list_incidents' +import { listMonitorsTool } from '@/tools/datadog/list_monitors' +import { muteMonitorTool } from '@/tools/datadog/mute_monitor' +import { queryLogsTool } from '@/tools/datadog/query_logs' +import { queryTimeseriesTool } from '@/tools/datadog/query_timeseries' +import { sendLogsTool } from '@/tools/datadog/send_logs' +import { submitMetricsTool } from '@/tools/datadog/submit_metrics' +import { unmuteMonitorTool } from '@/tools/datadog/unmute_monitor' +import { updateIncidentTool } from '@/tools/datadog/update_incident' +import { updateSloTool } from '@/tools/datadog/update_slo' +import { + buildSloPayload, + datadogErrorMessage, + mergeSloUpdatePayload, + splitCommaList, +} from '@/tools/datadog/utils' + +const auth = { apiKey: 'key', applicationKey: 'app-key' } as const + +function jsonResponse(body: unknown, init?: { status?: number; statusText?: string }): Response { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + statusText: init?.statusText ?? 'OK', + headers: { 'Content-Type': 'application/json' }, + }) +} + +function callBody( + tool: { request: { body?: (params: TParams) => unknown } }, + params: TParams +): any { + return tool.request.body?.(params) +} + +function callUrl(tool: { request: { url: unknown } }, params: TParams): string { + const url = tool.request.url + return typeof url === 'function' ? url(params) : (url as string) +} + +describe('submit_metrics metric intake types', () => { + /** + * Datadog `MetricIntakeType` is 0 unspecified, 1 count, 2 rate, 3 gauge. Swapping + * these silently changes how Datadog aggregates the series. + */ + it.each([ + ['count', 1], + ['rate', 2], + ['gauge', 3], + ])('encodes %s as %i', (type, code) => { + const body = callBody(submitMetricsTool, { + ...auth, + series: JSON.stringify([{ metric: 'm', type, points: [{ timestamp: 1, value: 2 }] }]), + } as any) + expect(body.series[0].type).toBe(code) + }) + + it('omits type when the caller did not supply one', () => { + const body = callBody(submitMetricsTool, { + ...auth, + series: JSON.stringify([{ metric: 'm', points: [{ timestamp: 1, value: 2 }] }]), + } as any) + expect(body.series[0]).not.toHaveProperty('type') + }) + + it('does not invent a resources entry', () => { + const body = callBody(submitMetricsTool, { + ...auth, + series: JSON.stringify([{ metric: 'm', points: [{ timestamp: 1, value: 2 }] }]), + } as any) + expect(body.series[0]).not.toHaveProperty('resources') + }) + + it('forwards interval, which Datadog requires for count and rate metrics', () => { + const body = callBody(submitMetricsTool, { + ...auth, + series: JSON.stringify([ + { metric: 'm', type: 'count', interval: 20, points: [{ timestamp: 1, value: 2 }] }, + ]), + } as any) + expect(body.series[0].interval).toBe(20) + }) +}) + +describe('SLO payloads', () => { + it('rejects blank thresholds instead of sending an empty array', () => { + expect(() => + buildSloPayload({ ...auth, name: 'n', type: 'metric', thresholds: '' } as any) + ).toThrow(/thresholds must be a non-empty JSON array/) + }) + + it('rejects a non-numeric monitor id instead of sending null', () => { + expect(() => + buildSloPayload({ + ...auth, + name: 'n', + type: 'monitor', + thresholds: '[{"timeframe":"30d","target":99.9}]', + monitorIds: '123,abc', + } as any) + ).toThrow(/monitorIds must be a comma-separated list of whole numbers/) + }) + + /** + * `PUT /api/v1/slo/{slo_id}` is a full replacement, so every stored field the user + * did not edit has to be replayed or Datadog erases it. + */ + it('preserves stored fields the caller left blank', () => { + const stored = { + id: 'slo-1', + created_at: 1, + modified_at: 2, + creator: { email: 'a@b.c' }, + monitor_tags: ['env:prod'], + name: 'Old name', + type: 'monitor', + thresholds: [{ timeframe: '30d', target: 99.9 }], + description: 'keep me', + tags: ['team:core'], + monitor_ids: [123], + groups: ['env:prod'], + target_threshold: 99.9, + timeframe: '30d', + } + + const body = mergeSloUpdatePayload(stored, { ...auth, sloId: 'slo-1', name: 'New name' } as any) + + expect(body.name).toBe('New name') + expect(body.description).toBe('keep me') + expect(body.tags).toEqual(['team:core']) + expect(body.monitor_ids).toEqual([123]) + expect(body.groups).toEqual(['env:prod']) + expect(body.thresholds).toEqual([{ timeframe: '30d', target: 99.9 }]) + expect(body.target_threshold).toBe(99.9) + expect(body.timeframe).toBe('30d') + }) + + it('strips fields Datadog computes and rejects on update', () => { + const body = mergeSloUpdatePayload( + { id: 'slo-1', created_at: 1, modified_at: 2, creator: {}, monitor_tags: [], name: 'n' }, + { ...auth, sloId: 'slo-1' } as any + ) + for (const field of ['id', 'created_at', 'modified_at', 'creator', 'monitor_tags']) { + expect(body).not.toHaveProperty(field) + } + }) +}) + +describe('update_slo read-modify-write', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('reads the stored SLO before replacing it', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + jsonResponse({ data: { id: 'slo-1', name: 'Old', type: 'metric', description: 'keep' } }) + ) + .mockResolvedValueOnce(jsonResponse({ data: [{ id: 'slo-1', name: 'New' }] })) + + const result = await updateSloTool.directExecution!( + { ...auth, sloId: 'slo-1', name: 'New' } as any, + undefined + ) + + expect(result.success).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0][1]?.method).toBe('GET') + + const putBody = JSON.parse(String(fetchMock.mock.calls[1][1]?.body)) + expect(putBody.name).toBe('New') + expect(putBody.description).toBe('keep') + }) + + it('does not write when the stored SLO cannot be read', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ errors: ['SLO not found'] }, { status: 404 })) + + const result = await updateSloTool.directExecution!( + { ...auth, sloId: 'missing', name: 'New' } as any, + undefined + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('SLO not found') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('datadogErrorMessage', () => { + it('reads v1 string errors', async () => { + await expect( + datadogErrorMessage(jsonResponse({ errors: ['bad query'] }, { status: 400 })) + ).resolves.toBe('bad query') + }) + + it('reads v2 JSON:API error objects', async () => { + await expect( + datadogErrorMessage(jsonResponse({ errors: [{ detail: 'forbidden' }] }, { status: 403 })) + ).resolves.toBe('forbidden') + }) + + /** The SLO delete conflict returns errors as a map of resource id to reason. */ + it('reads dictionary-shaped errors', async () => { + await expect( + datadogErrorMessage( + jsonResponse({ errors: { 'slo-1': 'still referenced by a dashboard' } }, { status: 409 }) + ) + ).resolves.toBe('still referenced by a dashboard') + }) + + it('falls back to the status line when no message is present', async () => { + await expect( + datadogErrorMessage(jsonResponse({}, { status: 500, statusText: 'Server Error' })) + ).resolves.toBe('HTTP 500: Server Error') + }) +}) + +describe('send_logs', () => { + /** Datadog treats unrecognized keys as the log's structured attributes. */ + it('preserves custom attributes', () => { + const body = callBody(sendLogsTool, { + ...auth, + logs: JSON.stringify([{ message: 'boom', status: 'error', orderId: 42 }]), + } as any) + expect(body[0].status).toBe('error') + expect(body[0].orderId).toBe(42) + }) + + it('does not pad absent optional fields with empty strings', () => { + const body = callBody(sendLogsTool, { + ...auth, + logs: JSON.stringify([{ message: 'boom' }]), + } as any) + expect(body[0]).not.toHaveProperty('hostname') + expect(body[0]).not.toHaveProperty('service') + expect(body[0]).not.toHaveProperty('ddtags') + }) +}) + +describe('incident include parameter', () => { + /** The spec enum is exact, so a space from a comma-separated input 400s. */ + it('trims spaces in get_incident', () => { + const url = callUrl(getIncidentTool, { + ...auth, + incidentId: 'abc', + include: 'users, attachments', + } as any) + expect(url).toContain('include=users%2Cattachments') + expect(url).not.toContain('%20') + }) + + it('trims spaces in list_incidents', () => { + const url = callUrl(listIncidentsTool, { ...auth, include: 'users, attachments' } as any) + expect(url).toContain('include=users%2Cattachments') + expect(url).not.toContain('%20') + }) +}) + +describe('update_incident blank handling', () => { + /** A blank input must not blank the stored incident under a partial update. */ + it('omits empty strings rather than overwriting stored values', () => { + const body = callBody(updateIncidentTool, { + ...auth, + incidentId: 'abc', + title: '', + customerImpactScope: '', + detected: '', + } as any) + expect(body.data.attributes).not.toHaveProperty('title') + expect(body.data.attributes).not.toHaveProperty('customer_impact_scope') + expect(body.data.attributes).not.toHaveProperty('detected') + }) + + it('still sends supplied values', () => { + const body = callBody(updateIncidentTool, { + ...auth, + incidentId: 'abc', + title: 'Real title', + } as any) + expect(body.data.id).toBe('abc') + expect(body.data.type).toBe('incidents') + expect(body.data.attributes.title).toBe('Real title') + }) +}) + +describe('create_monitor options', () => { + /** Silently dropping malformed options created monitors with no thresholds. */ + it('fails loudly on malformed options JSON', () => { + expect(() => + callBody(createMonitorTool, { + ...auth, + name: 'n', + type: 'metric alert', + query: 'q', + options: '{not json', + } as any) + ).toThrow(/options parameter must be valid JSON/) + }) +}) + +describe('query_timeseries', () => { + /** Datadog reports query failures with a 200 and a non-ok status. */ + it('surfaces a non-ok status as a failure', async () => { + const result = await queryTimeseriesTool.transformResponse!( + jsonResponse({ status: 'error', error: 'invalid query', series: [] }) + ) + expect(result.success).toBe(false) + expect(result.error).toContain('invalid query') + }) + + it('passes through a successful query', async () => { + const result = await queryTimeseriesTool.transformResponse!( + jsonResponse({ status: 'ok', series: [{ metric: 'm', tag_set: [], pointlist: [[1000, 5]] }] }) + ) + expect(result.success).toBe(true) + expect(result.output.series[0].points[0]).toEqual({ timestamp: 1, value: 5 }) + }) +}) + +describe('pagination wiring', () => { + it('sends downtime page params', () => { + const url = callUrl(listDowntimesTool, { ...auth, limit: 50, offset: 100 } as any) + expect(url).toContain('page%5Blimit%5D=50') + expect(url).toContain('page%5Boffset%5D=100') + }) + + it('feeds a log search cursor back into the request', () => { + const body = callBody(queryLogsTool, { + ...auth, + query: '*', + from: 'now-1h', + to: 'now', + cursor: 'abc123', + } as any) + expect(body.page.cursor).toBe('abc123') + }) +}) + +describe('monitor mute and unmute', () => { + it('mutes with the scope and end datadogpy documents', () => { + const body = callBody(muteMonitorTool, { + ...auth, + monitorId: '123', + scope: 'host:web-1', + end: 1705323600, + } as any) + expect(body).toEqual({ scope: 'host:web-1', end: 1705323600 }) + expect(callUrl(muteMonitorTool, { ...auth, monitorId: '123' } as any)).toContain( + '/api/v1/monitor/123/mute' + ) + }) + + /** An indefinite mute sends no `end`, so the monitor stays muted until unmuted. */ + it('omits end when the caller wants an indefinite mute', () => { + const body = callBody(muteMonitorTool, { ...auth, monitorId: '123' } as any) + expect(body).not.toHaveProperty('end') + }) + + /** Muting is only safe to ship because it can be reversed from Sim. */ + it('ships an unmute counterpart that can clear every scope', () => { + const body = callBody(unmuteMonitorTool, { + ...auth, + monitorId: '123', + allScopes: true, + } as any) + expect(body).toEqual({ all_scopes: true }) + expect(callUrl(unmuteMonitorTool, { ...auth, monitorId: '123' } as any)).toContain( + '/api/v1/monitor/123/unmute' + ) + }) +}) + +describe('create_downtime monitor targeting', () => { + /** monitor_identifier is a oneOf, so accepting both would silently drop one. */ + it('rejects a monitor ID and monitor tags together', () => { + expect(() => + callBody(createDowntimeTool, { + ...auth, + scope: '*', + monitorId: '123', + monitorTags: 'team:backend', + } as any) + ).toThrow(/either a monitor ID or monitor tags, not both/) + }) + + it('rejects a non-numeric monitor ID instead of sending null', () => { + expect(() => + callBody(createDowntimeTool, { ...auth, scope: '*', monitorId: 'abc' } as any) + ).toThrow(/monitorIds must be a comma-separated list of whole numbers/) + }) + + it('falls back to the wildcard monitor tag when no monitor is named', () => { + const body = callBody(createDowntimeTool, { ...auth, scope: '*' } as any) + expect(body.data.attributes.monitor_identifier).toEqual({ monitor_tags: ['*'] }) + }) + + it('targets a single monitor by numeric id', () => { + const body = callBody(createDowntimeTool, { ...auth, scope: '*', monitorId: '123' } as any) + expect(body.data.attributes.monitor_identifier).toEqual({ monitor_id: 123 }) + }) + + /** + * A `` reference to get_monitor resolves to a number, and an LLM tool + * call can pass one too, so the parser must not assume a string. + */ + it('accepts a monitor id that arrives as a number', () => { + const body = callBody(createDowntimeTool, { ...auth, scope: '*', monitorId: 123 } as any) + expect(body.data.attributes.monitor_identifier).toEqual({ monitor_id: 123 }) + }) + + /** A blank monitor ID is how an untouched field arrives; it is not a chosen target. */ + it('does not treat a whitespace-only monitor id as a conflicting target', () => { + const body = callBody(createDowntimeTool, { + ...auth, + scope: '*', + monitorId: ' ', + monitorTags: 'team:backend', + } as any) + expect(body.data.attributes.monitor_identifier).toEqual({ monitor_tags: ['team:backend'] }) + }) + + it('accepts monitor tags that arrive as an array', () => { + const body = callBody(createDowntimeTool, { + ...auth, + scope: '*', + monitorTags: ['team:backend', 'priority:high'], + } as any) + expect(body.data.attributes.monitor_identifier).toEqual({ + monitor_tags: ['team:backend', 'priority:high'], + }) + }) +}) + +describe('splitCommaList input tolerance', () => { + it('handles strings, numbers, and arrays without throwing', () => { + expect(splitCommaList('a, b')).toEqual(['a', 'b']) + expect(splitCommaList(123)).toEqual(['123']) + expect(splitCommaList([1, 2])).toEqual(['1', '2']) + expect(splitCommaList(undefined)).toBeUndefined() + expect(splitCommaList('')).toBeUndefined() + }) +}) + +describe('path parameter encoding', () => { + /** A pasted id often carries surrounding whitespace, which would 404 as `%20123`. */ + it('trims and encodes the monitor id in get_monitor', () => { + const url = callUrl(getMonitorTool, { ...auth, monitorId: ' 12 3 ' } as any) + expect(url).toContain('/api/v1/monitor/12%203') + expect(url).not.toContain('/monitor/ 12') + }) + + it('trims and encodes the downtime id in cancel_downtime', () => { + const url = callUrl(cancelDowntimeTool, { ...auth, downtimeId: ' a/b ' } as any) + expect(url).toContain('/api/v2/downtime/a%2Fb') + expect(url).not.toContain('/downtime/ a') + }) +}) + +describe('list_downtimes limit description', () => { + /** + * The Datadog v2 spec declares `default: 30` and `example: 100` but no `maximum`, so the + * description must not present 100 as a vendor-enforced ceiling. + */ + it('does not claim a vendor maximum', () => { + const description = listDowntimesTool.params.limit.description ?? '' + expect(description).not.toMatch(/max:\s*100/) + expect(description).toMatch(/declares no maximum/) + }) +}) + +describe('list_monitors pagination', () => { + /** + * Datadog returns every monitor when `page` is absent, so both page params have to reach + * the request for the page size to have any effect. + */ + it('sends page and page_size', () => { + const url = callUrl(listMonitorsTool, { ...auth, page: 2, pageSize: 50 } as any) + expect(url).toContain('page=2') + expect(url).toContain('page_size=50') + }) +}) + +describe('list_dashboards filters', () => { + /** `filter[shared]` and `filter[deleted]` are incompatible, so an off toggle sends nothing. */ + it('omits both filters when neither is enabled', () => { + const url = callUrl(listDashboardsTool, { ...auth, filterShared: false, filterDeleted: false }) + expect(url).not.toContain('filter%5Bshared%5D') + expect(url).not.toContain('filter%5Bdeleted%5D') + }) + + it('sends only the filter that is enabled', () => { + const url = callUrl(listDashboardsTool, { ...auth, filterShared: true, filterDeleted: false }) + expect(url).toContain('filter%5Bshared%5D=true') + expect(url).not.toContain('filter%5Bdeleted%5D') + }) +}) + +describe('submit_metrics errors output', () => { + /** `errors` is the only signal that Datadog rejected part of an accepted submission. */ + it('reports errors on the success path', async () => { + const result = await submitMetricsTool.transformResponse!( + jsonResponse({ errors: ['metric name too long'] }) + ) + expect(result.success).toBe(true) + expect(result.output.errors).toEqual(['metric name too long']) + }) + + it('reports errors on the failure path', async () => { + const result = await submitMetricsTool.transformResponse!( + jsonResponse({ errors: ['bad payload'] }, { status: 400 }) + ) + expect(result.success).toBe(false) + expect(result.output.errors).toEqual(['bad payload']) + }) +}) + +describe('registry surface', () => { + it('keeps create_event on api-key-only auth', () => { + expect(createEventTool.params.applicationKey).toBeUndefined() + }) +}) diff --git a/apps/sim/tools/datadog/delete_dashboard.ts b/apps/sim/tools/datadog/delete_dashboard.ts new file mode 100644 index 00000000000..1d9411d69db --- /dev/null +++ b/apps/sim/tools/datadog/delete_dashboard.ts @@ -0,0 +1,76 @@ +import type { DeleteDashboardParams, DeleteDashboardResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteDashboardTool: ToolConfig = { + id: 'datadog_delete_dashboard', + name: 'Datadog Delete Dashboard', + description: 'Delete a dashboard by ID.', + version: '1.0.0', + + params: { + dashboardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the dashboard to delete', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl(params.site, `/api/v1/dashboard/${encodeURIComponent(params.dashboardId)}`), + method: 'DELETE', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json().catch(() => ({})) + + return { + success: true, + output: { + success: true, + deletedDashboardId: data.deleted_dashboard_id, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the dashboard was deleted', + }, + deletedDashboardId: { + type: 'string', + description: 'ID of the deleted dashboard', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/delete_slo.ts b/apps/sim/tools/datadog/delete_slo.ts new file mode 100644 index 00000000000..f0c9b672a46 --- /dev/null +++ b/apps/sim/tools/datadog/delete_slo.ts @@ -0,0 +1,88 @@ +import type { DeleteSloParams, DeleteSloResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteSloTool: ToolConfig = { + id: 'datadog_delete_slo', + name: 'Datadog Delete SLO', + description: + 'Permanently delete a service level objective. Datadog returns a conflict when the SLO is still referenced by a dashboard.', + version: '1.0.0', + + params: { + sloId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the service level objective to delete', + }, + force: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Delete even when the SLO is referenced by other resources', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryString = params.force ? '?force=true' : '' + return datadogApiUrl( + params.site, + `/api/v1/slo/${encodeURIComponent(params.sloId)}${queryString}` + ) + }, + method: 'DELETE', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { success: false, deletedIds: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json().catch(() => ({})) + + return { + success: true, + output: { + success: true, + deletedIds: data.data ?? [], + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the SLO was deleted', + }, + deletedIds: { + type: 'array', + description: 'IDs of the deleted service level objectives', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/datadog/get_browser_synthetics_results.ts b/apps/sim/tools/datadog/get_browser_synthetics_results.ts new file mode 100644 index 00000000000..d4986798ca1 --- /dev/null +++ b/apps/sim/tools/datadog/get_browser_synthetics_results.ts @@ -0,0 +1,145 @@ +import type { + GetBrowserSyntheticsResultsParams, + GetBrowserSyntheticsResultsResponse, +} from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getBrowserSyntheticsResultsTool: ToolConfig< + GetBrowserSyntheticsResultsParams, + GetBrowserSyntheticsResultsResponse +> = { + id: 'datadog_get_browser_synthetics_results', + name: 'Datadog Get Browser Synthetic Test Results', + description: + 'Get the latest result summaries (up to the last 150 runs) for a Synthetic browser test, including step counts and errors.', + version: '1.0.0', + + params: { + publicId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The public ID of the Synthetic browser test', + }, + fromTs: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Timestamp in milliseconds from which to start querying results', + }, + toTs: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Timestamp in milliseconds up to which to query results', + }, + probeDc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated locations to query results for (e.g., "aws:eu-west-3")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.fromTs !== undefined) queryParams.set('from_ts', String(params.fromTs)) + if (params.toTs !== undefined) queryParams.set('to_ts', String(params.toTs)) + for (const location of splitCommaList(params.probeDc) ?? []) { + queryParams.append('probe_dc', location) + } + const queryString = queryParams.toString() + return datadogApiUrl( + params.site, + `/api/v1/synthetics/tests/browser/${encodeURIComponent(params.publicId)}/results${ + queryString ? `?${queryString}` : '' + }` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { results: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + results: data.results ?? [], + lastTimestampFetched: data.last_timestamp_fetched, + }, + } + }, + + outputs: { + results: { + type: 'array', + description: 'Latest browser test result summaries', + items: { + type: 'object', + properties: { + result_id: { type: 'string', description: 'ID of the browser test result' }, + check_time: { type: 'number', description: 'Time the browser test ran' }, + probe_dc: { type: 'string', description: 'Location the browser test ran from' }, + status: { + type: 'number', + description: 'Monitor status: 0 not triggered, 1 triggered, 2 no data', + }, + result: { + type: 'object', + description: 'Run outcome', + properties: { + duration: { type: 'number', description: 'Length of the run in milliseconds' }, + errorCount: { type: 'number', description: 'Number of errors collected in the run' }, + stepCountCompleted: { + type: 'number', + description: 'Steps completed before failing', + }, + stepCountTotal: { type: 'number', description: 'Total number of steps' }, + device: { type: 'object', description: 'Device the run was performed on' }, + }, + }, + }, + }, + }, + lastTimestampFetched: { + type: 'number', + description: 'Timestamp of the latest browser test run', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/get_dashboard.ts b/apps/sim/tools/datadog/get_dashboard.ts new file mode 100644 index 00000000000..b3eb9c4ad3f --- /dev/null +++ b/apps/sim/tools/datadog/get_dashboard.ts @@ -0,0 +1,81 @@ +import type { GetDashboardParams, GetDashboardResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getDashboardTool: ToolConfig = { + id: 'datadog_get_dashboard', + name: 'Datadog Get Dashboard', + description: 'Get the full definition of a dashboard, including its widgets.', + version: '1.0.0', + + params: { + dashboardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the dashboard (e.g., "abc-def-ghi")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl(params.site, `/api/v1/dashboard/${encodeURIComponent(params.dashboardId)}`), + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { dashboard: {} }, + error: await datadogErrorMessage(response), + } + } + + return { + success: true, + output: { dashboard: await response.json() }, + } + }, + + outputs: { + dashboard: { + type: 'object', + description: 'The dashboard definition', + properties: { + id: { type: 'string', description: 'Dashboard ID' }, + title: { type: 'string', description: 'Dashboard title' }, + description: { type: 'string', description: 'Dashboard description' }, + layout_type: { type: 'string', description: 'Layout type: ordered or free' }, + url: { type: 'string', description: 'Dashboard URL path' }, + author_handle: { type: 'string', description: 'Handle of the dashboard author' }, + author_name: { type: 'string', description: 'Name of the dashboard author' }, + created_at: { type: 'string', description: 'Creation timestamp' }, + modified_at: { type: 'string', description: 'Modification timestamp' }, + tags: { type: 'array', description: 'Dashboard tags' }, + notify_list: { type: 'array', description: 'Handles notified on dashboard changes' }, + template_variables: { type: 'array', description: 'Template variable definitions' }, + widgets: { type: 'array', description: 'Widget definitions' }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/get_incident.ts b/apps/sim/tools/datadog/get_incident.ts new file mode 100644 index 00000000000..8574c6438c8 --- /dev/null +++ b/apps/sim/tools/datadog/get_incident.ts @@ -0,0 +1,115 @@ +import type { GetIncidentParams, GetIncidentResponse } from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getIncidentTool: ToolConfig = { + id: 'datadog_get_incident', + name: 'Datadog Get Incident', + description: + 'Get the details of a single incident by ID. Requires the Incident Management `incident_read` permission; the Incidents API is in public beta.', + version: '1.0.0', + + params: { + incidentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the incident (e.g., "00000000-0000-0000-1234-000000000000")', + }, + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated related resources to include (e.g., "users", "attachments")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const include = splitCommaList(params.include)?.join(',') + const queryString = include ? `?${new URLSearchParams({ include }).toString()}` : '' + return datadogApiUrl( + params.site, + `/api/v2/incidents/${encodeURIComponent(params.incidentId)}${queryString}` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { incident: { id: '', attributes: {} } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + incident: { + id: data.data?.id, + type: data.data?.type, + attributes: data.data?.attributes ?? {}, + }, + }, + } + }, + + outputs: { + incident: { + type: 'object', + description: 'The incident', + properties: { + id: { type: 'string', description: 'Incident UUID' }, + type: { type: 'string', description: 'Resource type (incidents)' }, + attributes: { + type: 'object', + description: 'Incident attributes', + properties: { + title: { type: 'string', description: 'Incident title' }, + state: { type: 'string', description: 'Incident state' }, + severity: { type: 'string', description: 'Incident severity' }, + public_id: { type: 'number', description: 'Incremental public incident ID' }, + customer_impacted: { type: 'boolean', description: 'Whether customers were impacted' }, + customer_impact_scope: { + type: 'string', + description: 'Summary of the customer impact', + }, + created: { type: 'string', description: 'Creation timestamp' }, + modified: { type: 'string', description: 'Last modification timestamp' }, + resolved: { type: 'string', description: 'Resolution timestamp' }, + time_to_resolve: { type: 'number', description: 'Seconds from creation to resolution' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/get_monitor.ts b/apps/sim/tools/datadog/get_monitor.ts index 0a65208fc8d..2d69e114e39 100644 --- a/apps/sim/tools/datadog/get_monitor.ts +++ b/apps/sim/tools/datadog/get_monitor.ts @@ -1,4 +1,5 @@ import type { GetMonitorParams, GetMonitorResponse } from '@/tools/datadog/types' +import { datadogErrorMessage } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const getMonitorTool: ToolConfig = { @@ -19,7 +20,7 @@ export const getMonitorTool: ToolConfig = required: false, visibility: 'user-or-llm', description: - 'Comma-separated group states to include (e.g., "alert,warn", "alert,warn,no data,ok")', + 'Comma-separated group states to include. Valid values are "all", "alert", "warn", and "no data" (e.g., "alert,warn").', }, withDowntimes: { type: 'boolean', @@ -55,8 +56,9 @@ export const getMonitorTool: ToolConfig = if (params.groupStates) queryParams.set('group_states', params.groupStates) if (params.withDowntimes) queryParams.set('with_downtimes', 'true') + const monitorId = encodeURIComponent(String(params.monitorId).trim()) const queryString = queryParams.toString() - return `https://api.${site}/api/v1/monitor/${params.monitorId}${queryString ? `?${queryString}` : ''}` + return `https://api.${site}/api/v1/monitor/${monitorId}${queryString ? `?${queryString}` : ''}` }, method: 'GET', headers: (params) => ({ @@ -68,13 +70,13 @@ export const getMonitorTool: ToolConfig = transformResponse: async (response: Response) => { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { - monitor: {} as any, + monitor: {}, }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } @@ -115,6 +117,11 @@ export const getMonitorTool: ToolConfig = overall_state: { type: 'string', description: 'Current monitor state' }, created: { type: 'string', description: 'Creation timestamp' }, modified: { type: 'string', description: 'Last modification timestamp' }, + options: { + type: 'json', + description: 'Monitor options (thresholds, notification settings)', + }, + creator: { type: 'json', description: 'Monitor creator (email, handle, name)' }, }, }, }, diff --git a/apps/sim/tools/datadog/get_security_signal.ts b/apps/sim/tools/datadog/get_security_signal.ts new file mode 100644 index 00000000000..d5eee359db6 --- /dev/null +++ b/apps/sim/tools/datadog/get_security_signal.ts @@ -0,0 +1,93 @@ +import type { GetSecuritySignalParams, GetSecuritySignalResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSecuritySignalTool: ToolConfig = + { + id: 'datadog_get_security_signal', + name: 'Datadog Get Security Signal', + description: + 'Get the details of a single Cloud SIEM security signal. Requires the `security_monitoring_signals_read` permission.', + version: '1.0.0', + + params: { + signalId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the security signal', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl( + params.site, + `/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}` + ), + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { signal: { attributes: {} } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + signal: { + id: data.data?.id, + type: data.data?.type, + attributes: data.data?.attributes ?? {}, + }, + }, + } + }, + + outputs: { + signal: { + type: 'object', + description: 'The security signal', + properties: { + id: { type: 'string', description: 'Signal ID' }, + type: { type: 'string', description: 'Resource type (signal)' }, + attributes: { + type: 'object', + description: 'Signal attributes', + properties: { + message: { type: 'string', description: 'Message from the detection rule' }, + timestamp: { type: 'string', description: 'Signal timestamp' }, + tags: { type: 'array', description: 'Tags on the signal' }, + custom: { type: 'object', description: 'Signal-specific attributes' }, + }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/datadog/get_slo.ts b/apps/sim/tools/datadog/get_slo.ts new file mode 100644 index 00000000000..f902b503ca6 --- /dev/null +++ b/apps/sim/tools/datadog/get_slo.ts @@ -0,0 +1,99 @@ +import type { GetSloParams, GetSloResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSloTool: ToolConfig = { + id: 'datadog_get_slo', + name: 'Datadog Get SLO', + description: 'Get the configuration of a single service level objective by ID.', + version: '1.0.0', + + params: { + sloId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the service level objective', + }, + withConfiguredAlertIds: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include the IDs of SLO monitors that reference this SLO', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryString = params.withConfiguredAlertIds ? '?with_configured_alert_ids=true' : '' + return datadogApiUrl( + params.site, + `/api/v1/slo/${encodeURIComponent(params.sloId)}${queryString}` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { slo: data.data ?? { id: '', name: '', type: '' } }, + } + }, + + outputs: { + slo: { + type: 'object', + description: 'The service level objective', + properties: { + id: { type: 'string', description: 'SLO ID' }, + name: { type: 'string', description: 'SLO name' }, + type: { type: 'string', description: 'SLO type: metric, monitor, or time_slice' }, + description: { type: 'string', description: 'SLO description' }, + tags: { type: 'array', description: 'SLO tags' }, + thresholds: { type: 'array', description: 'Timeframe targets and warnings' }, + target_threshold: { type: 'number', description: 'Primary target threshold' }, + warning_threshold: { type: 'number', description: 'Primary warning threshold' }, + timeframe: { type: 'string', description: 'Primary timeframe' }, + monitor_ids: { type: 'array', description: 'Monitor IDs for monitor-based SLOs' }, + groups: { type: 'array', description: 'Monitor groups narrowing the SLO scope' }, + configured_alert_ids: { + type: 'array', + description: 'SLO monitor IDs referencing this SLO', + optional: true, + }, + created_at: { type: 'number', description: 'Creation timestamp (Unix seconds)' }, + modified_at: { type: 'number', description: 'Modification timestamp (Unix seconds)' }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/get_slo_history.ts b/apps/sim/tools/datadog/get_slo_history.ts new file mode 100644 index 00000000000..01199c0322c --- /dev/null +++ b/apps/sim/tools/datadog/get_slo_history.ts @@ -0,0 +1,134 @@ +import type { GetSloHistoryParams, GetSloHistoryResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSloHistoryTool: ToolConfig = { + id: 'datadog_get_slo_history', + name: 'Datadog Get SLO History', + description: + 'Get an SLO’s history over a time window, including the overall SLI value and remaining error budget.', + version: '1.0.0', + + params: { + sloId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the service level objective', + }, + fromTs: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Start of the query window as a Unix timestamp in seconds', + }, + toTs: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'End of the query window as a Unix timestamp in seconds', + }, + target: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'SLO target between 0 and 100. When supplied, the response includes the remaining error budget for a custom timeframe', + }, + applyCorrection: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Whether to apply SLO corrections (defaults to true)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams({ + from_ts: String(params.fromTs), + to_ts: String(params.toTs), + }) + if (params.target !== undefined) queryParams.set('target', String(params.target)) + if (params.applyCorrection !== undefined) + queryParams.set('apply_correction', String(params.applyCorrection)) + return datadogApiUrl( + params.site, + `/api/v1/slo/${encodeURIComponent(params.sloId)}/history?${queryParams.toString()}` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { history: {} }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + const history = data.data ?? {} + + return { + success: true, + output: { + history, + sliValue: history.overall?.sli_value ?? null, + }, + } + }, + + outputs: { + history: { + type: 'object', + description: 'SLO history for the requested window', + properties: { + from_ts: { type: 'number', description: 'Window start (Unix seconds)' }, + to_ts: { type: 'number', description: 'Window end (Unix seconds)' }, + type: { type: 'string', description: 'SLO type' }, + overall: { + type: 'object', + description: 'Overall SLI data for the window', + properties: { + sli_value: { type: 'number', description: 'SLI value over the window' }, + span_precision: { type: 'number', description: 'Decimal precision of the SLI value' }, + error_budget_remaining: { + type: 'object', + description: 'Remaining error budget keyed by timeframe', + }, + }, + }, + groups: { type: 'array', description: 'Per-group SLI data for grouped SLOs' }, + monitors: { type: 'array', description: 'Per-monitor SLI data for multi-monitor SLOs' }, + thresholds: { type: 'object', description: 'Thresholds keyed by timeframe' }, + }, + }, + sliValue: { + type: 'number', + description: 'Overall SLI value over the window', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/get_synthetics_results.ts b/apps/sim/tools/datadog/get_synthetics_results.ts new file mode 100644 index 00000000000..4b16db8f9e5 --- /dev/null +++ b/apps/sim/tools/datadog/get_synthetics_results.ts @@ -0,0 +1,139 @@ +import type { + GetSyntheticsResultsParams, + GetSyntheticsResultsResponse, +} from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSyntheticsResultsTool: ToolConfig< + GetSyntheticsResultsParams, + GetSyntheticsResultsResponse +> = { + id: 'datadog_get_synthetics_results', + name: 'Datadog Get Synthetic Test Results', + description: + 'Get the latest result summaries (up to the last 150 runs) for a Synthetic API test.', + version: '1.0.0', + + params: { + publicId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The public ID of the Synthetic API test', + }, + fromTs: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Timestamp in milliseconds from which to start querying results', + }, + toTs: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Timestamp in milliseconds up to which to query results', + }, + probeDc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated locations to query results for (e.g., "aws:eu-west-3")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.fromTs !== undefined) queryParams.set('from_ts', String(params.fromTs)) + if (params.toTs !== undefined) queryParams.set('to_ts', String(params.toTs)) + for (const location of splitCommaList(params.probeDc) ?? []) { + queryParams.append('probe_dc', location) + } + const queryString = queryParams.toString() + return datadogApiUrl( + params.site, + `/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}/results${ + queryString ? `?${queryString}` : '' + }` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { results: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + results: data.results ?? [], + lastTimestampFetched: data.last_timestamp_fetched, + }, + } + }, + + outputs: { + results: { + type: 'array', + description: 'Latest test result summaries', + items: { + type: 'object', + properties: { + result_id: { type: 'string', description: 'ID of the test result' }, + check_time: { type: 'number', description: 'Time the test ran' }, + probe_dc: { type: 'string', description: 'Location the test ran from' }, + status: { + type: 'number', + description: 'Monitor status: 0 not triggered, 1 triggered, 2 no data', + }, + result: { + type: 'object', + description: 'Run outcome', + properties: { + passed: { type: 'boolean', description: 'Whether the run passed' }, + timings: { type: 'object', description: 'Request timing breakdown' }, + }, + }, + }, + }, + }, + lastTimestampFetched: { + type: 'number', + description: 'Timestamp of the latest test run', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/get_synthetics_test.ts b/apps/sim/tools/datadog/get_synthetics_test.ts new file mode 100644 index 00000000000..3dc59815c0d --- /dev/null +++ b/apps/sim/tools/datadog/get_synthetics_test.ts @@ -0,0 +1,85 @@ +import type { GetSyntheticsTestParams, GetSyntheticsTestResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSyntheticsTestTool: ToolConfig = + { + id: 'datadog_get_synthetics_test', + name: 'Datadog Get Synthetic Test', + description: + 'Get the configuration of a Synthetic test by public ID. Browser test steps are not included by this type-agnostic endpoint.', + version: '1.0.0', + + params: { + publicId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The public ID of the Synthetic test (e.g., "abc-def-ghi")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl( + params.site, + `/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}` + ), + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { test: {} }, + error: await datadogErrorMessage(response), + } + } + + return { + success: true, + output: { test: await response.json() }, + } + }, + + outputs: { + test: { + type: 'object', + description: 'The Synthetic test configuration', + properties: { + public_id: { type: 'string', description: 'Public ID of the test' }, + name: { type: 'string', description: 'Test name' }, + status: { type: 'string', description: 'Pause status: live or paused' }, + type: { type: 'string', description: 'Test type: api, browser, mobile, or network' }, + subtype: { type: 'string', description: 'Test subtype, such as http or ssl' }, + message: { type: 'string', description: 'Notification message' }, + monitor_id: { type: 'number', description: 'Associated monitor ID' }, + tags: { type: 'array', description: 'Tags attached to the test' }, + locations: { type: 'array', description: 'Locations the test runs from' }, + config: { type: 'object', description: 'Test request, assertions, and variables' }, + options: { type: 'object', description: 'Scheduling, retry, and monitor options' }, + creator: { type: 'object', description: 'User who created the test' }, + }, + }, + }, + } diff --git a/apps/sim/tools/datadog/index.ts b/apps/sim/tools/datadog/index.ts index 2568a9bb3e0..e00b7a65afd 100644 --- a/apps/sim/tools/datadog/index.ts +++ b/apps/sim/tools/datadog/index.ts @@ -1,15 +1,44 @@ +import { addIncidentTodoTool } from '@/tools/datadog/add_incident_todo' import { cancelDowntimeTool } from '@/tools/datadog/cancel_downtime' +import { createDashboardTool } from '@/tools/datadog/create_dashboard' import { createDowntimeTool } from '@/tools/datadog/create_downtime' import { createEventTool } from '@/tools/datadog/create_event' +import { createIncidentTool } from '@/tools/datadog/create_incident' import { createMonitorTool } from '@/tools/datadog/create_monitor' +import { createSloTool } from '@/tools/datadog/create_slo' +import { deleteDashboardTool } from '@/tools/datadog/delete_dashboard' +import { deleteSloTool } from '@/tools/datadog/delete_slo' +import { getBrowserSyntheticsResultsTool } from '@/tools/datadog/get_browser_synthetics_results' +import { getDashboardTool } from '@/tools/datadog/get_dashboard' +import { getIncidentTool } from '@/tools/datadog/get_incident' import { getMonitorTool } from '@/tools/datadog/get_monitor' +import { getSecuritySignalTool } from '@/tools/datadog/get_security_signal' +import { getSloTool } from '@/tools/datadog/get_slo' +import { getSloHistoryTool } from '@/tools/datadog/get_slo_history' +import { getSyntheticsResultsTool } from '@/tools/datadog/get_synthetics_results' +import { getSyntheticsTestTool } from '@/tools/datadog/get_synthetics_test' +import { listDashboardsTool } from '@/tools/datadog/list_dashboards' import { listDowntimesTool } from '@/tools/datadog/list_downtimes' +import { listIncidentsTool } from '@/tools/datadog/list_incidents' import { listMonitorsTool } from '@/tools/datadog/list_monitors' +import { listSecurityRulesTool } from '@/tools/datadog/list_security_rules' +import { listSecuritySignalsTool } from '@/tools/datadog/list_security_signals' +import { listServicesTool } from '@/tools/datadog/list_services' +import { listSlosTool } from '@/tools/datadog/list_slos' +import { listSyntheticsTestsTool } from '@/tools/datadog/list_synthetics_tests' import { muteMonitorTool } from '@/tools/datadog/mute_monitor' import { queryLogsTool } from '@/tools/datadog/query_logs' import { queryTimeseriesTool } from '@/tools/datadog/query_timeseries' +import { searchSpansTool } from '@/tools/datadog/search_spans' import { sendLogsTool } from '@/tools/datadog/send_logs' import { submitMetricsTool } from '@/tools/datadog/submit_metrics' +import { triggerSyntheticsTestsTool } from '@/tools/datadog/trigger_synthetics_tests' +import { unmuteMonitorTool } from '@/tools/datadog/unmute_monitor' +import { updateIncidentTool } from '@/tools/datadog/update_incident' +import { updateSecuritySignalAssigneeTool } from '@/tools/datadog/update_security_signal_assignee' +import { updateSecuritySignalStateTool } from '@/tools/datadog/update_security_signal_state' +import { updateSloTool } from '@/tools/datadog/update_slo' +import { updateSyntheticsStatusTool } from '@/tools/datadog/update_synthetics_status' export const datadogSubmitMetricsTool = submitMetricsTool export const datadogQueryTimeseriesTool = queryTimeseriesTool @@ -18,8 +47,37 @@ export const datadogCreateMonitorTool = createMonitorTool export const datadogGetMonitorTool = getMonitorTool export const datadogListMonitorsTool = listMonitorsTool export const datadogMuteMonitorTool = muteMonitorTool +export const datadogUnmuteMonitorTool = unmuteMonitorTool export const datadogQueryLogsTool = queryLogsTool export const datadogSendLogsTool = sendLogsTool export const datadogCreateDowntimeTool = createDowntimeTool export const datadogListDowntimesTool = listDowntimesTool export const datadogCancelDowntimeTool = cancelDowntimeTool +export const datadogListIncidentsTool = listIncidentsTool +export const datadogGetIncidentTool = getIncidentTool +export const datadogCreateIncidentTool = createIncidentTool +export const datadogUpdateIncidentTool = updateIncidentTool +export const datadogAddIncidentTodoTool = addIncidentTodoTool +export const datadogListSlosTool = listSlosTool +export const datadogGetSloTool = getSloTool +export const datadogCreateSloTool = createSloTool +export const datadogUpdateSloTool = updateSloTool +export const datadogDeleteSloTool = deleteSloTool +export const datadogGetSloHistoryTool = getSloHistoryTool +export const datadogListDashboardsTool = listDashboardsTool +export const datadogGetDashboardTool = getDashboardTool +export const datadogCreateDashboardTool = createDashboardTool +export const datadogDeleteDashboardTool = deleteDashboardTool +export const datadogListSyntheticsTestsTool = listSyntheticsTestsTool +export const datadogGetSyntheticsTestTool = getSyntheticsTestTool +export const datadogGetSyntheticsResultsTool = getSyntheticsResultsTool +export const datadogGetBrowserSyntheticsResultsTool = getBrowserSyntheticsResultsTool +export const datadogTriggerSyntheticsTestsTool = triggerSyntheticsTestsTool +export const datadogUpdateSyntheticsStatusTool = updateSyntheticsStatusTool +export const datadogListSecuritySignalsTool = listSecuritySignalsTool +export const datadogGetSecuritySignalTool = getSecuritySignalTool +export const datadogUpdateSecuritySignalStateTool = updateSecuritySignalStateTool +export const datadogUpdateSecuritySignalAssigneeTool = updateSecuritySignalAssigneeTool +export const datadogListSecurityRulesTool = listSecurityRulesTool +export const datadogSearchSpansTool = searchSpansTool +export const datadogListServicesTool = listServicesTool diff --git a/apps/sim/tools/datadog/list_dashboards.ts b/apps/sim/tools/datadog/list_dashboards.ts new file mode 100644 index 00000000000..4c7d58095bb --- /dev/null +++ b/apps/sim/tools/datadog/list_dashboards.ts @@ -0,0 +1,112 @@ +import type { ListDashboardsParams, ListDashboardsResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const listDashboardsTool: ToolConfig = { + id: 'datadog_list_dashboards', + name: 'Datadog List Dashboards', + description: + 'List custom created or cloned dashboards. Datadog preset dashboards are not returned.', + version: '1.0.0', + + params: { + filterShared: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return only shared dashboards', + }, + filterDeleted: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return only deleted dashboards. Incompatible with filterShared', + }, + count: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of dashboards to return (default: 100)', + }, + start: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Offset of the first dashboard returned (e.g., 0, 100)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + /** + * Datadog treats `filter[shared]` and `filter[deleted]` as incompatible, so each is sent + * only when the caller turned it on rather than sending both as `false`. + */ + url: (params) => { + const queryParams = new URLSearchParams() + if (params.filterShared) queryParams.set('filter[shared]', 'true') + if (params.filterDeleted) queryParams.set('filter[deleted]', 'true') + if (params.count !== undefined) queryParams.set('count', String(params.count)) + if (params.start !== undefined) queryParams.set('start', String(params.start)) + const queryString = queryParams.toString() + return datadogApiUrl(params.site, `/api/v1/dashboard${queryString ? `?${queryString}` : ''}`) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { dashboards: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { dashboards: data.dashboards ?? [] }, + } + }, + + outputs: { + dashboards: { + type: 'array', + description: 'List of dashboard summaries', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Dashboard ID' }, + title: { type: 'string', description: 'Dashboard title' }, + description: { type: 'string', description: 'Dashboard description' }, + layout_type: { type: 'string', description: 'Layout type: ordered or free' }, + url: { type: 'string', description: 'Dashboard URL path' }, + author_handle: { type: 'string', description: 'Handle of the dashboard author' }, + created_at: { type: 'string', description: 'Creation timestamp' }, + modified_at: { type: 'string', description: 'Modification timestamp' }, + is_read_only: { type: 'boolean', description: 'Whether the dashboard is read-only' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/list_downtimes.ts b/apps/sim/tools/datadog/list_downtimes.ts index 430f439e6e2..a8c269fbc1b 100644 --- a/apps/sim/tools/datadog/list_downtimes.ts +++ b/apps/sim/tools/datadog/list_downtimes.ts @@ -1,4 +1,10 @@ -import type { ListDowntimesParams, ListDowntimesResponse } from '@/tools/datadog/types' +import type { + DatadogV2Resource, + DowntimeAttributes, + ListDowntimesParams, + ListDowntimesResponse, +} from '@/tools/datadog/types' +import { datadogErrorMessage } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const listDowntimesTool: ToolConfig = { @@ -14,11 +20,18 @@ export const listDowntimesTool: ToolConfig { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { downtimes: [], }, - error: errorData.errors?.[0]?.detail || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } const data = await response.json() - const downtimes = (data.data || []).map((d: any) => { - const attrs = d.attributes || {} + const downtimes = (data.data || []).map((d: DatadogV2Resource) => { + const attrs: DowntimeAttributes = d.attributes || {} return { id: d.id, scope: attrs.scope ? [attrs.scope] : [], message: attrs.message, start: attrs.schedule?.start ? new Date(attrs.schedule.start).getTime() / 1000 : undefined, end: attrs.schedule?.end ? new Date(attrs.schedule.end).getTime() / 1000 : undefined, - timezone: attrs.schedule?.timezone, - disabled: attrs.disabled, + timezone: attrs.display_timezone ?? undefined, active: attrs.status === 'active', created: attrs.created ? new Date(attrs.created).getTime() / 1000 : undefined, modified: attrs.modified ? new Date(attrs.modified).getTime() / 1000 : undefined, @@ -92,23 +105,32 @@ export const listDowntimesTool: ToolConfig = { + id: 'datadog_list_incidents', + name: 'Datadog List Incidents', + description: + 'List incidents for the organization. Requires the Incident Management `incident_read` permission; the Incidents API is in public beta.', + version: '1.0.0', + + params: { + include: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated related resources to include: "users" and/or "attachments"', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of incidents to return per page (default: 10, max: 100)', + }, + pageOffset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Index of the first incident to return (e.g., 0, 10, 20)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + const include = splitCommaList(params.include)?.join(',') + if (include) queryParams.set('include', include) + if (params.pageSize !== undefined) queryParams.set('page[size]', String(params.pageSize)) + if (params.pageOffset !== undefined) + queryParams.set('page[offset]', String(params.pageOffset)) + const queryString = queryParams.toString() + return datadogApiUrl(params.site, `/api/v2/incidents${queryString ? `?${queryString}` : ''}`) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { incidents: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + incidents: (data.data ?? []).map((incident: DatadogV2Resource) => ({ + id: incident.id, + type: incident.type, + attributes: incident.attributes ?? {}, + })), + nextOffset: data.meta?.pagination?.next_offset, + }, + } + }, + + outputs: { + incidents: { + type: 'array', + description: 'List of incidents', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Incident UUID' }, + type: { type: 'string', description: 'Resource type (incidents)' }, + attributes: { + type: 'object', + description: 'Incident attributes', + properties: { + title: { type: 'string', description: 'Incident title' }, + state: { type: 'string', description: 'Incident state' }, + severity: { type: 'string', description: 'Incident severity' }, + public_id: { type: 'number', description: 'Incremental public incident ID' }, + customer_impacted: { + type: 'boolean', + description: 'Whether customers were impacted', + }, + created: { type: 'string', description: 'Creation timestamp' }, + modified: { type: 'string', description: 'Last modification timestamp' }, + resolved: { type: 'string', description: 'Resolution timestamp' }, + }, + }, + }, + }, + }, + nextOffset: { + type: 'number', + description: 'Offset to use for the next page of results', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/list_monitors.ts b/apps/sim/tools/datadog/list_monitors.ts index eaf24493600..f910ab3eac3 100644 --- a/apps/sim/tools/datadog/list_monitors.ts +++ b/apps/sim/tools/datadog/list_monitors.ts @@ -1,9 +1,7 @@ -import { createLogger } from '@sim/logger' -import type { ListMonitorsParams, ListMonitorsResponse } from '@/tools/datadog/types' +import type { ListMonitorsParams, ListMonitorsResponse, MonitorData } from '@/tools/datadog/types' +import { datadogErrorMessage } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('DatadogListMonitors') - export const listMonitorsTool: ToolConfig = { id: 'datadog_list_monitors', name: 'Datadog List Monitors', @@ -16,7 +14,7 @@ export const listMonitorsTool: ToolConfig ({ @@ -112,18 +99,18 @@ export const listMonitorsTool: ToolConfig { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { monitors: [], }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } const text = await response.text() - let data: any + let data: unknown try { data = JSON.parse(text) } catch (e) { @@ -142,7 +129,7 @@ export const listMonitorsTool: ToolConfig ({ + const monitors = (data as MonitorData[]).map((m) => ({ id: m.id, name: m.name, type: m.type, @@ -176,8 +163,17 @@ export const listMonitorsTool: ToolConfig = + { + id: 'datadog_list_security_rules', + name: 'Datadog List Security Rules', + description: + 'List Cloud SIEM detection rules. Requires the `security_monitoring_rules_read` permission.', + version: '1.0.0', + + params: { + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Search query filtering rules by attributes such as type, source, or tags (e.g., "type:log_detection source:cloudtrail")', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Sort attribute, prefix with "-" for descending: name, creation_date, update_date, enabled, type, highest_severity, or source', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of rules per page (default: 10, max: 100)', + }, + pageNumber: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page to retrieve, starting at zero', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.query) queryParams.set('query', params.query) + if (params.sort) queryParams.set('sort', params.sort) + if (params.pageSize !== undefined) queryParams.set('page[size]', String(params.pageSize)) + if (params.pageNumber !== undefined) + queryParams.set('page[number]', String(params.pageNumber)) + const queryString = queryParams.toString() + return datadogApiUrl( + params.site, + `/api/v2/security_monitoring/rules${queryString ? `?${queryString}` : ''}` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { rules: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + rules: (data.data ?? []).map((rule: SecurityRuleData) => ({ + id: rule.id, + name: rule.name, + type: rule.type, + message: rule.message, + tags: rule.tags ?? [], + isEnabled: rule.isEnabled, + isDefault: rule.isDefault, + createdAt: rule.createdAt, + version: rule.version, + })), + }, + } + }, + + outputs: { + rules: { + type: 'array', + description: 'List of detection rules', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Rule ID' }, + name: { type: 'string', description: 'Rule name' }, + type: { type: 'string', description: 'Rule type' }, + message: { type: 'string', description: 'Message attached to generated signals' }, + tags: { type: 'array', description: 'Rule tags' }, + isEnabled: { type: 'boolean', description: 'Whether the rule is enabled' }, + isDefault: { + type: 'boolean', + description: 'Whether the rule is a Datadog default rule', + }, + createdAt: { type: 'number', description: 'Creation timestamp in milliseconds' }, + version: { type: 'number', description: 'Rule version' }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/datadog/list_security_signals.ts b/apps/sim/tools/datadog/list_security_signals.ts new file mode 100644 index 00000000000..22b09bafd8a --- /dev/null +++ b/apps/sim/tools/datadog/list_security_signals.ts @@ -0,0 +1,154 @@ +import type { + DatadogV2Resource, + ListSecuritySignalsParams, + ListSecuritySignalsResponse, + SecuritySignalAttributes, +} from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const listSecuritySignalsTool: ToolConfig< + ListSecuritySignalsParams, + ListSecuritySignalsResponse +> = { + id: 'datadog_list_security_signals', + name: 'Datadog List Security Signals', + description: + 'Search Cloud SIEM security signals by query and time range. Requires the `security_monitoring_signals_read` permission.', + version: '1.0.0', + + params: { + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Signal search query (e.g., "security:attack status:high")', + }, + from: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Minimum timestamp as an ISO-8601 date-time (e.g., "2026-01-02T09:42:36.320Z"). Signal search does not accept relative expressions like "now-1h".', + }, + to: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Maximum timestamp as an ISO-8601 date-time (e.g., "2026-01-03T09:42:36.320Z"). Signal search does not accept relative expressions like "now".', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: "timestamp" for oldest first, "-timestamp" for newest first', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor returned as nextCursor by a previous call', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of signals to return (default: 10, max: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => datadogApiUrl(params.site, '/api/v2/security_monitoring/signals/search'), + method: 'POST', + headers: datadogHeaders, + body: (params) => { + const filter: Record = {} + if (params.query) filter.query = params.query + if (params.from) filter.from = params.from + if (params.to) filter.to = params.to + + const page: Record = {} + if (params.cursor) page.cursor = params.cursor + if (params.limit !== undefined) page.limit = params.limit + + const body: Record = {} + if (Object.keys(filter).length > 0) body.filter = filter + if (Object.keys(page).length > 0) body.page = page + if (params.sort) body.sort = params.sort + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { signals: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + signals: (data.data ?? []).map((signal: DatadogV2Resource) => ({ + id: signal.id, + type: signal.type, + attributes: signal.attributes ?? {}, + })), + nextCursor: data.meta?.page?.after, + }, + } + }, + + outputs: { + signals: { + type: 'array', + description: 'List of security signals', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Signal ID' }, + type: { type: 'string', description: 'Resource type (signal)' }, + attributes: { + type: 'object', + description: 'Signal attributes', + properties: { + message: { type: 'string', description: 'Message from the detection rule' }, + timestamp: { type: 'string', description: 'Signal timestamp' }, + tags: { type: 'array', description: 'Tags on the signal' }, + custom: { type: 'object', description: 'Signal-specific attributes' }, + }, + }, + }, + }, + }, + nextCursor: { + type: 'string', + description: 'Cursor for the next page of signals', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/list_services.ts b/apps/sim/tools/datadog/list_services.ts new file mode 100644 index 00000000000..4081e6440e6 --- /dev/null +++ b/apps/sim/tools/datadog/list_services.ts @@ -0,0 +1,121 @@ +import type { + DatadogV2Resource, + ListServicesParams, + ListServicesResponse, + ServiceDefinitionAttributes, +} from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const listServicesTool: ToolConfig = { + id: 'datadog_list_services', + name: 'Datadog List Services', + description: + 'List service definitions from the Datadog Service Catalog. Requires the `apm_service_catalog_read` permission.', + version: '1.0.0', + + params: { + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of service definitions per page (default: 10, max: 100)', + }, + pageNumber: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page to retrieve, starting at zero', + }, + schemaVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Schema version to return (e.g., "v2", "v2.1", "v2.2")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.pageSize !== undefined) queryParams.set('page[size]', String(params.pageSize)) + if (params.pageNumber !== undefined) + queryParams.set('page[number]', String(params.pageNumber)) + if (params.schemaVersion) queryParams.set('schema_version', params.schemaVersion) + const queryString = queryParams.toString() + return datadogApiUrl( + params.site, + `/api/v2/services/definitions${queryString ? `?${queryString}` : ''}` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { services: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + services: (data.data ?? []).map( + (service: DatadogV2Resource) => ({ + id: service.id, + type: service.type, + schema: service.attributes?.schema ?? {}, + meta: service.attributes?.meta ?? {}, + }) + ), + }, + } + }, + + outputs: { + services: { + type: 'array', + description: 'List of service definitions', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Service definition ID' }, + type: { type: 'string', description: 'Resource type (service_definitions)' }, + schema: { + type: 'object', + description: + 'The service definition schema. Its shape depends on the requested schema version', + }, + meta: { + type: 'object', + description: 'Ingestion metadata such as origin and last modified time', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/list_slos.ts b/apps/sim/tools/datadog/list_slos.ts new file mode 100644 index 00000000000..bbf6fdb96db --- /dev/null +++ b/apps/sim/tools/datadog/list_slos.ts @@ -0,0 +1,135 @@ +import type { ListSlosParams, ListSlosResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const listSlosTool: ToolConfig = { + id: 'datadog_list_slos', + name: 'Datadog List SLOs', + description: + 'List service level objectives, optionally filtered by IDs, name, tags, or underlying metrics query.', + version: '1.0.0', + + params: { + ids: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated SLO IDs to fetch (e.g., "id1,id2")', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter results by SLO name (e.g., "checkout latency")', + }, + tagsQuery: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter results by a single SLO tag (e.g., "env:prod")', + }, + metricsQuery: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter results by SLO numerator and denominator (e.g., "aws.elb.request_count")', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of SLOs to return (default: 1000)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Offset of the first SLO returned (e.g., 0, 50)', + }, + isDeleted: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Return only deleted SLOs', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.ids) queryParams.set('ids', params.ids) + if (params.query) queryParams.set('query', params.query) + if (params.tagsQuery) queryParams.set('tags_query', params.tagsQuery) + if (params.metricsQuery) queryParams.set('metrics_query', params.metricsQuery) + if (params.limit !== undefined) queryParams.set('limit', String(params.limit)) + if (params.offset !== undefined) queryParams.set('offset', String(params.offset)) + if (params.isDeleted) queryParams.set('is_deleted', 'true') + const queryString = queryParams.toString() + return datadogApiUrl(params.site, `/api/v1/slo${queryString ? `?${queryString}` : ''}`) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { slos: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + slos: data.data ?? [], + }, + } + }, + + outputs: { + slos: { + type: 'array', + description: 'List of service level objectives', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'SLO ID' }, + name: { type: 'string', description: 'SLO name' }, + type: { type: 'string', description: 'SLO type: metric, monitor, or time_slice' }, + description: { type: 'string', description: 'SLO description' }, + tags: { type: 'array', description: 'SLO tags' }, + thresholds: { type: 'array', description: 'Timeframe targets and warnings' }, + target_threshold: { type: 'number', description: 'Primary target threshold' }, + warning_threshold: { type: 'number', description: 'Primary warning threshold' }, + timeframe: { type: 'string', description: 'Primary timeframe' }, + monitor_ids: { type: 'array', description: 'Monitor IDs for monitor-based SLOs' }, + created_at: { type: 'number', description: 'Creation timestamp (Unix seconds)' }, + modified_at: { type: 'number', description: 'Modification timestamp (Unix seconds)' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/list_synthetics_tests.ts b/apps/sim/tools/datadog/list_synthetics_tests.ts new file mode 100644 index 00000000000..fef037aef29 --- /dev/null +++ b/apps/sim/tools/datadog/list_synthetics_tests.ts @@ -0,0 +1,115 @@ +import type { + ListSyntheticsTestsParams, + ListSyntheticsTestsResponse, + SyntheticsTestData, +} from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const listSyntheticsTestsTool: ToolConfig< + ListSyntheticsTestsParams, + ListSyntheticsTestsResponse +> = { + id: 'datadog_list_synthetics_tests', + name: 'Datadog List Synthetic Tests', + description: 'List all Synthetic tests (API, browser, and mobile) with their current status.', + version: '1.0.0', + + params: { + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of tests returned per page (default: 100)', + }, + pageNumber: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page to retrieve, starting at zero', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.pageSize !== undefined) queryParams.set('page_size', String(params.pageSize)) + if (params.pageNumber !== undefined) queryParams.set('page_number', String(params.pageNumber)) + const queryString = queryParams.toString() + return datadogApiUrl( + params.site, + `/api/v1/synthetics/tests${queryString ? `?${queryString}` : ''}` + ) + }, + method: 'GET', + headers: datadogHeaders, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { tests: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + tests: (data.tests ?? []).map((test: SyntheticsTestData) => ({ + public_id: test.public_id, + name: test.name, + status: test.status, + type: test.type, + subtype: test.subtype, + message: test.message, + monitor_id: test.monitor_id, + tags: test.tags ?? [], + locations: test.locations ?? [], + })), + }, + } + }, + + outputs: { + tests: { + type: 'array', + description: 'List of Synthetic tests', + items: { + type: 'object', + properties: { + public_id: { type: 'string', description: 'Public ID of the test' }, + name: { type: 'string', description: 'Test name' }, + status: { type: 'string', description: 'Pause status: live or paused' }, + type: { type: 'string', description: 'Test type: api, browser, mobile, or network' }, + subtype: { type: 'string', description: 'Test subtype, such as http or ssl' }, + message: { type: 'string', description: 'Notification message' }, + monitor_id: { type: 'number', description: 'Associated monitor ID' }, + tags: { type: 'array', description: 'Tags attached to the test' }, + locations: { type: 'array', description: 'Locations the test runs from' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/mute_monitor.ts b/apps/sim/tools/datadog/mute_monitor.ts index 5d641853883..708d3d0e888 100644 --- a/apps/sim/tools/datadog/mute_monitor.ts +++ b/apps/sim/tools/datadog/mute_monitor.ts @@ -1,10 +1,12 @@ import type { MuteMonitorParams, MuteMonitorResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const muteMonitorTool: ToolConfig = { id: 'datadog_mute_monitor', name: 'Datadog Mute Monitor', - description: 'Mute a monitor to temporarily suppress notifications.', + description: + 'Mute a monitor to temporarily suppress its notifications. Use Unmute Monitor to reverse it, or schedule a downtime instead when you want a planned, auditable maintenance window.', version: '1.0.0', params: { @@ -26,7 +28,7 @@ export const muteMonitorTool: ToolConfig required: false, visibility: 'user-or-llm', description: - 'Unix timestamp in seconds when the mute should end (e.g., 1705323600). If not specified, mutes indefinitely.', + 'Unix timestamp in seconds when the mute should end (e.g., 1705323600). If not specified, the monitor stays muted until it is unmuted.', }, apiKey: { type: 'string', @@ -49,40 +51,36 @@ export const muteMonitorTool: ToolConfig }, request: { - url: (params) => { - const site = params.site || 'datadoghq.com' - return `https://api.${site}/api/v1/monitor/${params.monitorId}/mute` - }, + url: (params) => + datadogApiUrl(params.site, `/api/v1/monitor/${encodeURIComponent(params.monitorId)}/mute`), method: 'POST', - headers: (params) => ({ - 'Content-Type': 'application/json', - 'DD-API-KEY': params.apiKey, - 'DD-APPLICATION-KEY': params.applicationKey, - }), + headers: datadogHeaders, body: (params) => { - const body: Record = {} + const body: { scope?: string; end?: number } = {} if (params.scope) body.scope = params.scope - if (params.end) body.end = params.end + if (params.end !== undefined) body.end = params.end return body }, }, transformResponse: async (response: Response) => { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) return { success: false, - output: { - success: false, - }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + output: { success: false }, + error: await datadogErrorMessage(response), } } + const data = await response.json().catch(() => ({})) + return { success: true, output: { success: true, + monitorId: data.id, + name: data.name, + overallState: data.overall_state, }, } }, @@ -92,5 +90,20 @@ export const muteMonitorTool: ToolConfig type: 'boolean', description: 'Whether the monitor was successfully muted', }, + monitorId: { + type: 'number', + description: 'ID of the muted monitor', + optional: true, + }, + name: { + type: 'string', + description: 'Name of the muted monitor', + optional: true, + }, + overallState: { + type: 'string', + description: 'Monitor state after muting', + optional: true, + }, }, } diff --git a/apps/sim/tools/datadog/query_logs.ts b/apps/sim/tools/datadog/query_logs.ts index 1443c8077fb..c668820efb1 100644 --- a/apps/sim/tools/datadog/query_logs.ts +++ b/apps/sim/tools/datadog/query_logs.ts @@ -1,4 +1,10 @@ -import type { QueryLogsParams, QueryLogsResponse } from '@/tools/datadog/types' +import type { + DatadogV2Resource, + LogAttributes, + QueryLogsParams, + QueryLogsResponse, +} from '@/tools/datadog/types' +import { datadogErrorMessage } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const queryLogsTool: ToolConfig = { @@ -36,6 +42,13 @@ export const queryLogsTool: ToolConfig = { visibility: 'user-or-llm', description: 'Maximum number of logs to return (e.g., 50, 100, max: 1000)', }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Pagination cursor from a previous call, taken from its nextLogId output. Omit for the first page.', + }, sort: { type: 'string', required: false, @@ -80,7 +93,11 @@ export const queryLogsTool: ToolConfig = { 'DD-APPLICATION-KEY': params.applicationKey, }), body: (params) => { - const body: Record = { + const body: { + filter: { query: string; from: string; to: string; indexes?: string[] } + page: { limit: number; cursor?: string } + sort?: 'timestamp' | '-timestamp' + } = { filter: { query: params.query, from: params.from, @@ -91,6 +108,10 @@ export const queryLogsTool: ToolConfig = { }, } + if (params.cursor) { + body.page.cursor = params.cursor + } + if (params.sort) { body.sort = params.sort } @@ -108,18 +129,18 @@ export const queryLogsTool: ToolConfig = { transformResponse: async (response: Response) => { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { logs: [], }, - error: errorData.errors?.[0]?.detail || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } const data = await response.json() - const logs = (data.data || []).map((log: any) => ({ + const logs = (data.data || []).map((log: DatadogV2Resource) => ({ id: log.id, content: { timestamp: log.attributes?.timestamp, @@ -158,6 +179,8 @@ export const queryLogsTool: ToolConfig = { service: { type: 'string', description: 'Service name' }, message: { type: 'string', description: 'Log message' }, status: { type: 'string', description: 'Log status/level' }, + attributes: { type: 'json', description: 'Free-form log attributes' }, + tags: { type: 'array', description: 'Log tags' }, }, }, }, diff --git a/apps/sim/tools/datadog/query_timeseries.ts b/apps/sim/tools/datadog/query_timeseries.ts index abae9e4a3e8..ee4be02dfc2 100644 --- a/apps/sim/tools/datadog/query_timeseries.ts +++ b/apps/sim/tools/datadog/query_timeseries.ts @@ -1,4 +1,9 @@ -import type { QueryTimeseriesParams, QueryTimeseriesResponse } from '@/tools/datadog/types' +import type { + MetricsQuerySeries, + QueryTimeseriesParams, + QueryTimeseriesResponse, +} from '@/tools/datadog/types' +import { datadogErrorMessage } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const queryTimeseriesTool: ToolConfig = { @@ -68,19 +73,33 @@ export const queryTimeseriesTool: ToolConfig { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { series: [], status: 'error', }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } const data = await response.json() - const series = (data.series || []).map((s: any) => ({ + + /** + * A failed metrics query still returns 200; Datadog signals it with a non-`ok` + * `status` and puts the reason in `error`. Without this the caller gets an empty + * series and no indication anything went wrong. + */ + if (data.status && data.status !== 'ok') { + return { + success: false, + output: { series: [], status: data.status }, + error: data.error || `Datadog returned query status "${data.status}"`, + } + } + + const series = (data.series || []).map((s: MetricsQuerySeries) => ({ metric: s.metric || s.expression, tags: s.tag_set || [], points: (s.pointlist || []).map((p: [number, number]) => ({ @@ -93,7 +112,7 @@ export const queryTimeseriesTool: ToolConfig = { + id: 'datadog_search_spans', + name: 'Datadog Search Spans', + description: 'Search indexed APM spans using the span query syntax, with cursor pagination.', + version: '1.0.0', + + params: { + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Span search query (e.g., "service:web* AND @http.status_code:[500 TO 599]"). Defaults to "*"', + }, + from: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Minimum time, ISO-8601, date math, or milliseconds (default: "now-15m")', + }, + to: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum time, ISO-8601, date math, or milliseconds (default: "now")', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: "timestamp" for oldest first, "-timestamp" for newest first', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor returned as nextCursor by a previous call', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of spans to return (default: 10, max: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => datadogApiUrl(params.site, '/api/v2/spans/events/search'), + method: 'POST', + headers: datadogHeaders, + body: (params) => { + const filter: Record = {} + if (params.query) filter.query = params.query + if (params.from) filter.from = params.from + if (params.to) filter.to = params.to + + const page: Record = {} + if (params.cursor) page.cursor = params.cursor + if (params.limit !== undefined) page.limit = params.limit + + const attributes: Record = {} + if (Object.keys(filter).length > 0) attributes.filter = filter + if (Object.keys(page).length > 0) attributes.page = page + if (params.sort) attributes.sort = params.sort + + return { data: { type: 'search_request', attributes } } + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { spans: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + spans: (data.data ?? []).map((span: DatadogV2Resource) => ({ + id: span.id, + type: span.type, + attributes: span.attributes ?? {}, + })), + nextCursor: data.meta?.page?.after, + elapsed: data.meta?.elapsed, + }, + } + }, + + outputs: { + spans: { + type: 'array', + description: 'List of matching spans', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Unique span event ID' }, + type: { type: 'string', description: 'Resource type (spans)' }, + attributes: { + type: 'object', + description: 'Span attributes', + properties: { + service: { type: 'string', description: 'Service that emitted the span' }, + resource_name: { type: 'string', description: 'Resource name' }, + env: { type: 'string', description: 'Environment' }, + host: { type: 'string', description: 'Host that emitted the span' }, + type: { type: 'string', description: 'Span type, such as web or db' }, + trace_id: { type: 'string', description: 'Trace ID' }, + span_id: { type: 'string', description: 'Span ID' }, + parent_id: { type: 'string', description: 'Parent span ID' }, + start_timestamp: { type: 'string', description: 'Span start timestamp' }, + end_timestamp: { type: 'string', description: 'Span end timestamp' }, + tags: { type: 'array', description: 'Tags on the span' }, + custom: { type: 'object', description: 'Custom span data' }, + }, + }, + }, + }, + }, + nextCursor: { + type: 'string', + description: 'Cursor for the next page of spans', + optional: true, + }, + elapsed: { + type: 'number', + description: 'Query time in milliseconds', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/send_logs.ts b/apps/sim/tools/datadog/send_logs.ts index 1dd5055675e..39f0a41ae00 100644 --- a/apps/sim/tools/datadog/send_logs.ts +++ b/apps/sim/tools/datadog/send_logs.ts @@ -1,4 +1,6 @@ -import type { SendLogsParams, SendLogsResponse } from '@/tools/datadog/types' +import { filterUndefined } from '@sim/utils/object' +import type { LogEntry, SendLogsParams, SendLogsResponse } from '@/tools/datadog/types' +import { datadogErrorMessage, parseJsonParam } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' export const sendLogsTool: ToolConfig = { @@ -47,33 +49,31 @@ export const sendLogsTool: ToolConfig = { 'DD-API-KEY': params.apiKey, }), body: (params) => { - let logs: any[] - try { - logs = typeof params.logs === 'string' ? JSON.parse(params.logs) : params.logs - } catch { - throw new Error('Invalid JSON in logs parameter') + const logs = parseJsonParam(params.logs, 'logs parameter') + if (!Array.isArray(logs) || logs.length === 0) { + throw new Error('logs must be a non-empty JSON array of log entries') } - // Ensure each log entry has the required format - return logs.map((log: any) => ({ - ddsource: log.ddsource || 'custom', - ddtags: log.ddtags || '', - hostname: log.hostname || '', - message: log.message, - service: log.service || '', - })) + /** + * Every extra key is preserved: Datadog's log intake accepts arbitrary + * additional properties as the log's structured attributes, so rebuilding each + * entry from a fixed field list would silently discard them. Empty optional + * fields are dropped rather than sent blank, which would otherwise suppress + * Datadog's own host inference and write an empty reserved `service` attribute. + */ + return logs.map((log) => filterUndefined({ ...log, ddsource: log.ddsource || 'custom' })) }, }, transformResponse: async (response: Response) => { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, output: { success: false, }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + error: message, } } diff --git a/apps/sim/tools/datadog/submit_metrics.ts b/apps/sim/tools/datadog/submit_metrics.ts index 7b4df3cf99b..7b0155f2e01 100644 --- a/apps/sim/tools/datadog/submit_metrics.ts +++ b/apps/sim/tools/datadog/submit_metrics.ts @@ -1,6 +1,23 @@ -import type { SubmitMetricsParams, SubmitMetricsResponse } from '@/tools/datadog/types' +import { filterUndefined } from '@sim/utils/object' +import type { + MetricSeries, + SubmitMetricsParams, + SubmitMetricsResponse, +} from '@/tools/datadog/types' +import { datadogErrorMessage, parseJsonParam } from '@/tools/datadog/utils' import type { ToolConfig } from '@/tools/types' +/** + * Datadog `MetricIntakeType` codes: 0 unspecified, 1 count, 2 rate, 3 gauge. + * A metric with no recognized type is submitted without a `type`, letting Datadog + * infer it rather than silently mislabeling the series. + */ +const METRIC_INTAKE_TYPE = { + count: 1, + rate: 2, + gauge: 3, +} as const + export const submitMetricsTool: ToolConfig = { id: 'datadog_submit_metrics', name: 'Datadog Submit Metrics', @@ -14,7 +31,7 @@ export const submitMetricsTool: ToolConfig { - let series: any[] - try { - series = typeof params.series === 'string' ? JSON.parse(params.series) : params.series - } catch { - throw new Error('Invalid JSON in series parameter') + const series = parseJsonParam(params.series, 'series parameter') + if (!Array.isArray(series) || series.length === 0) { + throw new Error('series must be a non-empty JSON array of metric series') } - // Transform to Datadog API v2 format - const formattedSeries = series.map((s: any) => ({ - metric: s.metric, - type: s.type === 'gauge' ? 0 : s.type === 'rate' ? 1 : s.type === 'count' ? 2 : 3, - points: s.points.map((p: any) => ({ - timestamp: p.timestamp, - value: p.value, - })), - tags: s.tags || [], - unit: s.unit, - resources: s.resources || [{ name: 'host', type: 'host' }], - })) + const formattedSeries = series.map((s) => + filterUndefined({ + metric: s.metric, + type: METRIC_INTAKE_TYPE[s.type as keyof typeof METRIC_INTAKE_TYPE], + points: s.points?.map((p) => ({ timestamp: p.timestamp, value: p.value })), + tags: s.tags, + unit: s.unit, + interval: s.interval, + source_type_name: s.sourceTypeName, + resources: s.resources, + }) + ) return { series: formattedSeries } }, @@ -67,14 +82,11 @@ export const submitMetricsTool: ToolConfig { if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const message = await datadogErrorMessage(response) return { success: false, - output: { - success: false, - errors: [errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`], - }, - error: errorData.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`, + output: { success: false, errors: [message] }, + error: message, } } diff --git a/apps/sim/tools/datadog/trigger_synthetics_tests.ts b/apps/sim/tools/datadog/trigger_synthetics_tests.ts new file mode 100644 index 00000000000..e7ce5a7f231 --- /dev/null +++ b/apps/sim/tools/datadog/trigger_synthetics_tests.ts @@ -0,0 +1,118 @@ +import type { + TriggerSyntheticsTestsParams, + TriggerSyntheticsTestsResponse, +} from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const triggerSyntheticsTestsTool: ToolConfig< + TriggerSyntheticsTestsParams, + TriggerSyntheticsTestsResponse +> = { + id: 'datadog_trigger_synthetics_tests', + name: 'Datadog Trigger Synthetic Tests', + description: 'Trigger an immediate run of one or more Synthetic tests by public ID.', + version: '1.0.0', + + params: { + publicIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated public IDs of the Synthetic tests to trigger', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => datadogApiUrl(params.site, '/api/v1/synthetics/tests/trigger'), + method: 'POST', + headers: datadogHeaders, + body: (params) => ({ + tests: (splitCommaList(params.publicIds) ?? []).map((publicId) => ({ + public_id: publicId, + })), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { triggeredCheckIds: [], results: [], locations: [] }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + batchId: data.batch_id, + triggeredCheckIds: data.triggered_check_ids ?? [], + results: data.results ?? [], + locations: data.locations ?? [], + }, + } + }, + + outputs: { + batchId: { + type: 'string', + description: 'Public ID of the triggered batch', + optional: true, + }, + triggeredCheckIds: { + type: 'array', + description: 'Public IDs of the triggered Synthetic tests', + items: { type: 'string' }, + }, + results: { + type: 'array', + description: 'Information about each triggered test run', + items: { + type: 'object', + properties: { + public_id: { type: 'string', description: 'Public ID of the test' }, + result_id: { type: 'string', description: 'ID of the run result' }, + location: { type: 'number', description: 'Location ID of the run' }, + device: { type: 'string', description: 'Device ID used for browser tests' }, + }, + }, + }, + locations: { + type: 'array', + description: 'Locations the tests were triggered from', + items: { + type: 'object', + properties: { + id: { type: 'number', description: 'Location ID' }, + name: { type: 'string', description: 'Location name' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/types.ts b/apps/sim/tools/datadog/types.ts index fc6e0f62eec..9932f838355 100644 --- a/apps/sim/tools/datadog/types.ts +++ b/apps/sim/tools/datadog/types.ts @@ -2,13 +2,17 @@ import type { ToolResponse } from '@/tools/types' // Datadog Site/Region options +/** Regional sites Datadog serves the API from, per the `site` server variable enum. */ export type DatadogSite = | 'datadoghq.com' | 'us3.datadoghq.com' | 'us5.datadoghq.com' | 'datadoghq.eu' | 'ap1.datadoghq.com' + | 'ap2.datadoghq.com' + | 'uk1.datadoghq.com' | 'ddog-gov.com' + | 'us2.ddog-gov.com' // Base parameters for write-only operations (only need API key) interface DatadogWriteOnlyParams { @@ -21,21 +25,34 @@ interface DatadogBaseParams extends DatadogWriteOnlyParams { applicationKey: string } +/** + * A JSON:API resource object as returned by Datadog v2 endpoints. v1 endpoints + * return flat objects instead, so this wrapper is only used for v2 payloads. + */ +export interface DatadogV2Resource { + id?: string + type?: string + attributes?: TAttributes +} + // METRICS TYPES export type MetricType = 'gauge' | 'rate' | 'count' | 'distribution' -interface MetricPoint { +export interface MetricPoint { timestamp: number value: number } -interface MetricSeries { +export interface MetricSeries { metric: string type?: MetricType points: MetricPoint[] tags?: string[] unit?: string + /** Required by Datadog when `type` is `rate` or `count`, in seconds. */ + interval?: number + sourceTypeName?: string resources?: { name: string; type: string }[] } @@ -63,6 +80,18 @@ interface TimeseriesPoint { value: number } +/** + * One series of a v1 metrics query response (`MetricsQueryMetadata`). Datadog + * returns the metric under `metric` for plain queries and `expression` for + * arithmetic/function queries. + */ +export interface MetricsQuerySeries { + metric?: string + expression?: string + tag_set?: string[] + pointlist?: [number, number][] +} + interface TimeseriesResult { metric: string tags: string[] @@ -71,48 +100,13 @@ interface TimeseriesResult { interface QueryTimeseriesOutput { series: TimeseriesResult[] - status: string + status?: string } export interface QueryTimeseriesResponse extends ToolResponse { output: QueryTimeseriesOutput } -interface ListMetricsParams extends DatadogBaseParams { - from?: number // Unix timestamp - only return metrics active since this time - host?: string // Filter by host name - tags?: string // Filter by tags (comma-separated) -} - -interface ListMetricsOutput { - metrics: string[] -} - -interface ListMetricsResponse extends ToolResponse { - output: ListMetricsOutput -} - -interface GetMetricMetadataParams extends DatadogBaseParams { - metricName: string -} - -interface MetricMetadata { - description?: string - short_name?: string - unit?: string - per_unit?: string - type?: string - integration?: string -} - -interface GetMetricMetadataOutput { - metadata: MetricMetadata -} - -interface GetMetricMetadataResponse extends ToolResponse { - output: GetMetricMetadataOutput -} - // EVENTS TYPES export type EventAlertType = @@ -137,13 +131,13 @@ export interface CreateEventParams extends DatadogWriteOnlyParams { dateHappened?: number // Unix timestamp } -interface EventData { - id: number - title: string - text: string - date_happened: number - priority: string - alert_type: string +export interface EventData { + id?: number + title?: string + text?: string + date_happened?: number + priority?: string + alert_type?: string host?: string tags?: string[] url?: string @@ -157,37 +151,6 @@ export interface CreateEventResponse extends ToolResponse { output: CreateEventOutput } -interface GetEventParams extends DatadogBaseParams { - eventId: string -} - -interface GetEventOutput { - event: EventData -} - -interface GetEventResponse extends ToolResponse { - output: GetEventOutput -} - -interface QueryEventsParams extends DatadogBaseParams { - start: number // Unix timestamp - end: number // Unix timestamp - priority?: EventPriority - sources?: string // Comma-separated source names - tags?: string // Comma-separated tags - unaggregated?: boolean - excludeAggregate?: boolean - page?: number -} - -interface QueryEventsOutput { - events: EventData[] -} - -interface QueryEventsResponse extends ToolResponse { - output: QueryEventsOutput -} - // MONITORS TYPES export type MonitorType = @@ -235,11 +198,11 @@ export interface CreateMonitorParams extends DatadogBaseParams { options?: string // JSON string of MonitorOptions } -interface MonitorData { - id: number - name: string - type: string - query: string +export interface MonitorData { + id?: number + name?: string + type?: string + query?: string message?: string tags?: string[] priority?: number @@ -272,37 +235,6 @@ export interface GetMonitorResponse extends ToolResponse { output: GetMonitorOutput } -interface UpdateMonitorParams extends DatadogBaseParams { - monitorId: string - name?: string - query?: string - message?: string - tags?: string // Comma-separated tags - priority?: number - options?: string // JSON string of MonitorOptions -} - -interface UpdateMonitorOutput { - monitor: MonitorData -} - -interface UpdateMonitorResponse extends ToolResponse { - output: UpdateMonitorOutput -} - -interface DeleteMonitorParams extends DatadogBaseParams { - monitorId: string - force?: boolean -} - -interface DeleteMonitorOutput { - deleted_monitor_id: number -} - -interface DeleteMonitorResponse extends ToolResponse { - output: DeleteMonitorOutput -} - export interface ListMonitorsParams extends DatadogBaseParams { groupStates?: string // Comma-separated states name?: string // Filter by name @@ -322,42 +254,52 @@ export interface ListMonitorsResponse extends ToolResponse { output: ListMonitorsOutput } +/** + * Mute/unmute monitor are served by `POST /api/v1/monitor/{monitor_id}/mute` and + * `/unmute`. They are absent from the datadog-api-client-go generator spec but are + * still implemented by Datadog's official Python client (`datadogpy` Monitor.mute / + * Monitor.unmute), which is the source for these request shapes. + */ export interface MuteMonitorParams extends DatadogBaseParams { monitorId: string - scope?: string // Scope to mute (e.g., "host:myhost") - end?: number // Unix timestamp when mute ends -} - -interface MuteMonitorOutput { - success: boolean -} - -export interface MuteMonitorResponse extends ToolResponse { - output: MuteMonitorOutput + scope?: string + end?: number } -interface UnmuteMonitorParams extends DatadogBaseParams { +export interface UnmuteMonitorParams extends DatadogBaseParams { monitorId: string scope?: string allScopes?: boolean } -interface UnmuteMonitorOutput { +interface MonitorMuteOutput { success: boolean + monitorId?: number + name?: string + overallState?: string +} + +export interface MuteMonitorResponse extends ToolResponse { + output: MonitorMuteOutput } -interface UnmuteMonitorResponse extends ToolResponse { - output: UnmuteMonitorOutput +export interface UnmuteMonitorResponse extends ToolResponse { + output: MonitorMuteOutput } // LOGS TYPES -interface LogEntry { +/** + * One entry for the Datadog log intake. Datadog treats any key beyond the reserved + * ones as a structured log attribute, so extra properties are carried through. + */ +export interface LogEntry { ddsource?: string ddtags?: string hostname?: string message: string service?: string + [attribute: string]: unknown } export interface SendLogsParams extends DatadogWriteOnlyParams { @@ -377,21 +319,25 @@ export interface QueryLogsParams extends DatadogBaseParams { from: string // ISO-8601 or relative (now-1h) to: string // ISO-8601 or relative (now) limit?: number + cursor?: string sort?: 'timestamp' | '-timestamp' indexes?: string // Comma-separated index names } +/** Attributes of a v2 log event (`LogAttributes` in the Datadog v2 schema). */ +export interface LogAttributes { + timestamp?: string + host?: string + service?: string + message?: string + status?: string + attributes?: Record + tags?: string[] +} + interface LogData { - id: string - content: { - timestamp: string - host?: string - service?: string - message: string - status?: string - attributes?: Record - tags?: string[] - } + id?: string + content: LogAttributes } interface QueryLogsOutput { @@ -414,25 +360,32 @@ export interface CreateDowntimeParams extends DatadogBaseParams { monitorId?: string // Monitor ID to mute monitorTags?: string // Comma-separated tags to match monitors muteFirstRecoveryNotification?: boolean - notifyEndTypes?: string // Comma-separated: "canceled", "expired" - recurrence?: string // JSON string of recurrence config +} + +/** + * Attributes of a v2 downtime (`DowntimeResponseAttributes`). `scope` is a single + * query string, and every timestamp is an ISO-8601 string rather than epoch seconds. + */ +export interface DowntimeAttributes { + scope?: string + message?: string + schedule?: { start?: string | null; end?: string | null; timezone?: string } + status?: string + display_timezone?: string | null + created?: string + modified?: string + canceled?: string | null } interface DowntimeData { - id: number + id?: string scope: string[] message?: string start?: number end?: number timezone?: string - monitor_id?: number - monitor_tags?: string[] - mute_first_recovery_notification?: boolean - disabled?: boolean created?: number modified?: number - creator_id?: number - canceled?: number active?: boolean } @@ -446,12 +399,13 @@ export interface CreateDowntimeResponse extends ToolResponse { export interface ListDowntimesParams extends DatadogBaseParams { currentOnly?: boolean - withCreator?: boolean - monitorId?: string + limit?: number + offset?: number } interface ListDowntimesOutput { downtimes: DowntimeData[] + totalCount?: number } export interface ListDowntimesResponse extends ToolResponse { @@ -469,82 +423,158 @@ interface CancelDowntimeOutput { export interface CancelDowntimeResponse extends ToolResponse { output: CancelDowntimeOutput } - // SLO TYPES export type SloType = 'metric' | 'monitor' | 'time_slice' +export type SloTimeframe = '7d' | '30d' | '90d' | 'custom' + interface SloThreshold { - timeframe: '7d' | '30d' | '90d' | 'custom' - target: number // Target percentage (e.g., 99.9) + timeframe: SloTimeframe + target: number target_display?: string warning?: number warning_display?: string } -interface CreateSloParams extends DatadogBaseParams { - name: string - type: SloType - description?: string - tags?: string // Comma-separated tags - thresholds: string // JSON string of SloThreshold[] - // For metric-based SLO - query?: string // JSON string of { numerator: string, denominator: string } - // For monitor-based SLO - monitorIds?: string // Comma-separated monitor IDs - groups?: string // Comma-separated group names -} - interface SloData { id: string name: string type: string - description?: string + description?: string | null tags?: string[] - thresholds: SloThreshold[] - creator?: { email: string; handle: string; name: string } + thresholds?: SloThreshold[] + target_threshold?: number + warning_threshold?: number + timeframe?: string + monitor_ids?: number[] + monitor_tags?: string[] + groups?: string[] + query?: { numerator: string; denominator: string } | null + creator?: { email?: string; handle?: string; name?: string | null } | null created_at?: number modified_at?: number + configured_alert_ids?: number[] +} + +export interface ListSlosParams extends DatadogBaseParams { + ids?: string + query?: string + tagsQuery?: string + metricsQuery?: string + limit?: number + offset?: number + isDeleted?: boolean +} + +interface ListSlosOutput { + slos: SloData[] +} + +export interface ListSlosResponse extends ToolResponse { + output: ListSlosOutput +} + +export interface GetSloParams extends DatadogBaseParams { + sloId: string + withConfiguredAlertIds?: boolean +} + +interface GetSloOutput { + slo: SloData +} + +export interface GetSloResponse extends ToolResponse { + output: GetSloOutput +} + +export interface CreateSloParams extends DatadogBaseParams { + name: string + type: SloType + description?: string + tags?: string + thresholds: string + query?: string + monitorIds?: string + groups?: string + targetThreshold?: number + warningThreshold?: number + timeframe?: SloTimeframe } interface CreateSloOutput { slo: SloData } -interface CreateSloResponse extends ToolResponse { +export interface CreateSloResponse extends ToolResponse { output: CreateSloOutput } -interface GetSloHistoryParams extends DatadogBaseParams { +export interface UpdateSloParams extends CreateSloParams { sloId: string - fromTs: number // Unix timestamp - toTs: number // Unix timestamp - target?: number // Target SLO percentage +} + +interface UpdateSloOutput { + slo: SloData +} + +export interface UpdateSloResponse extends ToolResponse { + output: UpdateSloOutput +} + +export interface DeleteSloParams extends DatadogBaseParams { + sloId: string + force?: boolean +} + +interface DeleteSloOutput { + success: boolean + deletedIds: string[] +} + +export interface DeleteSloResponse extends ToolResponse { + output: DeleteSloOutput +} + +export interface GetSloHistoryParams extends DatadogBaseParams { + sloId: string + fromTs: number + toTs: number + target?: number + applyCorrection?: boolean +} + +interface SloHistorySliData { + name?: string + group?: string + sli_value?: number | null + span_precision?: number + precision?: Record + error_budget_remaining?: Record + monitor_type?: string + monitor_modified?: number + preview?: boolean + history?: number[][] } interface SloHistoryData { - from_ts: number - to_ts: number - type: string - type_id: number - sli_value?: number - overall: { - name: string - sli_value: number - span_precision: number - precision: { [key: string]: number } - } - series?: { - times: number[] - values: number[] - } + from_ts?: number + to_ts?: number + type?: string + type_id?: number + group_by?: string[] + overall?: SloHistorySliData + groups?: SloHistorySliData[] + monitors?: SloHistorySliData[] + thresholds?: Record } interface GetSloHistoryOutput { history: SloHistoryData + sliValue?: number | null } -interface GetSloHistoryResponse extends ToolResponse { +export interface GetSloHistoryResponse extends ToolResponse { output: GetSloHistoryOutput } @@ -552,39 +582,53 @@ interface GetSloHistoryResponse extends ToolResponse { export type DashboardLayoutType = 'ordered' | 'free' -interface CreateDashboardParams extends DatadogBaseParams { - title: string - layoutType: DashboardLayoutType - description?: string - widgets?: string // JSON string of widget definitions - isReadOnly?: boolean - notifyList?: string // Comma-separated user handles to notify - templateVariables?: string // JSON string of template variable definitions - tags?: string // Comma-separated tags -} - interface DashboardData { - id: string - title: string - layout_type: string - description?: string + id?: string + title?: string + layout_type?: string + description?: string | null + url?: string + author_handle?: string + author_name?: string | null + created_at?: string + modified_at?: string + is_read_only?: boolean + reflow_type?: string + tags?: string[] | null + notify_list?: string[] | null + restricted_roles?: string[] + template_variables?: unknown[] | null + widgets?: unknown[] +} + +interface DashboardSummaryData { + id?: string + title?: string + description?: string | null + layout_type?: string url?: string author_handle?: string created_at?: string modified_at?: string is_read_only?: boolean - tags?: string[] } -interface CreateDashboardOutput { - dashboard: DashboardData +export interface ListDashboardsParams extends DatadogBaseParams { + filterShared?: boolean + filterDeleted?: boolean + count?: number + start?: number } -interface CreateDashboardResponse extends ToolResponse { - output: CreateDashboardOutput +interface ListDashboardsOutput { + dashboards: DashboardSummaryData[] +} + +export interface ListDashboardsResponse extends ToolResponse { + output: ListDashboardsOutput } -interface GetDashboardParams extends DatadogBaseParams { +export interface GetDashboardParams extends DatadogBaseParams { dashboardId: string } @@ -592,160 +636,525 @@ interface GetDashboardOutput { dashboard: DashboardData } -interface GetDashboardResponse extends ToolResponse { +export interface GetDashboardResponse extends ToolResponse { output: GetDashboardOutput } -interface ListDashboardsParams extends DatadogBaseParams { - filterShared?: boolean - filterDeleted?: boolean - count?: number - start?: number -} - -interface DashboardSummary { - id: string +export interface CreateDashboardParams extends DatadogBaseParams { title: string + layoutType: DashboardLayoutType + widgets: string description?: string - layout_type: string - url?: string - author_handle?: string - created_at?: string - modified_at?: string - is_read_only?: boolean - popularity?: number + notifyList?: string + templateVariables?: string + tags?: string + reflowType?: 'auto' | 'fixed' } -interface ListDashboardsOutput { - dashboards: DashboardSummary[] - total?: number +interface CreateDashboardOutput { + dashboard: DashboardData } -interface ListDashboardsResponse extends ToolResponse { - output: ListDashboardsOutput +export interface CreateDashboardResponse extends ToolResponse { + output: CreateDashboardOutput } -// HOSTS TYPES +export interface DeleteDashboardParams extends DatadogBaseParams { + dashboardId: string +} -interface ListHostsParams extends DatadogBaseParams { - filter?: string // Filter hosts by name, alias, or tag - sortField?: string // Field to sort by - sortDir?: 'asc' | 'desc' - start?: number // Starting offset - count?: number // Max hosts to return - from?: number // Unix timestamp - hosts seen in last N seconds - includeMutedHostsData?: boolean - includeHostsMetadata?: boolean +interface DeleteDashboardOutput { + success: boolean + deletedDashboardId?: string } -interface HostData { - name: string - id: number - aliases?: string[] - apps?: string[] - aws_name?: string - host_name?: string - is_muted?: boolean - last_reported_time?: number - meta?: { - agent_version?: string - cpu_cores?: number - gohai?: string - machine?: string - platform?: string - } - metrics?: { - cpu?: number - iowait?: number - load?: number - } - mute_timeout?: number - sources?: string[] - tags_by_source?: Record - up?: boolean +export interface DeleteDashboardResponse extends ToolResponse { + output: DeleteDashboardOutput } -interface ListHostsOutput { - hosts: HostData[] - total_matching?: number - total_returned?: number +// SYNTHETICS TYPES + +export type SyntheticsTestPauseStatus = 'live' | 'paused' + +export interface SyntheticsTestData { + public_id?: string + name?: string + status?: string + type?: string + subtype?: string + message?: string + monitor_id?: number + tags?: string[] + locations?: string[] + config?: unknown + options?: unknown + creator?: { email?: string; handle?: string; name?: string | null } | null } -interface ListHostsResponse extends ToolResponse { - output: ListHostsOutput +export interface ListSyntheticsTestsParams extends DatadogBaseParams { + pageSize?: number + pageNumber?: number } -// INCIDENTS TYPES +interface ListSyntheticsTestsOutput { + tests: SyntheticsTestData[] +} -export type IncidentSeverity = 'SEV-1' | 'SEV-2' | 'SEV-3' | 'SEV-4' | 'SEV-5' | 'UNKNOWN' -export type IncidentState = 'active' | 'stable' | 'resolved' +export interface ListSyntheticsTestsResponse extends ToolResponse { + output: ListSyntheticsTestsOutput +} -interface CreateIncidentParams extends DatadogBaseParams { - title: string - customerImpacted: boolean - severity?: IncidentSeverity - fields?: string // JSON string of additional fields +export interface GetSyntheticsTestParams extends DatadogBaseParams { + publicId: string } -interface IncidentData { - id: string - type: string - attributes: { - title: string - customer_impacted: boolean - severity?: IncidentSeverity - state?: IncidentState - created?: string - modified?: string - resolved?: string - detected?: string - customer_impact_scope?: string - customer_impact_start?: string - customer_impact_end?: string - public_id?: number - time_to_detect?: number - time_to_internal_response?: number - time_to_repair?: number - time_to_resolve?: number +interface GetSyntheticsTestOutput { + test: SyntheticsTestData +} + +export interface GetSyntheticsTestResponse extends ToolResponse { + output: GetSyntheticsTestOutput +} + +export interface GetSyntheticsResultsParams extends DatadogBaseParams { + publicId: string + fromTs?: number + toTs?: number + probeDc?: string +} + +interface SyntheticsResultData { + result_id?: string + check_time?: number + probe_dc?: string + status?: number + result?: { passed?: boolean; timings?: Record } +} + +interface GetSyntheticsResultsOutput { + results: SyntheticsResultData[] + lastTimestampFetched?: number +} + +export interface GetSyntheticsResultsResponse extends ToolResponse { + output: GetSyntheticsResultsOutput +} + +export interface GetBrowserSyntheticsResultsParams extends DatadogBaseParams { + publicId: string + fromTs?: number + toTs?: number + probeDc?: string +} + +/** + * Result of a single browser test run (`SyntheticsBrowserTestResultShortResult`). + * Unlike the API-test result shape, these fields are camelCase. + */ +interface SyntheticsBrowserResultData { + result_id?: string + check_time?: number + probe_dc?: string + status?: number + result?: { + duration?: number + errorCount?: number + stepCountCompleted?: number + stepCountTotal?: number + device?: Record } } -interface CreateIncidentOutput { - incident: IncidentData +interface GetBrowserSyntheticsResultsOutput { + results: SyntheticsBrowserResultData[] + lastTimestampFetched?: number } -interface CreateIncidentResponse extends ToolResponse { - output: CreateIncidentOutput +export interface GetBrowserSyntheticsResultsResponse extends ToolResponse { + output: GetBrowserSyntheticsResultsOutput +} + +export interface TriggerSyntheticsTestsParams extends DatadogBaseParams { + publicIds: string +} + +interface TriggerSyntheticsTestsOutput { + batchId?: string | null + triggeredCheckIds: string[] + results: { + public_id?: string + result_id?: string + location?: number + device?: string + }[] + locations: { id?: number; name?: string }[] +} + +export interface TriggerSyntheticsTestsResponse extends ToolResponse { + output: TriggerSyntheticsTestsOutput } -interface ListIncidentsParams extends DatadogBaseParams { +export interface UpdateSyntheticsStatusParams extends DatadogBaseParams { + publicId: string + newStatus: SyntheticsTestPauseStatus +} + +interface UpdateSyntheticsStatusOutput { + success: boolean + status?: SyntheticsTestPauseStatus +} + +export interface UpdateSyntheticsStatusResponse extends ToolResponse { + output: UpdateSyntheticsStatusOutput +} + +// SECURITY MONITORING TYPES + +export type SecuritySignalState = 'open' | 'archived' | 'under_review' + +export type SecuritySignalArchiveReason = + | 'none' + | 'false_positive' + | 'testing_or_maintenance' + | 'remediated' + | 'investigated_case_opened' + | 'true_positive_benign' + | 'true_positive_malicious' + | 'other' + +/** Attributes of a Cloud SIEM signal (`SecurityMonitoringSignalAttributes`). */ +export interface SecuritySignalAttributes { + message?: string + timestamp?: string + tags?: string[] + custom?: Record +} + +interface SecuritySignalData { + id?: string + type?: string + attributes: SecuritySignalAttributes +} + +export interface ListSecuritySignalsParams extends DatadogBaseParams { + query?: string + from?: string + to?: string + sort?: 'timestamp' | '-timestamp' + cursor?: string + limit?: number +} + +interface ListSecuritySignalsOutput { + signals: SecuritySignalData[] + nextCursor?: string +} + +export interface ListSecuritySignalsResponse extends ToolResponse { + output: ListSecuritySignalsOutput +} + +export interface GetSecuritySignalParams extends DatadogBaseParams { + signalId: string +} + +interface GetSecuritySignalOutput { + signal: SecuritySignalData +} + +export interface GetSecuritySignalResponse extends ToolResponse { + output: GetSecuritySignalOutput +} + +export interface SecuritySignalTriageData { + id?: string + type?: string + state?: string + assignee?: { uuid?: string; handle?: string; name?: string | null; id?: number } + incidentIds?: number[] + archiveReason?: string + archiveComment?: string + stateUpdateTimestamp?: number +} + +export interface UpdateSecuritySignalStateParams extends DatadogBaseParams { + signalId: string + state: SecuritySignalState + archiveReason?: SecuritySignalArchiveReason + archiveComment?: string +} + +interface UpdateSecuritySignalStateOutput { + signal: SecuritySignalTriageData +} + +export interface UpdateSecuritySignalStateResponse extends ToolResponse { + output: UpdateSecuritySignalStateOutput +} + +export interface UpdateSecuritySignalAssigneeParams extends DatadogBaseParams { + signalId: string + assigneeUuid: string +} + +interface UpdateSecuritySignalAssigneeOutput { + signal: SecuritySignalTriageData +} + +export interface UpdateSecuritySignalAssigneeResponse extends ToolResponse { + output: UpdateSecuritySignalAssigneeOutput +} + +export interface ListSecurityRulesParams extends DatadogBaseParams { query?: string + sort?: string + pageSize?: number + pageNumber?: number +} + +export interface SecurityRuleData { + id?: string + name?: string + type?: string + message?: string + tags?: string[] + isEnabled?: boolean + isDefault?: boolean + createdAt?: number + version?: number +} + +interface ListSecurityRulesOutput { + rules: SecurityRuleData[] +} + +export interface ListSecurityRulesResponse extends ToolResponse { + output: ListSecurityRulesOutput +} + +// APM / SPANS TYPES + +export interface SearchSpansParams extends DatadogBaseParams { + query?: string + from?: string + to?: string + sort?: 'timestamp' | '-timestamp' + cursor?: string + limit?: number +} + +/** Attributes of an indexed APM span (`SpansAttributes`). */ +export interface SpanAttributes { + service?: string + resource_name?: string + env?: string + host?: string + type?: string + trace_id?: string + span_id?: string + parent_id?: string + start_timestamp?: string + end_timestamp?: string + ingestion_reason?: string + retained_by?: string + single_span?: boolean + tags?: string[] + custom?: Record + attributes?: Record +} + +interface SpanData { + id?: string + type?: string + attributes: SpanAttributes +} + +interface SearchSpansOutput { + spans: SpanData[] + nextCursor?: string + elapsed?: number +} + +export interface SearchSpansResponse extends ToolResponse { + output: SearchSpansOutput +} + +export interface ListServicesParams extends DatadogBaseParams { + pageSize?: number + pageNumber?: number + schemaVersion?: string +} + +/** + * Attributes of a service definition (`ServiceDefinitionDataAttributes`). `schema` + * is a union of the v2, v2.1 and v2.2 schema versions, so it stays opaque. + */ +export interface ServiceDefinitionAttributes { + schema?: unknown + meta?: Record +} + +interface ServiceDefinitionData { + id?: string + type?: string + schema?: unknown + meta?: Record +} + +interface ListServicesOutput { + services: ServiceDefinitionData[] +} + +export interface ListServicesResponse extends ToolResponse { + output: ListServicesOutput +} + +// INCIDENTS TYPES + +export type IncidentSeverity = 'UNKNOWN' | 'SEV-0' | 'SEV-1' | 'SEV-2' | 'SEV-3' | 'SEV-4' | 'SEV-5' + +interface IncidentFieldValue { + type?: string + value?: string | string[] | null +} + +/** Attributes of an incident (`IncidentResponseAttributes`). */ +export interface IncidentAttributes { + title?: string + state?: string | null + severity?: string + visibility?: string | null + customer_impacted?: boolean + customer_impact_scope?: string | null + customer_impact_start?: string | null + customer_impact_end?: string | null + customer_impact_duration?: number + fields?: Record + incident_type_uuid?: string + is_test?: boolean + public_id?: number + created?: string + modified?: string + declared?: string + detected?: string | null + resolved?: string | null + archived?: string | null + time_to_detect?: number + time_to_internal_response?: number + time_to_repair?: number + time_to_resolve?: number +} + +interface IncidentData { + id?: string + type?: string + attributes: IncidentAttributes +} + +export interface ListIncidentsParams extends DatadogBaseParams { + include?: string pageSize?: number pageOffset?: number - include?: string // Comma-separated: users, attachments } interface ListIncidentsOutput { incidents: IncidentData[] + nextOffset?: number } -interface ListIncidentsResponse extends ToolResponse { +export interface ListIncidentsResponse extends ToolResponse { output: ListIncidentsOutput } +export interface GetIncidentParams extends DatadogBaseParams { + incidentId: string + include?: string +} + +interface GetIncidentOutput { + incident: IncidentData +} + +export interface GetIncidentResponse extends ToolResponse { + output: GetIncidentOutput +} + +export interface CreateIncidentParams extends DatadogBaseParams { + title: string + customerImpacted: boolean + severity?: IncidentSeverity + customerImpactScope?: string + incidentTypeUuid?: string + isTest?: boolean + fields?: string + notificationHandles?: string +} + +interface CreateIncidentOutput { + incident: IncidentData +} + +export interface CreateIncidentResponse extends ToolResponse { + output: CreateIncidentOutput +} + +export interface UpdateIncidentParams extends DatadogBaseParams { + incidentId: string + title?: string + severity?: IncidentSeverity + customerImpacted?: boolean + customerImpactScope?: string + customerImpactStart?: string + customerImpactEnd?: string + detected?: string + fields?: string + notificationHandles?: string +} + +interface UpdateIncidentOutput { + incident: IncidentData +} + +export interface UpdateIncidentResponse extends ToolResponse { + output: UpdateIncidentOutput +} + +export interface AddIncidentTodoParams extends DatadogBaseParams { + incidentId: string + content: string + assignees: string + dueDate?: string +} + +interface IncidentTodoData { + id?: string + type?: string + attributes: { + content?: string + assignees?: string[] + completed?: string | null + due_date?: string | null + incident_id?: string + created?: string + modified?: string + } +} + +interface AddIncidentTodoOutput { + todo: IncidentTodoData +} + +export interface AddIncidentTodoResponse extends ToolResponse { + output: AddIncidentTodoOutput +} + // Union type for all Datadog responses export type DatadogResponse = | SubmitMetricsResponse | QueryTimeseriesResponse - | ListMetricsResponse - | GetMetricMetadataResponse | CreateEventResponse - | GetEventResponse - | QueryEventsResponse | CreateMonitorResponse | GetMonitorResponse - | UpdateMonitorResponse - | DeleteMonitorResponse | ListMonitorsResponse | MuteMonitorResponse | UnmuteMonitorResponse @@ -754,11 +1163,31 @@ export type DatadogResponse = | CreateDowntimeResponse | ListDowntimesResponse | CancelDowntimeResponse + | ListSlosResponse + | GetSloResponse | CreateSloResponse + | UpdateSloResponse + | DeleteSloResponse | GetSloHistoryResponse - | CreateDashboardResponse - | GetDashboardResponse | ListDashboardsResponse - | ListHostsResponse - | CreateIncidentResponse + | GetDashboardResponse + | CreateDashboardResponse + | DeleteDashboardResponse + | ListSyntheticsTestsResponse + | GetSyntheticsTestResponse + | GetSyntheticsResultsResponse + | GetBrowserSyntheticsResultsResponse + | TriggerSyntheticsTestsResponse + | UpdateSyntheticsStatusResponse + | ListSecuritySignalsResponse + | GetSecuritySignalResponse + | UpdateSecuritySignalStateResponse + | UpdateSecuritySignalAssigneeResponse + | ListSecurityRulesResponse + | SearchSpansResponse + | ListServicesResponse | ListIncidentsResponse + | GetIncidentResponse + | CreateIncidentResponse + | UpdateIncidentResponse + | AddIncidentTodoResponse diff --git a/apps/sim/tools/datadog/unmute_monitor.ts b/apps/sim/tools/datadog/unmute_monitor.ts new file mode 100644 index 00000000000..5fc48af2b56 --- /dev/null +++ b/apps/sim/tools/datadog/unmute_monitor.ts @@ -0,0 +1,108 @@ +import type { UnmuteMonitorParams, UnmuteMonitorResponse } from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const unmuteMonitorTool: ToolConfig = { + id: 'datadog_unmute_monitor', + name: 'Datadog Unmute Monitor', + description: + 'Unmute a monitor so it resumes sending notifications. Reverses Mute Monitor, either for one scope or for every scope at once.', + version: '1.0.0', + + params: { + monitorId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the monitor to unmute (e.g., "12345678")', + }, + scope: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Scope to unmute (e.g., "host:myhost"). Leave blank to unmute the monitor itself rather than a single scope.', + }, + allScopes: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Clear the mute settings for every scope on this monitor', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl(params.site, `/api/v1/monitor/${encodeURIComponent(params.monitorId)}/unmute`), + method: 'POST', + headers: datadogHeaders, + body: (params) => { + const body: { scope?: string; all_scopes?: boolean } = {} + if (params.scope) body.scope = params.scope + if (params.allScopes !== undefined) body.all_scopes = params.allScopes + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json().catch(() => ({})) + + return { + success: true, + output: { + success: true, + monitorId: data.id, + name: data.name, + overallState: data.overall_state, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the monitor was successfully unmuted', + }, + monitorId: { + type: 'number', + description: 'ID of the unmuted monitor', + optional: true, + }, + name: { + type: 'string', + description: 'Name of the unmuted monitor', + optional: true, + }, + overallState: { + type: 'string', + description: 'Monitor state after unmuting', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/datadog/update_incident.ts b/apps/sim/tools/datadog/update_incident.ts new file mode 100644 index 00000000000..7be32b485cc --- /dev/null +++ b/apps/sim/tools/datadog/update_incident.ts @@ -0,0 +1,180 @@ +import type { UpdateIncidentParams, UpdateIncidentResponse } from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + parseJsonParam, + splitCommaList, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateIncidentTool: ToolConfig = { + id: 'datadog_update_incident', + name: 'Datadog Update Incident', + description: + 'Partially update an existing incident. Requires the Incident Management `incident_write` permission; the Incidents API is in public beta.', + version: '1.0.0', + + params: { + incidentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the incident to update', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New title for the incident', + }, + severity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Incident severity: UNKNOWN, SEV-0, SEV-1, SEV-2, SEV-3, SEV-4, or SEV-5', + }, + customerImpacted: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the incident caused customer impact', + }, + customerImpactScope: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Summary of the customer impact', + }, + customerImpactStart: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO-8601 timestamp when customers began being impacted', + }, + customerImpactEnd: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO-8601 timestamp when customers were no longer impacted', + }, + detected: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO-8601 timestamp when the incident was detected', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of user-defined incident fields to update, e.g. {"state": {"type": "dropdown", "value": "resolved"}}', + }, + notificationHandles: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated handles to notify about the update', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl(params.site, `/api/v2/incidents/${encodeURIComponent(params.incidentId)}`), + method: 'PATCH', + headers: datadogHeaders, + body: (params) => { + const fields = + parseJsonParam>(params.fields, 'fields parameter') ?? {} + if (params.severity) { + fields.severity = { type: 'dropdown', value: params.severity } + } + + /** + * Only non-empty values are sent. An empty string is how an untouched input + * arrives, and Datadog would take it literally — blanking the stored title or + * rejecting the request as an invalid `date-time`. + */ + const attributes: Record = {} + if (params.title) attributes.title = params.title + if (params.customerImpacted !== undefined) + attributes.customer_impacted = params.customerImpacted + if (params.customerImpactScope) attributes.customer_impact_scope = params.customerImpactScope + if (params.customerImpactStart) attributes.customer_impact_start = params.customerImpactStart + if (params.customerImpactEnd) attributes.customer_impact_end = params.customerImpactEnd + if (params.detected) attributes.detected = params.detected + if (Object.keys(fields).length > 0) attributes.fields = fields + + const handles = splitCommaList(params.notificationHandles) + if (handles) { + attributes.notification_handles = handles.map((handle) => ({ handle })) + } + + return { data: { type: 'incidents', id: params.incidentId, attributes } } + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { incident: { id: '', attributes: {} } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + incident: { + id: data.data?.id, + type: data.data?.type, + attributes: data.data?.attributes ?? {}, + }, + }, + } + }, + + outputs: { + incident: { + type: 'object', + description: 'The updated incident', + properties: { + id: { type: 'string', description: 'Incident UUID' }, + type: { type: 'string', description: 'Resource type (incidents)' }, + attributes: { + type: 'object', + description: 'Incident attributes', + properties: { + title: { type: 'string', description: 'Incident title' }, + state: { type: 'string', description: 'Incident state' }, + severity: { type: 'string', description: 'Incident severity' }, + modified: { type: 'string', description: 'Last modification timestamp' }, + resolved: { type: 'string', description: 'Resolution timestamp' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/update_security_signal_assignee.ts b/apps/sim/tools/datadog/update_security_signal_assignee.ts new file mode 100644 index 00000000000..00a41db112b --- /dev/null +++ b/apps/sim/tools/datadog/update_security_signal_assignee.ts @@ -0,0 +1,104 @@ +import type { + UpdateSecuritySignalAssigneeParams, + UpdateSecuritySignalAssigneeResponse, +} from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + mapSignalTriageData, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateSecuritySignalAssigneeTool: ToolConfig< + UpdateSecuritySignalAssigneeParams, + UpdateSecuritySignalAssigneeResponse +> = { + id: 'datadog_update_security_signal_assignee', + name: 'Datadog Assign Security Signal', + description: + 'Assign a Cloud SIEM security signal to a Datadog user by UUID. Requires the `security_monitoring_signals_write` permission.', + version: '1.0.0', + + params: { + signalId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the security signal', + }, + assigneeUuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'UUID of the Datadog user to assign the signal to (e.g., "773b045d-ccf8-4808-bd3b-955ef6a8c940")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl( + params.site, + `/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}/assignee` + ), + method: 'PATCH', + headers: datadogHeaders, + body: (params) => ({ + data: { attributes: { assignee: { uuid: params.assigneeUuid } } }, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { signal: {} }, + error: await datadogErrorMessage(response), + } + } + + return { + success: true, + output: { signal: mapSignalTriageData(await response.json()) }, + } + }, + + outputs: { + signal: { + type: 'object', + description: 'The updated signal triage data', + properties: { + id: { type: 'string', description: 'Signal ID' }, + type: { type: 'string', description: 'Resource type of the signal' }, + state: { type: 'string', description: 'Current triage state' }, + assignee: { type: 'object', description: 'User the signal is assigned to' }, + incidentIds: { type: 'array', description: 'IDs of incidents linked to the signal' }, + archiveReason: { type: 'string', description: 'Archive reason, when archived' }, + archiveComment: { type: 'string', description: 'Archive comment, when archived' }, + stateUpdateTimestamp: { + type: 'number', + description: 'Timestamp of the last state update', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/update_security_signal_state.ts b/apps/sim/tools/datadog/update_security_signal_state.ts new file mode 100644 index 00000000000..78a87fb082c --- /dev/null +++ b/apps/sim/tools/datadog/update_security_signal_state.ts @@ -0,0 +1,119 @@ +import type { + UpdateSecuritySignalStateParams, + UpdateSecuritySignalStateResponse, +} from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + mapSignalTriageData, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateSecuritySignalStateTool: ToolConfig< + UpdateSecuritySignalStateParams, + UpdateSecuritySignalStateResponse +> = { + id: 'datadog_update_security_signal_state', + name: 'Datadog Update Security Signal State', + description: + 'Change the triage state of a Cloud SIEM security signal to open, under_review, or archived. Requires the `security_monitoring_signals_write` permission.', + version: '1.0.0', + + params: { + signalId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the security signal', + }, + state: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New triage state: "open", "under_review", or "archived"', + }, + archiveReason: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Reason when archiving: none, false_positive, testing_or_maintenance, remediated, investigated_case_opened, true_positive_benign, true_positive_malicious, or other', + }, + archiveComment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comment explaining why the signal was archived', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl( + params.site, + `/api/v2/security_monitoring/signals/${encodeURIComponent(params.signalId)}/state` + ), + method: 'PATCH', + headers: datadogHeaders, + body: (params) => { + const attributes: Record = { state: params.state } + if (params.archiveReason) attributes.archive_reason = params.archiveReason + if (params.archiveComment) attributes.archive_comment = params.archiveComment + return { data: { attributes } } + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + return { + success: false, + output: { signal: {} }, + error: await datadogErrorMessage(response), + } + } + + return { + success: true, + output: { signal: mapSignalTriageData(await response.json()) }, + } + }, + + outputs: { + signal: { + type: 'object', + description: 'The updated signal triage data', + properties: { + id: { type: 'string', description: 'Signal ID' }, + type: { type: 'string', description: 'Resource type of the signal' }, + state: { type: 'string', description: 'Current triage state' }, + assignee: { type: 'object', description: 'User the signal is assigned to' }, + incidentIds: { type: 'array', description: 'IDs of incidents linked to the signal' }, + archiveReason: { type: 'string', description: 'Archive reason, when archived' }, + archiveComment: { type: 'string', description: 'Archive comment, when archived' }, + stateUpdateTimestamp: { + type: 'number', + description: 'Timestamp of the last state update', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/update_slo.ts b/apps/sim/tools/datadog/update_slo.ts new file mode 100644 index 00000000000..9d01b1da86c --- /dev/null +++ b/apps/sim/tools/datadog/update_slo.ts @@ -0,0 +1,185 @@ +import type { UpdateSloParams, UpdateSloResponse } from '@/tools/datadog/types' +import { + datadogApiUrl, + datadogErrorMessage, + datadogHeaders, + mergeSloUpdatePayload, +} from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateSloTool: ToolConfig = { + id: 'datadog_update_slo', + name: 'Datadog Update SLO', + description: + 'Update a service level objective. Reads the current SLO first and applies only the fields you supply, so anything left blank keeps its stored value.', + version: '1.0.0', + + params: { + sloId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the service level objective to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the SLO. Leave blank to keep the current name.', + }, + type: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'SLO type: "metric" or "monitor". Leave blank to keep the current type. Changing type requires supplying the matching query or monitorIds.', + }, + thresholds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of thresholds replacing the stored ones, e.g. [{"timeframe": "30d", "target": 99.9, "warning": 99.95}]. Leave blank to keep the current thresholds.', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the SLO', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated tags (e.g., "env:prod,team:core")', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'For metric SLOs, JSON with numerator and denominator, e.g. {"numerator": "sum:requests{status:ok}.as_count()", "denominator": "sum:requests{*}.as_count()"}', + }, + monitorIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'For monitor SLOs, comma-separated monitor IDs (e.g., "123,456")', + }, + groups: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated monitor groups (e.g., "env:prod,role:mysql")', + }, + targetThreshold: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Primary target threshold (e.g., 99.9)', + }, + warningThreshold: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Primary warning threshold, must be greater than the target (e.g., 99.95)', + }, + timeframe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Primary timeframe: "7d", "30d", or "90d"', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => datadogApiUrl(params.site, `/api/v1/slo/${encodeURIComponent(params.sloId)}`), + method: 'PUT', + headers: datadogHeaders, + }, + + /** + * Datadog's SLO update is a full replacement, so the stored SLO is read first and + * the supplied edits are overlaid onto it. Sending only the filled-in fields would + * erase every field the caller left blank. + */ + directExecution: async (params, signal) => { + const url = datadogApiUrl(params.site, `/api/v1/slo/${encodeURIComponent(params.sloId)}`) + const headers = datadogHeaders(params) + + const existingResponse = await fetch(url, { method: 'GET', headers, signal }) + if (!existingResponse.ok) { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: `Could not load SLO ${params.sloId} before updating it: ${await datadogErrorMessage(existingResponse)}`, + } + } + + const existing = await existingResponse.json() + const stored = existing.data + if (!stored || typeof stored !== 'object') { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: `Datadog returned no SLO for id ${params.sloId}`, + } + } + + const response = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify(mergeSloUpdatePayload(stored, params)), + signal, + }) + + if (!response.ok) { + return { + success: false, + output: { slo: { id: '', name: '', type: '' } }, + error: await datadogErrorMessage(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { slo: data.data?.[0] ?? { id: '', name: '', type: '' } }, + } + }, + + outputs: { + slo: { + type: 'object', + description: 'The updated service level objective', + properties: { + id: { type: 'string', description: 'SLO ID' }, + name: { type: 'string', description: 'SLO name' }, + type: { type: 'string', description: 'SLO type' }, + description: { type: 'string', description: 'SLO description' }, + tags: { type: 'array', description: 'SLO tags' }, + thresholds: { type: 'array', description: 'Timeframe targets and warnings' }, + modified_at: { type: 'number', description: 'Modification timestamp (Unix seconds)' }, + }, + }, + }, +} diff --git a/apps/sim/tools/datadog/update_synthetics_status.ts b/apps/sim/tools/datadog/update_synthetics_status.ts new file mode 100644 index 00000000000..d504dc34226 --- /dev/null +++ b/apps/sim/tools/datadog/update_synthetics_status.ts @@ -0,0 +1,93 @@ +import type { + UpdateSyntheticsStatusParams, + UpdateSyntheticsStatusResponse, +} from '@/tools/datadog/types' +import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateSyntheticsStatusTool: ToolConfig< + UpdateSyntheticsStatusParams, + UpdateSyntheticsStatusResponse +> = { + id: 'datadog_update_synthetics_status', + name: 'Datadog Pause Or Start Synthetic Test', + description: 'Pause or resume a Synthetic test by setting its status to "paused" or "live".', + version: '1.0.0', + + params: { + publicId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The public ID of the Synthetic test to update', + }, + newStatus: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New status: "live" to start the test or "paused" to pause it', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog API key', + }, + applicationKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Datadog Application key', + }, + site: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Datadog site/region (default: datadoghq.com)', + }, + }, + + request: { + url: (params) => + datadogApiUrl( + params.site, + `/api/v1/synthetics/tests/${encodeURIComponent(params.publicId)}/status` + ), + method: 'PUT', + headers: datadogHeaders, + body: (params) => ({ new_status: params.newStatus }), + }, + + transformResponse: async (response: Response, params) => { + const status = params?.newStatus + + if (!response.ok) { + return { + success: false, + output: { success: false, status }, + error: await datadogErrorMessage(response), + } + } + + const updated = await response.json().catch(() => false) + + return { + success: true, + output: { + success: updated === true, + status, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether Datadog reported the status update as successful', + }, + status: { + type: 'string', + description: 'The status that was requested: live or paused', + }, + }, +} diff --git a/apps/sim/tools/datadog/utils.ts b/apps/sim/tools/datadog/utils.ts new file mode 100644 index 00000000000..45d744a78a5 --- /dev/null +++ b/apps/sim/tools/datadog/utils.ts @@ -0,0 +1,235 @@ +import type { + CreateSloParams, + DatadogSite, + SecuritySignalTriageData, + UpdateSloParams, +} from '@/tools/datadog/types' + +/** + * Builds a fully-qualified Datadog API URL for the caller's site/region. + * Datadog serves each region from its own host (`datadoghq.com`, `datadoghq.eu`, + * `ddog-gov.com`, ...), so every request must be built from the configured site. + */ +export function datadogApiUrl(site: DatadogSite | undefined, path: string): string { + return `https://api.${site || 'datadoghq.com'}${path}` +} + +/** Standard Datadog authentication headers for API + application key auth. */ +export function datadogHeaders(params: { + apiKey: string + applicationKey: string +}): Record { + return { + 'Content-Type': 'application/json', + 'DD-API-KEY': params.apiKey, + 'DD-APPLICATION-KEY': params.applicationKey, + } +} + +/** Reads one Datadog error entry, which is a plain string (v1) or a JSON:API object (v2). */ +function errorEntryMessage(entry: unknown): string | null { + if (typeof entry === 'string') return entry + if (entry && typeof entry === 'object') { + const record = entry as { detail?: unknown; title?: unknown } + if (typeof record.detail === 'string') return record.detail + if (typeof record.title === 'string') return record.title + } + return null +} + +/** + * Extracts a human-readable message from a failed Datadog response. + * + * Datadog returns `{ errors: ... }` in three shapes: an array of plain strings (v1), + * an array of JSON:API error objects carrying `detail`/`title` (v2), and — on the SLO + * delete conflict — a dictionary keyed by resource ID whose values are the reasons. + */ +export async function datadogErrorMessage(response: Response): Promise { + const fallback = `HTTP ${response.status}: ${response.statusText}` + const body = await response.json().catch(() => null) + const errors = (body as { errors?: unknown })?.errors + + const entries = Array.isArray(errors) + ? errors + : errors && typeof errors === 'object' + ? Object.values(errors as Record) + : [] + + const messages = entries + .map(errorEntryMessage) + .filter((message): message is string => Boolean(message)) + + return messages.length > 0 ? messages.join('; ') : fallback +} + +/** + * Splits a comma-separated user input into a trimmed, non-empty list. + * + * Accepts `unknown` because a `` reference can resolve to a non-string: + * a monitor ID read from `get_monitor` arrives as a number, and an LLM tool call can + * pass an array. Calling `.split` on those would throw before the request is built. + */ +export function splitCommaList(value: unknown): string[] | undefined { + if (value === undefined || value === null || value === '') return undefined + const items = (Array.isArray(value) ? value : String(value).split(',')) + .map((item) => String(item).trim()) + .filter((item) => item.length > 0) + return items.length > 0 ? items : undefined +} + +/** + * Parses a JSON param, throwing a descriptive error when it is malformed. + * Block inputs arrive as strings, but an upstream block reference can resolve to + * an already-parsed object, so both shapes are accepted. + */ +export function parseJsonParam(value: unknown, fieldName: string): T | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') return value as T + try { + return JSON.parse(value) as T + } catch { + throw new Error(`${fieldName} must be valid JSON`) + } +} + +/** + * Parses a comma-separated monitor ID list into the integers Datadog expects. + * `Number('abc')` yields `NaN`, which `JSON.stringify` writes as `null` and Datadog + * rejects with a message that names nothing the user typed, so bad input is rejected here. + */ +export function parseMonitorIds(value: unknown): number[] | undefined { + const items = splitCommaList(value) + if (!items) return undefined + return items.map((id) => { + const parsed = Number(id) + if (!Number.isInteger(parsed)) { + throw new Error(`monitorIds must be a comma-separated list of whole numbers (got "${id}")`) + } + return parsed + }) +} + +/** + * Builds the `ServiceLevelObjective` request body for SLO creation. + */ +export function buildSloPayload(params: CreateSloParams): Record { + const thresholds = parseJsonParam(params.thresholds, 'thresholds parameter') + if (!Array.isArray(thresholds) || thresholds.length === 0) { + throw new Error('thresholds must be a non-empty JSON array') + } + + const body: Record = { + name: params.name, + type: params.type, + thresholds, + } + + if (params.description) body.description = params.description + + const tags = splitCommaList(params.tags) + if (tags) body.tags = tags + + const query = parseJsonParam>(params.query, 'query parameter') + if (query) body.query = query + + const monitorIds = parseMonitorIds(params.monitorIds) + if (monitorIds) body.monitor_ids = monitorIds + + const groups = splitCommaList(params.groups) + if (groups) body.groups = groups + + if (params.targetThreshold !== undefined) body.target_threshold = params.targetThreshold + if (params.warningThreshold !== undefined) body.warning_threshold = params.warningThreshold + if (params.timeframe) body.timeframe = params.timeframe + + return body +} + +/** + * Fields Datadog computes and rejects or ignores on an SLO update request. + * They must be stripped from a stored SLO before it is replayed into `PUT /api/v1/slo/{slo_id}`. + */ +const SLO_READ_ONLY_FIELDS = ['id', 'created_at', 'modified_at', 'creator', 'monitor_tags'] as const + +/** + * Merges user-supplied SLO edits onto the SLO Datadog currently stores. + * + * `PUT /api/v1/slo/{slo_id}` is a full replacement, not a patch, so a request built + * only from the fields the user filled in silently erases every field they left + * blank. Reading the stored SLO first and overlaying only the supplied fields keeps + * an edit to one field from destroying the rest. + */ +export function mergeSloUpdatePayload( + stored: Record, + params: UpdateSloParams +): Record { + const body: Record = { ...stored } + for (const field of SLO_READ_ONLY_FIELDS) delete body[field] + + const thresholds = parseJsonParam(params.thresholds, 'thresholds parameter') + if (thresholds !== undefined) { + if (!Array.isArray(thresholds) || thresholds.length === 0) { + throw new Error('thresholds must be a non-empty JSON array') + } + body.thresholds = thresholds + } + + if (params.name) body.name = params.name + if (params.type) body.type = params.type + if (params.description !== undefined && params.description !== '') { + body.description = params.description + } + + const tags = splitCommaList(params.tags) + if (tags) body.tags = tags + + const query = parseJsonParam>(params.query, 'query parameter') + if (query) body.query = query + + const monitorIds = parseMonitorIds(params.monitorIds) + if (monitorIds) body.monitor_ids = monitorIds + + const groups = splitCommaList(params.groups) + if (groups) body.groups = groups + + if (params.targetThreshold !== undefined) body.target_threshold = params.targetThreshold + if (params.warningThreshold !== undefined) body.warning_threshold = params.warningThreshold + if (params.timeframe) body.timeframe = params.timeframe + + return body +} + +/** + * Projects a security signal triage response (`PATCH .../state` and `.../assignee` + * both return `SecurityMonitoringSignalTriageUpdateResponse`) onto a flat shape. + */ +export function mapSignalTriageData(data: unknown): SecuritySignalTriageData { + const payload = + ( + data as { + data?: { + id?: string + type?: string + attributes?: { + state?: string + assignee?: SecuritySignalTriageData['assignee'] + incident_ids?: number[] + archive_reason?: string + archive_comment?: string + state_update_timestamp?: number + } + } + } + )?.data ?? {} + const attributes = payload.attributes ?? {} + return { + id: payload.id, + type: payload.type, + state: attributes.state, + assignee: attributes.assignee, + incidentIds: attributes.incident_ids, + archiveReason: attributes.archive_reason, + archiveComment: attributes.archive_comment, + stateUpdateTimestamp: attributes.state_update_timestamp, + } +} diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index 2b9301b7114..75a043152f3 100644 --- a/apps/sim/tools/generated/tool-ids.ts +++ b/apps/sim/tools/generated/tool-ids.ts @@ -3,7 +3,7 @@ /** Every registered tool id, including versioned variants. */ const toolIds: string[] = JSON.parse( - '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_update_candidate","athena_batch_get_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_dns_record","cloudflare_create_zone","cloudflare_delete_dns_record","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_update_dns_record","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_query_sensors","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_cancel_downtime","datadog_create_downtime","datadog_create_event","datadog_create_monitor","datadog_get_monitor","datadog_list_downtimes","datadog_list_monitors","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_send_logs","datadog_submit_metrics","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","evernote_copy_note","evernote_create_note","evernote_create_notebook","evernote_create_tag","evernote_delete_note","evernote_get_note","evernote_get_notebook","evernote_list_notebooks","evernote_list_tags","evernote_search_notes","evernote_update_note","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_decompress","file_fetch","file_get","file_get_content","file_manage_sharing","file_parser","file_parser_v2","file_parser_v3","file_read","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_get_note","granola_list_folders","granola_list_notes","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_role","iam_get_user","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_group_member","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_users","microsoft_ad_remove_group_member","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_user","okta_add_user_to_group","okta_create_group","okta_create_user","okta_deactivate_user","okta_delete_group","okta_delete_user","okta_get_group","okta_get_user","okta_list_group_members","okta_list_groups","okta_list_users","okta_remove_user_from_group","okta_reset_password","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_aggregate","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_list_attachments","servicenow_read_record","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_conversation","slack_schedule_message","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_send","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' + '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_update_candidate","athena_batch_get_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_ruleset_rule","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_r2_bucket","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_revoke_access_service_token","cloudflare_update_access_application","cloudflare_update_access_policy","cloudflare_update_dns_record","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset_rule","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","evernote_copy_note","evernote_create_note","evernote_create_notebook","evernote_create_tag","evernote_delete_note","evernote_get_note","evernote_get_notebook","evernote_list_notebooks","evernote_list_tags","evernote_search_notes","evernote_update_note","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_decompress","file_fetch","file_get","file_get_content","file_manage_sharing","file_parser","file_parser_v2","file_parser_v3","file_read","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_get_note","granola_list_folders","granola_list_notes","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_role","iam_get_user","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_conversation","slack_schedule_message","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_send","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' ) export default toolIds diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index f31b7ba2b19..f76ac0d4b79 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}}},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}}},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}}},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}}},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}}},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}}},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}}},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}}},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}}},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}}},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}}},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}}},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}}},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}}},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}}},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}}},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}}},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}}},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}}},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}}},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}}},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}}},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}}},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}}},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}}},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}}},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}}},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}}},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}}},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}}},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}}},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}}},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}}},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}}},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}}},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}}},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}}},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}}},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}}},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}}},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}}},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}}},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}}},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}}},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}}},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}}},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}}},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}}},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}}},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}}},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}}},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}}},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}}},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}}},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}}},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}}},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}}},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}}},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}}},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}}},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}}},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}}},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}}},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}}},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}}},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}]}"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}}},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}}},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}}},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}}},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}}},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}}},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}}},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}}},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}}},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}}},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}}},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}}},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}}},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}}},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}}},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}}},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}}},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}}},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}}},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}}},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}}},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}}},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}}},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}}},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}}},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}}},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}}},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}}},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}}},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}}},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}}},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}}},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}}},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}}},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}}},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}}},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"}},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"}},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"}},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}}},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}}},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}}},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"}}},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in (defaults to first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"}}},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"}}},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,