diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 8d3674cbd73..5c1b43c0d07 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -29,7 +29,7 @@ import type { BrowserDownloadsState, BrowserToolbarCommand } from '@sim/desktop- import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { isRecordLike, omit } from '@sim/utils/object' +import { isRecordLike, omit, toRecord } from '@sim/utils/object' import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron' import { Menu } from 'electron' import * as cdp from '@/main/browser-agent/cdp' @@ -1015,7 +1015,7 @@ function raceAgainstWatchdog( */ async function activeElementState(target: PageExecutionTarget): Promise> { const state = await execInPage(target, readActiveElementState, []).catch(() => null) - return isRecordLike(state) ? state : {} + return toRecord(state) } function requireSnapshotForElementAction(): void { @@ -1191,7 +1191,7 @@ async function pageActionState( resetMutationRevision, elementId, ]).catch(() => null) - return isRecordLike(state) ? state : {} + return toRecord(state) } function pageEffect( @@ -2565,7 +2565,7 @@ async function executeToolInner( }, } return { - ...(isRecordLike(fallback) ? fallback : {}), + ...fallback, trusted, ...state, ...combinedObservation, @@ -2849,12 +2849,11 @@ async function executeToolInner( await sleep(50) const state = unwrapPageResult(await execInPage(target, readSelectElementState, [elementId])) const effectObserved = - isRecordLike(selected) && isRecordLike(state) && selected.selected === state.selected && selected.value === state.value return { - ...(isRecordLike(selected) ? selected : {}), + ...selected, effectObserved, readback: state, ...(!effectObserved @@ -2994,7 +2993,7 @@ async function executeToolInner( : {}), } return { - ...(isRecordLike(result) ? result : {}), + ...result, trusted, effect, possibleEffectObserved, diff --git a/apps/desktop/src/main/browser-import/browser-sources.ts b/apps/desktop/src/main/browser-import/browser-sources.ts index 800aecfc61a..7d65b37ca99 100644 --- a/apps/desktop/src/main/browser-import/browser-sources.ts +++ b/apps/desktop/src/main/browser-import/browser-sources.ts @@ -77,23 +77,6 @@ export function userDataDirFor(source: BrowserSource, home: string = homedir()): return join(home, ...source.userDataSegments) } -/** - * Splits a bridge profile id back into its browser and profile directory. - * - * Ids are namespaced (`arc:Profile 1`) because profile directory names repeat - * across browsers — every one of them has a `Default`. Returns null for - * anything malformed; the caller then resolves against discovered profiles - * anyway, so a bad id can never become a path. - */ -export function parseProfileId(profileId: string): { sourceId: string; directory: string } | null { - const separator = profileId.indexOf(':') - if (separator <= 0 || separator === profileId.length - 1) return null - return { - sourceId: profileId.slice(0, separator), - directory: profileId.slice(separator + 1), - } -} - export function formatProfileId(sourceId: string, directory: string): string { return `${sourceId}:${directory}` } diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.ts b/apps/desktop/src/main/browser-import/chromium-profiles.ts index a82525aecd5..3f87c04e3b6 100644 --- a/apps/desktop/src/main/browser-import/chromium-profiles.ts +++ b/apps/desktop/src/main/browser-import/chromium-profiles.ts @@ -2,6 +2,7 @@ import { constants } from 'node:fs' import { access, lstat, readdir, readFile, realpath } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { isRecordLike } from '@sim/utils/object' import { BROWSER_SOURCES, type BrowserSource, @@ -134,7 +135,7 @@ async function readProfileDisplayNames(userDataDir: string): Promise)) { if (!PROFILE_DIR_PATTERN.test(dir)) continue const name = (info as { name?: unknown })?.name diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index 3ac6530f1ed..859f80088e6 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -32,7 +32,6 @@ import { type TerminalToolResponse, } from '@sim/terminal-protocol' import { sleep } from '@sim/utils/helpers' -import { isRecordLike } from '@sim/utils/object' import type { BrowserWindow, WebContents } from 'electron' import { type FocusedResourceShortcut, @@ -1201,8 +1200,3 @@ export class TerminalService { function unknownTerminal(terminalId: string): string { return `No terminal with id ${terminalId}. Call terminal_list for the open ones.` } - -/** Narrows an IPC payload to the tool-call shape without trusting the sender. */ -export function parseToolParams(value: unknown): Record { - return isRecordLike(value) ? value : {} -} diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index ad17849bd0a..812d38ea311 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -3898,6 +3898,138 @@ export const ClaudeIcon = (props: SVGProps) => ( ) +export function AzureDataExplorerIcon(props: SVGProps) { + const id = useId() + const wedgeGradient = `adx_wedge_${id}` + const dashLongGradient = `adx_dash_long_${id}` + const dashTopGradient = `adx_dash_top_${id}` + const dashBottomGradient = `adx_dash_bottom_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + export function AzureIcon(props: SVGProps) { const id = useId() const gradient0 = `azure_paint0_${id}` @@ -7406,26 +7538,6 @@ export function BedrockIcon(props: SVGProps) { ) } -export function TableIcon(props: SVGProps) { - return ( - - - - - - - - ) -} export function ReductoIcon(props: SVGProps) { return ( ) { ) } +export function RabbitmqIcon(props: SVGProps) { + return ( + + + + ) +} + export function RailwayIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 426663e2b70..55d32137cac 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -3,7 +3,7 @@ // Maps block types to their icon component references import type { ComponentType, SVGProps } from 'react' -import { Library, Rocket } from '@sim/emcn/icons' +import { Library, Rocket, Table } from '@sim/emcn/icons' import { A2AIcon, AgentMailIcon, @@ -22,6 +22,7 @@ import { AshbyIcon, AthenaIcon, AttioIcon, + AzureDataExplorerIcon, AzureIcon, BoxCompanyIcon, BrainIcon, @@ -182,6 +183,7 @@ import { QdrantIcon, QuartrIcon, QuiverIcon, + RabbitmqIcon, RailwayIcon, RB2BIcon, RDSIcon, @@ -223,7 +225,6 @@ import { StagehandIcon, StripeIcon, SupabaseIcon, - TableIcon, TailscaleIcon, TavilyIcon, TelegramIcon, @@ -280,6 +281,7 @@ export const blockTypeToIconMap: Record = { ashby: AshbyIcon, athena: AthenaIcon, attio: AttioIcon, + azure_data_explorer: AzureDataExplorerIcon, azure_devops: AzureIcon, box: BoxCompanyIcon, brandfetch: BrandfetchIcon, @@ -469,6 +471,7 @@ export const blockTypeToIconMap: Record = { qdrant: QdrantIcon, quartr: QuartrIcon, quiver: QuiverIcon, + rabbitmq: RabbitmqIcon, railway: RailwayIcon, rb2b: RB2BIcon, rds: RDSIcon, @@ -514,7 +517,7 @@ export const blockTypeToIconMap: Record = { stt: STTIcon, stt_v2: STTIcon, supabase: SupabaseIcon, - table: TableIcon, + table: Table, tailscale: TailscaleIcon, tavily: TavilyIcon, telegram: TelegramIcon, diff --git a/apps/docs/content/docs/en/integrations/ashby.mdx b/apps/docs/content/docs/en/integrations/ashby.mdx index 6ead3eee841..43a9600c438 100644 --- a/apps/docs/content/docs/en/integrations/ashby.mdx +++ b/apps/docs/content/docs/en/integrations/ashby.mdx @@ -21,16 +21,51 @@ With Ashby, you can: - **Add notes to candidates**: Attach notes to candidate records to capture feedback, context, or follow-up items - **List and view jobs**: Browse all open, closed, and archived job postings with location and department info - **List applications**: View all applications across your organization with candidate and job details, status tracking, and pagination +- **Sync jobs incrementally**: Pass the sync token from a previous List Jobs run to fetch only the reqs that changed, instead of rescanning every req on each run +- **Annotate jobs and reqs**: Set custom field values on a job, application, candidate, or opening, one field at a time or several at once +- **Delete and anonymize**: Remove an application, or strip personal information from a candidate The Ashby block also supports **webhook triggers** that automatically start workflows in response to Ashby events. Available triggers include Application Submitted, Candidate Stage Change, Candidate Hired, Candidate Deleted, Job Created, and Offer Created. Webhooks are fully managed — Sim automatically creates the webhook in Ashby when you save the trigger and deletes it when you remove it, so there's no manual webhook configuration needed. Just provide your Ashby API key (with `apiKeysWrite` permission) and select the event type. In Sim, the Ashby integration enables your agents to programmatically manage your recruiting pipeline. Agents can search for candidates, create new candidate records, add notes after interviews, and monitor applications across jobs. This allows you to automate recruiting workflows like candidate intake, interview follow-ups, pipeline reporting, and cross-referencing candidates across roles. + +### API key permissions + +Ashby grants permissions per module rather than per endpoint, and a missing permission fails at **runtime**, not when you build the workflow. +A key without the right scope returns a 403 in the middle of a run, so check the key before scheduling anything against it. + +| Permission | Covers | +| --- | --- | +| `jobsRead` | List Jobs, Get Job, List Job Postings, Get Job Posting | +| `candidatesRead` | Application and candidate reads | +| `candidatesWrite` | Create Candidate, Create Application, Set Custom Field Value, Set Custom Field Values, Change Application Source, Anonymize Candidate | +| `candidatesDelete` | Delete Application. This is separate from `candidatesWrite` - a read and write key returns 403 here | +| `apiKeysWrite` | Managed webhook triggers | + +`candidatesDelete` is a module permission, not an endpoint one, and Delete Application sits under the Candidates module. + +Two more settings on the API key are checkboxes rather than module scopes, and both are easy to miss: + +- **Allow access to confidential jobs and projects.** Without it, confidential reqs and the candidates on them are invisible to the key entirely - they do not appear in List Jobs at all, rather than appearing with fields hidden. +- **Allow access to non-offer private fields.** This gates custom fields marked private outside of offers. + +Every job returned by List Jobs carries a `confidential` flag, so you can filter confidential reqs out at sync time before they reach a prompt or a downstream surface. + +### Ashby API limitations + +These are constraints of the Ashby API itself, not of the Sim block: + +- **No notes or tags on a job or req.** Both are candidate-scoped in Ashby. Custom field values are the only way to annotate a job, which is what Set Custom Field Value is for. Writing `null` clears a value, so the annotation is reversible. +- **No pagination on List Job Postings.** The endpoint returns every posting in one response, with no cursor, page limit, or sync token. Paginate through List Jobs instead when you need to page. +- **No way to delete, archive, or update a custom field definition.** Creating one is irreversible, so never create throwaway field definitions against a shared organization. +- **No note deletion endpoint.** Notes can be created and listed but never removed. +- **No candidate deletion endpoint.** Anonymize Candidate strips personal information but leaves the record and its applications in place. Real deletion is available only in the Ashby UI, is limited to a 10-day window, and is restricted to certain roles. {/* MANUAL-CONTENT-END */} ## Usage Instructions -Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users). +Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag, anonymize), applications (list, get, create, delete, change stage, change source), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), custom field values (set one or many), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users). @@ -59,7 +94,110 @@ Adds a tag to a candidate in Ashby and returns the updated candidate. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | + +### Ashby Anonymize Candidate + +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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `candidateId` | string | Yes | UUID of the candidate to anonymize | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | + +### Ashby Change Application Source + +Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `applicationId` | string | Yes | UUID of the application whose source should change | +| `sourceId` | string | No | UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true. | +| `unsetSource` | boolean | No | Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -80,9 +218,11 @@ Adds a tag to a candidate in Ashby and returns the updated candidate. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Change Application Stage @@ -108,7 +248,8 @@ Moves an application to a different interview stage. Requires an archive reason | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -129,9 +270,11 @@ Moves an application to a different interview stage. Requires an archive reason | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Create Application @@ -161,7 +304,8 @@ Creates a new application for a candidate on a job. Optionally specify interview | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -182,9 +326,11 @@ Creates a new application for a candidate on a job. Optionally specify interview | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Create Candidate @@ -217,7 +363,8 @@ Creates a new candidate record in Ashby. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -238,9 +385,11 @@ Creates a new candidate record in Ashby. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Create Note @@ -272,6 +421,23 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo | ↳ `lastName` | string | Author last name | | ↳ `email` | string | Author email | +### Ashby Delete Application + +Permanently deletes an application in Ashby. Requires the candidatesDelete permission, which is a separate module permission from candidatesWrite - a read and write key returns 403 here. There is no equivalent endpoint for deleting a candidate; candidate deletion is UI-only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `applicationId` | string | Yes | UUID of the application to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `applicationId` | string | UUID of the deleted application | + ### Ashby Get Application Retrieves full details about a single application by its ID. @@ -294,7 +460,8 @@ Retrieves full details about a single application by its ID. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -315,9 +482,11 @@ Retrieves full details about a single application by its ID. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Get Candidate @@ -341,7 +510,8 @@ Retrieves full details about a single candidate by their ID. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -362,9 +532,11 @@ Retrieves full details about a single candidate by their ID. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Get Job @@ -388,7 +560,8 @@ Retrieves full details about a single job by its ID. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -409,9 +582,11 @@ Retrieves full details about a single job by its ID. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Get Job Posting @@ -506,7 +681,8 @@ Retrieves full details about a single offer by its ID. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -527,9 +703,11 @@ Retrieves full details about a single offer by its ID. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby List Applications @@ -741,6 +919,7 @@ Lists all job postings in Ashby. | `location` | string | No | Filter by location name \(case sensitive\) | | `department` | string | No | Filter by department name \(case sensitive\) | | `listedOnly` | boolean | No | When true, only returns listed \(publicly visible\) job postings \(default false\) | +| `includeUnpublishedJobPostings` | boolean | No | When true, also returns unpublished \(Draft\) job postings. The endpoint already returns both listed and unlisted published postings by default, so this only adds drafts. | | `jobBoardId` | string | No | UUID of a specific job board to filter postings to. If omitted, returns postings on the primary external job board. | #### Output @@ -759,6 +938,7 @@ Lists all job postings in Ashby. | ↳ `secondaryLocationIds` | array | Secondary location UUIDs | | ↳ `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) | | ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | +| ↳ `status` | string | Posting status \(Draft or Published\) | | ↳ `isListed` | boolean | Whether the posting is publicly listed | | ↳ `publishedDate` | string | ISO 8601 published date | | ↳ `applicationDeadline` | string | ISO 8601 application deadline | @@ -778,7 +958,8 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | | `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | -| `perPage` | number | No | Number of results per page \(default 100\) | +| `perPage` | number | No | Number of results per page \(default and max 100\). Ashby silently caps larger values rather than erroring. | +| `syncToken` | string | No | Opaque token from a prior sync to fetch only jobs changed since then. Ashby only returns a new syncToken on the last page, so drain moreDataAvailable/nextCursor before persisting it. | | `status` | string | No | Filter by job status: Open, Closed, Archived, or Draft | | `createdAfter` | string | No | Only return jobs created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) | | `openedAfter` | string | No | Only return jobs opened after this ISO 8601 timestamp | @@ -793,6 +974,7 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar | `jobs` | array | List of jobs | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental run, returned only once the last page is drained. Named as a cursor because that is what it is - an opaque resumption marker, not a credential - so it stays readable in block output alongside nextCursor. | ### Ashby List Locations @@ -974,7 +1156,8 @@ Removes a tag from a candidate in Ashby and returns the updated candidate. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -995,9 +1178,11 @@ Removes a tag from a candidate in Ashby and returns the updated candidate. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | ### Ashby Search Candidates @@ -1017,6 +1202,78 @@ Searches for candidates by name and/or email with AND logic. Results are limited | --------- | ---- | ----------- | | `candidates` | array | Matching candidates \(max 100 results\) | +### Ashby Set Custom Field Value + +Sets the value of a single custom field on an Ashby Application, Candidate, Job, or Opening. Custom fields are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Requires the candidatesWrite permission. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `objectId` | string | Yes | UUID of the object to set the field on \(application, candidate, job, or opening\) | +| `objectType` | string | Yes | Type of the object: Application, Candidate, Job, or Opening | +| `fieldId` | string | Yes | UUID of the custom field definition to set, as returned by List Custom Fields. This is the field definition ID, not the ID of a value already on the object. | +| `fieldValue` | json | No | Value to write, matching the field type: boolean, number, string \(String, LongText, Date, Url, or a ValueSelect option\), string array \(MultiValueSelect\), or an object for Currency \(\{value, currencyCode\}\), NumberRange \(\{type, minValue, maxValue\}\), CompensationRange, and Location \(\{country, region, city\}\). Pass null to clear the value, which makes the annotation reversible. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `customField` | object | The custom field as stored on the object after the write | + +### Ashby Set Custom Field Values + +Sets several custom field values on one Ashby Application, Candidate, Job, or Opening in a single call. Prefer this over repeated single-field writes to the same object - Ashby recommends it because concurrent single-field calls can race and overwrite each other. Requires the candidatesWrite permission. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Ashby API Key | +| `objectId` | string | Yes | UUID of the object to set the fields on \(application, candidate, job, or opening\) | +| `objectType` | string | Yes | Type of the object: Application, Candidate, Job, or Opening | +| `values` | json | Yes | Array of at least one \{ fieldId, fieldValue \} pair. fieldId is a custom field definition UUID from List Custom Fields. fieldValue matches the field type: boolean, number, string, string array \(MultiValueSelect\), or an object for Currency, NumberRange, CompensationRange, and Location. Pass null as a fieldValue to clear that field. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | + ### Ashby Update Candidate Updates an existing candidate record in Ashby. Only provided fields are changed. @@ -1051,7 +1308,8 @@ Updates an existing candidate record in Ashby. Only provided fields are changed. | `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | | `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | | `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | -| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) | +| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) | | `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | | `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | | `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | @@ -1072,9 +1330,11 @@ Updates an existing candidate record in Ashby. Only provided fields are changed. | `author` | json | Note author \(id, firstName, lastName, email\) | | `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `applicationId` | string | UUID of the deleted application | | `moreDataAvailable` | boolean | Whether more pages exist | | `nextCursor` | string | Pagination cursor for next page | | `syncToken` | string | Sync token for incremental updates | +| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output | diff --git a/apps/docs/content/docs/en/integrations/azure_data_explorer.mdx b/apps/docs/content/docs/en/integrations/azure_data_explorer.mdx new file mode 100644 index 00000000000..ebe2f96ae3d --- /dev/null +++ b/apps/docs/content/docs/en/integrations/azure_data_explorer.mdx @@ -0,0 +1,497 @@ +--- +title: Azure Data Explorer +description: Query and manage Azure Data Explorer (Kusto) clusters with KQL +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Azure Data Explorer](https://azure.microsoft.com/products/data-explorer) is Microsoft's analytics service for very large volumes of machine-generated data — logs, metrics, traces, telemetry, and IoT events. It is built for questions asked over billions of rows: you write a query, and it comes back in seconds. The same engine powers Fabric Eventhouse, Azure Monitor, and Application Insights. + +You query it with **KQL** (Kusto Query Language), a pipeline language that reads left to right. Start with a table, then pipe the rows through operators: + +```kusto +StormEvents +| where StartTime > ago(7d) and State == "FLORIDA" +| summarize Events = count() by EventType +| top 10 by Events +``` + +Azure Data Explorer also has a second command family: **management commands**, which all start with a dot (`.show tables`, `.create table`, `.ingest inline`). Queries read data; management commands inspect and change the cluster itself. + +In Sim, this integration gives your agents both halves: + +- **Ask questions of your telemetry** — turn a plain-English question into KQL, run it, and answer with real numbers instead of a guess +- **Discover the data model first** — list databases, tables, and stored functions, read a table's schema, and check its size and row count, so a generated query references columns that actually exist and you know what it will scan +- **Push rows in** — send small batches straight into a table, or materialize a query result into a rollup table with `.set-or-append` +- **Manage tables** — create a table from a column schema, or drop one you no longer need +- **Debug the pipeline** — list ingestion failures with their error codes and root causes, and check the state of a long-running operation +- **Run any management command** — the escape hatch for policies, mappings, and anything else on the control plane + +Authentication uses a **Microsoft Entra service principal** (an app registration with a tenant ID, client ID, and client secret) rather than an interactive sign-in, so scheduled and unattended workflows keep working without anyone logging in. Grant that principal access to the database with `.add database viewers ('aadapp=;')` — use `viewers` for read-only agents, and `ingestors` or `users` only when a workflow needs to write. + +A few things worth knowing before you build: + +- **Enable Read-only on the Run Query operation** whenever an agent writes its own KQL. It sends the `x-ms-readonly` header, and the cluster then refuses anything that would change data — a cheap guardrail against a generated query doing more than you intended. +- **Results are capped at 10,000 rows.** Every result reports `rowCount`, `totalRowCount`, and `truncated`, so a query that returned more than the cap says so rather than quietly looking complete. Aggregate with `summarize` or bound the query with `take` instead of pulling raw rows. +- **Ingest Rows Inline is for small batches.** It is ideal for tens or hundreds of rows from a workflow run. For continuous or high-volume loading, use Azure Data Explorer's queued or streaming ingestion instead. +- **Ingest From Query defaults to `set-or-append`**, which adds to an existing table. `set-or-replace` discards everything already in the target table — pick it only when you mean to rebuild the rollup from scratch. For a large backfill, turn on the background option and poll Show Operations with the operation ID it returns. +- **Ingest From Query matches columns by position, not by name.** Kusto aligns the query result to the target table on column type and order, so a query that projects the right columns in the wrong order ingests data into the wrong columns without erroring. End the query with an explicit `project` in the table's column order, and confirm with Show Table Schema first. +- **Drop Table is permanent.** It deletes the table and its data. Give an agent the `viewers` role rather than `admins` unless a workflow genuinely needs to change schema. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed. + + + +## Actions + +### Azure Data Explorer Query + +Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database to run the query against | +| `query` | string | Yes | KQL query text \(e.g., StormEvents \| where State == "FLORIDA" \| summarize count\(\) by EventType\) | +| `properties` | json | No | Kusto request properties object, e.g. \{"Options":\{"servertimeout":"00:04:00","queryconsistency":"strongconsistency"\}\} | +| `readOnly` | boolean | No | Send x-ms-readonly so the cluster rejects any request that would change data | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + +### Azure Data Explorer Management Command + +Run an Azure Data Explorer management command (a control command starting with ".") such as .show, .create, .alter, or .drop. Write commands change cluster state permanently; use the Query operation for reads. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `command` | string | Yes | Management command text, starting with "." \(e.g., .show table Events details\) | +| `database` | string | No | Database context for the command. Required for all commands except cluster-level ones such as .show databases | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + +### Azure Data Explorer List Databases + +List the databases on an Azure Data Explorer cluster that the service principal can access. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | +| `databases` | array | Database names, read from the DatabaseName column | + +### Azure Data Explorer List Tables + +List the tables in an Azure Data Explorer database, with their folder and docstring. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database whose tables should be listed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | +| `tables` | array | Table names, read from the TableName column | + +### Azure Data Explorer Show Table Schema + +Read the column schema of an Azure Data Explorer table in CSL form (e.g., "Timestamp:datetime,Level:string"). Use this before writing a KQL query against an unfamiliar table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database containing the table | +| `table` | string | Yes | Table whose schema should be read | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name of the table | +| `schema` | string | Comma-separated CSL column schema \(name:type\) | +| `databaseName` | string | The table's database | +| `folder` | string | The table's folder | +| `docString` | string | The table's docstring | + +### Azure Data Explorer Show Database Schema + +Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database whose schema should be read | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + +### Azure Data Explorer Show Table Details + +Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database to read table details from | +| `table` | string | No | Table to describe. Omit to describe every table in the database | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + +### Azure Data Explorer List Functions + +List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database whose stored functions should be listed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | +| `functions` | array | Stored function names, read from the Name column | + +### Azure Data Explorer Ingest Inline + +Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database containing the target table | +| `table` | string | Yes | Table to ingest into. Its schema is the assumed schema for the data | +| `data` | string | Yes | Rows to ingest, one record per line, parsed as CSV by default \(e.g., "Shoes,1000\\nWide Shoes,50"\) | +| `ingestionProperties` | string | No | Ingestion properties clause contents, e.g. format="json", ingestionMappingReference="mymapping" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | +| `extentIds` | array | Extent IDs created by the ingestion — one per data shard. A single empty or zero-valued ID means no data shard was generated | + +### Azure Data Explorer Ingest From Query + +Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database containing the target table | +| `table` | string | Yes | Table to ingest the query result into | +| `mode` | string | No | set \(create, fail if it exists\), append \(add to an existing table\), set-or-append \(default\), or set-or-replace \(replace all data\) | +| `sourceQuery` | string | Yes | KQL query whose result becomes the ingested data \(e.g., LogsTable \| where Level == "Error" \| where Timestamp > ago\(1h\)\). Project the columns in the target table\'s order — matching is positional, not by name | +| `async` | boolean | No | Return immediately with an OperationId and keep ingesting in the background. Check progress with Show Operations | +| `ingestionProperties` | string | No | Optional ingestion properties clause contents, e.g. distributed=true, tags='\["daily"\]' | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + +### Azure Data Explorer Create Table + +Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database to create the table in | +| `table` | string | Yes | Name of the table to create | +| `columnSchema` | string | Yes | Comma-separated CSL column schema \(e.g., Timestamp:datetime, Level:string, Count:long\) | +| `tableProperties` | string | No | Optional table properties clause contents, e.g. docstring="Raw logs", folder="Ingest" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + +### Azure Data Explorer Drop Table + +Drop a table from an Azure Data Explorer database. This permanently deletes the table and its data, and returns the tables that remain. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database containing the table | +| `table` | string | Yes | Name of the table to drop | +| `ifExists` | boolean | No | Succeed instead of failing when the table does not exist | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | +| `tables` | array | Tables remaining in the database, read from the TableName column | + +### Azure Data Explorer Show Ingestion Failures + +List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | Yes | Database whose ingestion failures should be listed | +| `operationId` | string | No | Limit results to a single ingestion operation ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + +### Azure Data Explorer Show Operations + +Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) | +| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal | +| `clientId` | string | Yes | Microsoft Entra application \(client\) ID | +| `clientSecret` | string | Yes | Microsoft Entra application client secret | +| `resource` | string | No | Token audience override. Defaults to the cluster URI itself | +| `database` | string | No | Database context for the command | +| `operationId` | string | No | Operation ID to check, e.g. the ID returned by an async ingestion | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tableName` | string | Name Kusto assigned to the returned result table | +| `columns` | array | Column metadata for the result table | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Kusto scalar type | +| ↳ `dataType` | string | Approximate .NET type | +| `rows` | array | Result rows as positional arrays matching the columns order | +| `records` | array | Result rows keyed by column name | +| `rowCount` | number | Rows carried in this result, after the row cap | +| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied | +| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true | + + diff --git a/apps/docs/content/docs/en/integrations/grafana.mdx b/apps/docs/content/docs/en/integrations/grafana.mdx index 8b96379cbdd..be83741d472 100644 --- a/apps/docs/content/docs/en/integrations/grafana.mdx +++ b/apps/docs/content/docs/en/integrations/grafana.mdx @@ -207,7 +207,7 @@ List all alert rules in the Grafana instance | ↳ `folderUID` | string | Parent folder UID | | ↳ `ruleGroup` | string | Rule group name | | ↳ `orgID` | number | Organization ID | -| ↳ `provenance` | string | Provisioning source \(empty if API-managed\) | +| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI | | ↳ `notification_settings` | json | Per-rule notification settings \(overrides\) | | ↳ `record` | json | Recording rule configuration \(recording rules only\) | @@ -245,7 +245,7 @@ Get a specific alert rule by its UID | `folderUID` | string | Parent folder UID | | `ruleGroup` | string | Rule group name | | `orgID` | number | Organization ID | -| `provenance` | string | Provisioning source \(empty if API-managed\) | +| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI | | `notification_settings` | json | Per-rule notification settings \(overrides\) | | `record` | json | Recording rule configuration \(recording rules only\) | @@ -266,8 +266,8 @@ Create a new alert rule | `condition` | string | No | The refId of the query or expression to use as the alert condition \(required for alerting rules; omit for recording rules\) | | `data` | string | Yes | JSON array of query/expression data objects | | `forDuration` | string | No | Duration to wait before firing \(e.g., 5m, 1h\) | -| `noDataState` | string | No | State when no data is returned \(NoData, Alerting, OK\) | -| `execErrState` | string | No | State on execution error \(Error, Alerting, OK\) | +| `noDataState` | string | No | State when no data is returned: NoData \(default\), Alerting, OK, or KeepLast. Ignored for recording rules | +| `execErrState` | string | No | State on execution error: Error \(default\), Alerting, OK, or KeepLast. Ignored for recording rules | | `annotations` | string | No | JSON object of annotations | | `labels` | string | No | JSON object of labels | | `uid` | string | No | Optional custom UID for the alert rule | @@ -299,7 +299,7 @@ Create a new alert rule | `folderUID` | string | Parent folder UID | | `ruleGroup` | string | Rule group name | | `orgID` | number | Organization ID | -| `provenance` | string | Provisioning source \(empty if API-managed\) | +| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI | | `notification_settings` | json | Per-rule notification settings \(overrides\) | | `record` | json | Recording rule configuration \(recording rules only\) | @@ -353,7 +353,7 @@ Update an existing alert rule. Fetches the current rule and merges your changes. | `folderUID` | string | Parent folder UID | | `ruleGroup` | string | Rule group name | | `orgID` | number | Organization ID | -| `provenance` | string | Provisioning source \(empty if API-managed\) | +| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI | | `notification_settings` | json | Per-rule notification settings \(overrides\) | | `record` | json | Recording rule configuration \(recording rules only\) | @@ -397,9 +397,9 @@ List all alert notification contact points | ↳ `uid` | string | Contact point UID | | ↳ `name` | string | Contact point name | | ↳ `type` | string | Notification type \(email, slack, etc.\) | -| ↳ `settings` | object | Type-specific settings | +| ↳ `settings` | json | Type-specific settings | | ↳ `disableResolveMessage` | boolean | Whether resolve messages are disabled | -| ↳ `provenance` | string | Provisioning source \(empty if API-managed\) | +| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI | ### Grafana Create Contact Point @@ -427,7 +427,7 @@ Create a notification contact point (e.g., Slack, email, PagerDuty) | `type` | string | Receiver type | | `settings` | json | Type-specific settings | | `disableResolveMessage` | boolean | Whether resolve notifications are suppressed | -| `provenance` | string | Provisioning source \(empty if API-managed\) | +| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI | ### Grafana Create Annotation @@ -474,7 +474,7 @@ Query annotations by time range, dashboard, or tags | `userId` | number | No | Filter by ID of the user who created the annotation | | `tags` | string | No | Comma-separated list of tags to filter by | | `type` | string | No | Filter by type \(alert or annotation\) | -| `limit` | number | No | Maximum number of annotations to return | +| `limit` | number | No | Maximum number of annotations to return \(Grafana defaults to 100\) | #### Output @@ -518,8 +518,8 @@ Update an existing annotation | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | number | The ID of the updated annotation | -| `message` | string | Confirmation message | +| `annotationId` | number | The annotation that was updated, echoed from the request — Grafana answers a patch with only a message and returns no id | +| `message` | string | Confirmation message from Grafana, e.g. "Annotation patched" | ### Grafana Delete Annotation @@ -587,7 +587,7 @@ Get a data source by its ID or UID | `apiKey` | string | Yes | Grafana Service Account Token | | `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | | `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | -| `dataSourceId` | string | Yes | The ID or UID of the data source to retrieve \(e.g., prometheus, P1234AB5678\) | +| `dataSourceId` | string | Yes | The UID of the data source to retrieve \(e.g., P1234AB5678\). Numeric ids are not supported — Grafana serves those only behind a disabled-by-default feature toggle | #### Output @@ -629,8 +629,9 @@ Test connectivity to a data source by its UID | Parameter | Type | Description | | --------- | ---- | ----------- | -| `status` | string | Health status of the data source \(e.g., OK\) | -| `message` | string | Detailed health message from the data source | +| `status` | string | Verdict Grafana returned for the data source, e.g. OK or ERROR. An unhealthy source reports here rather than failing the tool | +| `message` | string | The plugin's diagnostic detail, which carries the reason on a failed check | +| `details` | json | Extra structured detail, when the data source plugin supplies any | ### Grafana List Folders @@ -788,8 +789,9 @@ Delete a folder by its UID | Parameter | Type | Description | | --------- | ---- | ----------- | -| `uid` | string | The UID of the deleted folder | -| `message` | string | Confirmation message | +| `id` | number | Numeric id of the deleted folder, as returned by Grafana | +| `uid` | string | The UID that was deleted, echoed from the request | +| `message` | string | Grafana's confirmation message | ### Grafana Get Health @@ -811,4 +813,154 @@ Check the health of the Grafana instance (version, database status) | `database` | string | Database health status \(e.g., ok\) | | `version` | string | Grafana version | +### Grafana Update Contact Point + +Replace a contact point by its UID. Grafana has no partial update for contact points, so every field is rewritten — resend the name, type, and full settings, or the omitted ones are reset. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `contactPointUid` | string | Yes | UID of the contact point to replace | +| `name` | string | Yes | Contact point name. Grafana groups receivers that share a name | +| `type` | string | Yes | Receiver type, e.g. slack, email, pagerduty, webhook, opsgenie, teams, discord, telegram | +| `settings` | string | Yes | JSON object of receiver settings for this type, e.g. \{"url":"https://hooks.slack.com/..."\} for slack | +| `disableResolveMessage` | boolean | No | Suppress the resolved notification. Omitting this resets it to false | +| `disableProvenance` | boolean | No | Send X-Disable-Provenance. Use only on a contact point whose provenance is already empty \(UI-created, or created by Sim with this on\) — sending it against an API-provisioned contact point is rejected with 403 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `uid` | string | The UID that was updated, echoed from the request — Grafana answers a contact point update with only a message and returns no object | +| `message` | string | Confirmation message from Grafana, e.g. "contactpoint updated" | + +### Grafana Delete Contact Point + +Permanently delete a contact point by its UID. Grafana refuses the delete while the contact point is still referenced by the notification policy tree or by an alert rule. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `contactPointUid` | string | Yes | UID of the contact point to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `uid` | string | The UID that was deleted, echoed from the request | +| `message` | string | Confirmation message from Grafana, e.g. "contactpoint deleted" | + +### Grafana Move Folder + +Move a folder under a different parent folder, or to the root by leaving the parent empty. Returns the folder with its new ancestry. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `folderUid` | string | Yes | UID of the folder to move | +| `parentUid` | string | No | UID of the new parent folder. Leave empty to move the folder to the root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | The numeric ID of the folder | +| `uid` | string | The UID of the folder | +| `title` | string | The title of the folder | +| `url` | string | The URL path of the folder | +| `parentUid` | string | UID of the new parent folder, absent once moved to the root | +| `parents` | array | Folder ancestry from the root down to the parent \(uid, title, url\) | +| `hasAcl` | boolean | Whether the folder has custom ACL permissions | +| `canSave` | boolean | Whether the caller can save the folder | +| `canEdit` | boolean | Whether the caller can edit the folder | +| `canAdmin` | boolean | Whether the caller can administer the folder | +| `createdBy` | string | Login that created the folder | +| `created` | string | Creation timestamp | +| `updatedBy` | string | Login that last updated the folder | +| `updated` | string | Last update timestamp | +| `version` | number | Folder revision number | + +### Grafana Get Alert Rule Group + +Read an alert rule group: its evaluation interval and every rule in it. The interval is the group-level knob that decides how often those rules are evaluated, which the individual alert rule operations do not expose. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `folderUid` | string | Yes | UID of the folder holding the rule group | +| `ruleGroup` | string | Yes | Name of the rule group | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `title` | string | Name of the rule group | +| `folderUid` | string | UID of the folder holding the group | +| `interval` | number | How often the group is evaluated, as an integer. Grafana returns seconds here rather than a duration string | +| `rules` | array | Provisioned alert rules in the group | +| ↳ `id` | number | Alert rule numeric ID | +| ↳ `uid` | string | Alert rule UID | +| ↳ `title` | string | Alert rule title | +| ↳ `condition` | string | RefId of the query used as the alert condition | +| ↳ `data` | json | Alert rule query/expression data array | +| ↳ `updated` | string | Last update timestamp | +| ↳ `noDataState` | string | State when no data is returned | +| ↳ `execErrState` | string | State on execution error | +| ↳ `for` | string | Duration the condition must hold before firing | +| ↳ `keepFiringFor` | string | Duration to keep firing after condition stops | +| ↳ `missingSeriesEvalsToResolve` | number | Number of missing series evaluations before resolving | +| ↳ `annotations` | json | Alert annotations | +| ↳ `labels` | json | Alert labels | +| ↳ `isPaused` | boolean | Whether the rule is paused | +| ↳ `folderUID` | string | Parent folder UID | +| ↳ `ruleGroup` | string | Rule group name | +| ↳ `orgID` | number | Organization ID | +| ↳ `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI | +| ↳ `notification_settings` | json | Per-rule notification settings \(overrides\) | +| ↳ `record` | json | Recording rule configuration \(recording rules only\) | + +### Grafana Query Data Source + +Run one or more queries against a Grafana data source that has a backend implementation, and read the values back. This is how you get actual metric numbers out of Grafana rather than dashboard or alert configuration. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grafana Service Account Token | +| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) | +| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) | +| `queries` | string | Yes | JSON array of at least one query. Each needs a datasource.uid and a refId, plus the fields that data source expects — expr for Prometheus, rawSql for SQL. Example: \[\{"refId":"A","datasource":\{"uid":"P123"\},"expr":"up","format":"time_series"\}\] | +| `from` | string | No | Start of the time range, either epoch milliseconds or Grafana relative time \(e.g., now-5m\). Defaults to now-1h | +| `to` | string | No | End of the time range, epoch milliseconds or relative \(e.g., now\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | json | Raw Grafana response, keyed by each query refId, each holding the frames that query produced | +| `series` | array | The same frames flattened into rows, so values can be read without walking the columnar layout | +| ↳ `refId` | string | The query this frame came from | +| ↳ `fields` | array | Field metadata in column order | +| ↳ `name` | string | Field name, e.g. time or A-series | +| ↳ `type` | string | Field type, e.g. time or number | +| ↳ `rowCount` | number | Number of rows in the frame | +| ↳ `rows` | array | Rows keyed by field name | + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 868cf803849..d1f48fc83d7 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -22,6 +22,7 @@ "atlassian-service-account", "attio", "attio-service-account", + "azure_data_explorer", "azure_devops", "box", "box-service-account", @@ -194,6 +195,7 @@ "qdrant", "quartr", "quiver", + "rabbitmq", "railway", "rb2b", "rds", diff --git a/apps/docs/content/docs/en/integrations/rabbitmq.mdx b/apps/docs/content/docs/en/integrations/rabbitmq.mdx new file mode 100644 index 00000000000..4ff61b352f2 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/rabbitmq.mdx @@ -0,0 +1,564 @@ +--- +title: RabbitMQ +description: Publish and read messages and manage queues in RabbitMQ +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[RabbitMQ](https://www.rabbitmq.com/) is an open-source message broker that sits between the parts of a system that produce work and the parts that do it. A producer publishes a message to an **exchange**, the exchange matches the message's **routing key** against its **bindings**, and every matching **queue** holds the message until a consumer takes it. That indirection is the point: producers never need to know who consumes their messages, and a queue absorbs bursts that would otherwise overwhelm a downstream service. + +**Why RabbitMQ?** +- **Durable buffering:** A queue holds work while consumers are slow, restarting, or offline, so a traffic spike becomes a backlog to work through rather than dropped requests. +- **Flexible routing:** Direct exchanges route on an exact key, topic exchanges on wildcard patterns like `orders.*`, fanout exchanges to every bound queue, and headers exchanges on message metadata. +- **Delivery guarantees:** Messages and queues can be marked durable so they survive a broker restart, and unacknowledged messages return to the queue when a consumer dies mid-work. +- **Failure handling built in:** Dead-letter exchanges, per-message TTLs, and queue length limits let you decide up front what happens to work that expires, overflows, or repeatedly fails. +- **Runs anywhere:** Self-hosted on your own infrastructure, or managed through providers such as CloudAMQP and Amazon MQ. + +**Using RabbitMQ in Sim** + +Sim talks to RabbitMQ over its **Management HTTP API** — the same interface behind the RabbitMQ management UI — using the management plugin's base URL plus a username and password. There is no AMQP connection to configure and no client library to install; if you can reach the management UI in a browser, Sim can reach your broker. + +**Key benefits of using RabbitMQ in Sim:** +- **Publish from any workflow step:** Hand off enriched or classified data to an existing service by publishing to an exchange, without that service needing to know Sim exists. +- **Inspect queues without consuming them:** The default acknowledgement mode requeues what it reads, so an agent can examine a dead-letter backlog and leave the messages exactly where they were. +- **Triage failures with an agent:** Read a dead-letter queue, let an agent group messages by root cause, and route the summary to Slack, PagerDuty, or a table. +- **Monitor broker health on a schedule:** List queues and read the broker overview to catch a queue whose depth is climbing or that has lost all its consumers. +- **Declare topology as part of a workflow:** Create queues, set arguments such as quorum type or dead-lettering, and bind them to exchanges as an automated setup step. + +**Before you start** + +- The **management plugin must be enabled and reachable** from Sim. It listens on port `15672` by default and is separate from the AMQP port (`5672`). Self-hosted brokers enable it with `rabbitmq-plugins enable rabbitmq_management`; managed providers expose it as a management or console URL. +- The management URL **must use `https`** unless the broker is on a loopback host. Credentials travel on every request as HTTP basic auth, so plain `http` to a remote broker would put them on the wire in the clear — Sim rejects it rather than sending them. +- The user you authenticate as needs the **`management` tag** at minimum, plus read and write permissions on the virtual host you target. Administrative operations require broader permissions. +- Publishing and reading messages over the HTTP API is **convenient but not a high-throughput transport** — RabbitMQ opens a new connection per request. It is well suited to workflow-rate traffic, inspection, and operational automation; a service consuming thousands of messages per second should use an AMQP client instead. +- Queue statistics such as message and consumer counts are **collected on an interval**, so a queue declared moments ago may report them as empty until the broker's next sample. +- Reading messages is **bounded per call** so one retrieval cannot exceed Sim's response limit. A batch is capped at 50 messages, payloads are truncated (each message reports whether it was), and a large batch shortens payloads further. AMQP properties and headers are returned in full — the broker offers no way to truncate them — so retrieving several messages carrying very large headers may still hit the limit; lower the count if that happens. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Connect agents to a RabbitMQ broker through its Management HTTP API. Publish messages to exchanges, read messages off queues, declare queues, exchanges, bindings, and policies, and inspect broker health, queue depth, consumers, connections, and cluster nodes. Works with self-hosted brokers and managed offerings such as CloudAMQP as long as the management plugin is reachable. + + + +## Actions + +### RabbitMQ Publish Message + +Publish a message to a RabbitMQ exchange with a routing key. Reports whether the message was routed to at least one queue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `exchange` | string | No | Exchange to publish to. Leave empty to publish to the default exchange, which routes by queue name. Empty is a valid value, so this is not required. | +| `routingKey` | string | Yes | Routing key. When publishing to the default exchange this is the target queue name. | +| `payload` | string | Yes | Message body to publish | +| `payloadEncoding` | string | No | How the payload is encoded: string \(default\) or base64 | +| `properties` | string | No | AMQP basic properties as a JSON object, e.g. \{"delivery_mode":2,"content_type":"application/json"\} | +| `headers` | string | No | Message headers as a JSON object, e.g. \{"source":"sim"\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `routed` | boolean | Whether the message was routed to at least one queue. False means no binding matched and the message was dropped. | +| `exchange` | string | Exchange the message was published to | +| `routingKey` | string | Routing key the message was published with | + +### RabbitMQ Get Messages + +Retrieve messages from a RabbitMQ queue. Defaults to requeueing the messages so they stay available to real consumers. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `queue` | string | Yes | Queue to read messages from | +| `count` | number | No | Maximum number of messages to retrieve, from 1 to $\{MAX_MESSAGE_COUNT\}. Defaults to 1 | +| `ackmode` | string | No | How retrieved messages are handled: ack_requeue_true \(default, leaves messages in the queue\), ack_requeue_false \(removes them\), reject_requeue_true, or reject_requeue_false | +| `encoding` | string | No | auto \(default\) returns readable text where possible, base64 always returns base64 | +| `truncate` | number | No | Truncate payloads longer than this many bytes. Defaults to $\{DEFAULT_TRUNCATE_BYTES\}, capped at $\{MAX_TRUNCATE_BYTES\}, and lowered further at high counts so the whole batch stays inside the response limit. Each message reports whether it was truncated | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueName` | string | Queue the messages were read from | +| `count` | number | Number of messages retrieved | +| `messages` | array | Retrieved messages, empty when the queue holds nothing | + +### RabbitMQ List Queues + +List queues in a RabbitMQ virtual host with their depth, consumer count, and configuration. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `page` | number | No | Page of results to return, starting at 1 | +| `pageSize` | number | No | Queues per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} | +| `name` | string | No | Filter queues whose name contains this value | +| `useRegex` | boolean | No | Treat the name filter as a regular expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queues` | array | Queues in the virtual host | +| `count` | number | Number of queues returned on this page | +| `totalCount` | number | Total queues in the virtual host before filtering | +| `page` | number | Page number returned | +| `pageCount` | number | Total number of pages | + +### RabbitMQ Get Queue + +Read a single RabbitMQ queue, including its depth, consumer count, and declaration settings. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `queue` | string | Yes | Queue name to read | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queue` | object | The requested queue | + +### RabbitMQ Create Queue + +Declare a RabbitMQ queue. Declaring a queue that already exists with the same settings succeeds without changing it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `queue` | string | Yes | Name of the queue to declare | +| `durable` | boolean | No | Whether the queue survives a broker restart. Defaults to true | +| `autoDelete` | boolean | No | Delete the queue when its last consumer disconnects. Defaults to false | +| `arguments` | string | No | Queue arguments as a JSON object, e.g. \{"x-queue-type":"quorum","x-message-ttl":60000\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueName` | string | Name of the declared queue | +| `vhost` | string | Virtual host the queue was declared in | +| `created` | boolean | Whether the declaration succeeded | + +### RabbitMQ Delete Queue + +Delete a RabbitMQ queue and every message still in it. Can be guarded so the delete only happens when the queue is unused or empty. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `queue` | string | Yes | Name of the queue to delete | +| `ifUnused` | boolean | No | Only delete the queue when it has no consumers | +| `ifEmpty` | boolean | No | Only delete the queue when it holds no messages | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueName` | string | Name of the deleted queue | +| `vhost` | string | Virtual host the queue was deleted from | +| `deleted` | boolean | Whether the queue was deleted | + +### RabbitMQ Purge Queue + +Discard every ready message in a RabbitMQ queue while leaving the queue itself in place. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `queue` | string | Yes | Name of the queue to purge | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueName` | string | Name of the purged queue | +| `vhost` | string | Virtual host the queue belongs to | +| `purged` | boolean | Whether the queue was purged | + +### RabbitMQ List Exchanges + +List exchanges in a RabbitMQ virtual host with their type and declaration settings. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `page` | number | No | Page of results to return, starting at 1 | +| `pageSize` | number | No | Exchanges per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} | +| `name` | string | No | Filter exchanges whose name contains this value | +| `useRegex` | boolean | No | Treat the name filter as a regular expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exchanges` | array | Exchanges in the virtual host | +| `count` | number | Number of exchanges returned on this page | +| `totalCount` | number | Total exchanges in the virtual host before filtering | +| `page` | number | Page number returned | +| `pageCount` | number | Total number of pages | + +### RabbitMQ Get Exchange + +Read a single RabbitMQ exchange and the settings it was declared with. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `exchange` | string | No | Exchange name to read. Leave empty for the default exchange, which is a valid value, so this is not required | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exchange` | object | The requested exchange | + +### RabbitMQ Create Exchange + +Declare a RabbitMQ exchange. Declaring an exchange that already exists with the same settings succeeds without changing it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `exchange` | string | Yes | Name of the exchange to declare | +| `exchangeType` | string | No | Routing behaviour: direct \(exact routing key, default\), topic \(wildcard patterns\), fanout \(every bound queue\), or headers \(match on binding arguments\) | +| `durable` | boolean | No | Whether the exchange survives a broker restart. Defaults to true | +| `autoDelete` | boolean | No | Delete the exchange once its last binding is removed. Defaults to false | +| `internal` | boolean | No | Internal exchanges cannot be published to directly, only bound from another exchange. Defaults to false | +| `arguments` | string | No | Exchange arguments as a JSON object, e.g. \{"alternate-exchange":"unrouted"\} to capture messages that match no binding | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exchangeName` | string | Name of the declared exchange | +| `vhost` | string | Virtual host the exchange was declared in | +| `created` | boolean | Whether the declaration succeeded | + +### RabbitMQ Delete Exchange + +Delete a RabbitMQ exchange and every binding attached to it. Publishers targeting it will fail afterwards. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `exchange` | string | Yes | Name of the exchange to delete | +| `ifUnused` | boolean | No | Only delete the exchange when nothing is bound to it | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exchangeName` | string | Name of the deleted exchange | +| `vhost` | string | Virtual host the exchange was deleted from | +| `deleted` | boolean | Whether the exchange was deleted | + +### RabbitMQ List Bindings + +List the bindings that route messages into a RabbitMQ queue, including the implicit default-exchange binding. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `queue` | string | Yes | Queue whose bindings should be listed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `queueName` | string | Queue the bindings route into | +| `bindings` | array | Bindings targeting the queue. The entry with an empty source is the implicit default-exchange binding | +| `count` | number | Number of bindings returned | + +### RabbitMQ List Exchange Bindings + +List everything an exchange routes to, so you can see which routing keys reach which queues. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `exchange` | string | No | Exchange whose outgoing bindings should be listed. Leave empty for the default exchange, which is a valid value, so this is not required | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exchangeName` | string | Exchange the bindings originate from | +| `bindings` | array | Bindings routing out of the exchange. An empty list means nothing it publishes can be delivered | +| `count` | number | Number of bindings returned | + +### RabbitMQ Create Binding + +Bind a queue or another exchange to a RabbitMQ exchange so messages matching a routing key are routed to it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `exchange` | string | Yes | Source exchange to bind from | +| `queue` | string | Yes | Destination queue, or destination exchange when binding exchange to exchange | +| `destinationType` | string | No | Whether the destination is a queue \(default\) or an exchange. Exchange-to-exchange bindings chain routing between exchanges | +| `routingKey` | string | No | Routing key the binding matches. Topic exchanges accept wildcards such as orders.* | +| `arguments` | string | No | Binding arguments as a JSON object. Headers exchanges match on these, e.g. \{"x-match":"all","type":"invoice"\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exchange` | string | Source exchange the binding reads from | +| `queueName` | string | Destination queue the binding routes into | +| `routingKey` | string | Routing key the binding matches | +| `propertiesKey` | string | Broker identifier addressing the new binding | +| `created` | boolean | Whether the binding was created | + +### RabbitMQ Delete Binding + +Remove a binding so an exchange stops routing its matching messages to that destination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `exchange` | string | Yes | Source exchange the binding reads from | +| `destination` | string | Yes | Destination queue or exchange the binding routes to | +| `destinationType` | string | No | Whether the destination is a queue \(default\) or an exchange | +| `propertiesKey` | string | Yes | Broker identifier for the binding, taken from List Bindings or Create Binding. It is the routing key for a simple binding, ~ for an empty routing key, and a hashed value when the binding has arguments | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exchange` | string | Source exchange the binding read from | +| `destination` | string | Destination the binding routed to | +| `propertiesKey` | string | Broker identifier of the deleted binding | +| `deleted` | boolean | Whether the binding was deleted | + +### RabbitMQ Get Overview + +Read broker-wide RabbitMQ status: version, cluster name, object totals, queue depth totals, and message rates. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rabbitmqVersion` | string | RabbitMQ version running on the node | +| `productName` | string | Broker product name | +| `productVersion` | string | Broker product version | +| `erlangVersion` | string | Erlang runtime version | +| `clusterName` | string | Name of the cluster | +| `node` | string | Node that served the request | +| `objectTotals` | object | Counts of brokers objects | +| ↳ `connections` | number | Open connections | +| ↳ `channels` | number | Open channels | +| ↳ `exchanges` | number | Declared exchanges | +| ↳ `queues` | number | Declared queues | +| ↳ `consumers` | number | Registered consumers | +| `queueTotals` | object | Aggregate queue depth across the broker | +| ↳ `messages` | number | Total messages across all queues | +| ↳ `messages_ready` | number | Messages ready for delivery | +| ↳ `messages_unacknowledged` | number | Delivered but unacknowledged messages | +| `messageStats` | json | Broker-wide message counters and rates, e.g. publish and confirm totals | + +### RabbitMQ Health Check + +Run one of the broker health checks and report whether it passed. A failing check is a normal result, not a tool error. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `check` | string | No | Which check to run: alarms \(cluster-wide resource alarms, default\), local-alarms, virtual-hosts, node-is-quorum-critical, port-listener, protocol-listener, or certificate-expiration | +| `port` | number | No | Port to verify a listener on. Required for the port-listener check | +| `protocol` | string | No | Protocol to verify a listener for, e.g. amqp, amqp/ssl, mqtt, stomp, or http. Required for the protocol-listener check | +| `within` | number | No | How far ahead to look for expiring certificates. Required for the certificate-expiration check | +| `unit` | string | No | Unit for the certificate-expiration window: days, weeks, months \(default\), or years | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `check` | string | The health check that was run | +| `healthy` | boolean | True when the check reported status ok | +| `status` | string | Raw status reported by the broker: ok or failed | +| `reason` | string | Explanation the broker gave, present on failures and on some passes | +| `details` | json | Full check body, including check-specific fields such as the ports or protocols found | + +### RabbitMQ List Nodes + +List the cluster nodes with memory, disk, file-descriptor, and alarm state. A fired alarm blocks publishers broker-wide. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `nodes` | array | Cluster nodes and their resource headroom | +| `count` | number | Number of nodes in the cluster | + +### RabbitMQ List Virtual Hosts + +List the virtual hosts on the broker with their message totals, so you can discover which scopes exist. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `vhosts` | array | Virtual hosts the authenticated user can see | +| `count` | number | Number of virtual hosts returned | + +### RabbitMQ List Connections + +List client connections to the broker with their user, state, and channel count. Connections are cluster-wide, not scoped to one virtual host. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `page` | number | No | Page of results to return, starting at 1 | +| `pageSize` | number | No | Connections per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} | +| `name` | string | No | Filter connections whose name contains this value | +| `useRegex` | boolean | No | Treat the name filter as a regular expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connections` | array | Open client connections | +| `count` | number | Number of connections returned on this page | +| `totalCount` | number | Total connections before filtering | +| `page` | number | Page number returned | +| `pageCount` | number | Total number of pages | + +### RabbitMQ List Channels + +List open channels with their prefetch limit and unacknowledged message count, which is where stalled consumers show up. Channels are cluster-wide, not scoped to one virtual host. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `page` | number | No | Page of results to return, starting at 1 | +| `pageSize` | number | No | Channels per page, from 1 to $\{RABBITMQ_MAX_PAGE_SIZE\}. Defaults to $\{DEFAULT_PAGE_SIZE\} | +| `name` | string | No | Filter channels whose name contains this value | +| `useRegex` | boolean | No | Treat the name filter as a regular expression | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `channels` | array | Open channels | +| `count` | number | Number of channels returned on this page | +| `totalCount` | number | Total channels before filtering | +| `page` | number | Page number returned | +| `pageCount` | number | Total number of pages | + +### RabbitMQ List Consumers + +List the consumers subscribed in a virtual host. An empty result for a queue with a backlog means nothing is processing it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `consumers` | array | Consumers currently subscribed in the virtual host | +| `count` | number | Number of consumers returned | + +### RabbitMQ List Policies + +List the policies in a virtual host. Policies are how dead-lettering, TTLs, and length limits get applied to matching queues and exchanges. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policies` | array | Policies defined in the virtual host | +| `count` | number | Number of policies returned | + +### RabbitMQ Create Policy + +Create or replace a RabbitMQ policy, applying settings such as dead-lettering, TTLs, or length limits to every queue or exchange whose name matches a pattern. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `policyName` | string | Yes | Name of the policy. Reusing an existing name replaces that policy | +| `pattern` | string | Yes | Regular expression matched against queue or exchange names, e.g. ^orders\\. to match every name starting with orders. | +| `definition` | string | Yes | Settings to apply, as a JSON object, e.g. \{"dead-letter-exchange":"dlx","message-ttl":86400000,"max-length":10000\} | +| `priority` | number | No | Priority, defaulting to 0. When several policies match a resource only the highest-priority one applies — they do not merge | +| `applyTo` | string | No | What the policy applies to: queues \(default\), classic_queues, quorum_queues, streams, exchanges, or all | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policyName` | string | Name of the created policy | +| `vhost` | string | Virtual host the policy applies in | +| `created` | boolean | Whether the policy was created or replaced | + +### RabbitMQ Delete Policy + +Delete a RabbitMQ policy. Every queue and exchange it matched immediately loses the settings it applied. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `policyName` | string | Yes | Name of the policy to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policyName` | string | Name of the deleted policy | +| `vhost` | string | Virtual host the policy applied in | +| `deleted` | boolean | Whether the policy was deleted | + + diff --git a/apps/docs/content/docs/en/integrations/table.mdx b/apps/docs/content/docs/en/integrations/table.mdx index 20761d52ffe..a178a8778c6 100644 --- a/apps/docs/content/docs/en/integrations/table.mdx +++ b/apps/docs/content/docs/en/integrations/table.mdx @@ -54,7 +54,7 @@ Tables are created from the **Tables** section in the sidebar. Each table requir ## Usage Instructions -Create and manage custom data tables. Store, query, and manipulate structured data within workflows. +Create and manage custom data tables. Store, query, and manipulate structured data within workflows. Query Rows returns every matching row when Limit is omitted and fails if the result exceeds 5MB. @@ -213,7 +213,7 @@ Query rows from a table with filtering, sorting, and pagination | `tableId` | string | Yes | Table ID | | `filter` | object | No | Filter conditions \(MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $ncontains, $startsWith, $endsWith, $empty\) | | `sort` | object | No | Sort order as \{field: "asc"\|"desc"\} | -| `limit` | number | No | Maximum rows to return \(default: $\{TABLE_LIMITS.DEFAULT_QUERY_LIMIT\}, max: $\{TABLE_LIMITS.MAX_QUERY_LIMIT\}\) | +| `limit` | number | No | Maximum rows to return. Omit to return every matching row; the query fails if the result exceeds the 5MB response budget. | | `offset` | number | No | Number of rows to skip \(default: 0\) | #### Output @@ -226,6 +226,7 @@ Query rows from a table with filtering, sorting, and pagination | `totalCount` | number | Total rows matching filter | | `limit` | number | Limit used in query | | `offset` | number | Offset used in query | +| `nextCursor` | string | Non-null when more rows match past this page. A page can end early at the byte budget, so this — not a short rowCount — is what says whether more remain. To page, advance offset by rowCount and stop when this is null. | ### Get Row diff --git a/apps/docs/content/docs/en/knowledgebase/connectors.mdx b/apps/docs/content/docs/en/knowledgebase/connectors.mdx index 4b69acdc3d1..76e0e23a8ef 100644 --- a/apps/docs/content/docs/en/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/en/knowledgebase/connectors.mdx @@ -14,23 +14,24 @@ Connectors continuously sync documents from external services into your knowledg -Sim ships with 49 built-in connectors: +Sim ships with 61 built-in connectors: | Category | Connectors | |----------|-----------| -| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Google Calendar, Google Sheets, Google Forms, Typeform | -| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Amazon S3 | -| **Documents** | Google Docs, WordPress, Webflow, DocuSign | +| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Trello, ClickUp, Google Calendar, Google Sheets, Google Forms, Microsoft Excel, Typeform | +| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Box, Amazon S3, SFTP | +| **Documents** | Google Docs, Google Slides, Mintlify, WordPress, Webflow, DocuSign | | **Development** | GitHub, GitLab, Azure DevOps, Sentry | -| **Communication** | Slack, Discord, Microsoft Teams, Reddit, YouTube | +| **Communication** | Slack, Discord, Microsoft Teams, Reddit, X, YouTube | | **Email** | Gmail, Outlook | | **CRM** | HubSpot, Salesforce | -| **Support** | Intercom, ServiceNow, Zendesk | -| **Incident Management** | incident.io, Rootly | +| **Support** | Intercom, ServiceNow, Zendesk, Zoho Desk | +| **Incident Management** | incident.io, Rootly, PagerDuty | | **Data** | Airtable | | **Note-taking** | Evernote, Obsidian | -| **Meetings** | Zoom, Gong, Grain, Granola, Fathom, Fireflies | +| **Meetings** | Zoom, Google Meet, Gong, Grain, Granola, Fathom, Fireflies | | **Recruiting** | Greenhouse, Ashby | +| **Compliance** | Google Vault | ## Adding a Connector @@ -55,6 +56,9 @@ Other connectors use **API keys** or **personal access tokens** instead. The set | **YouTube** | YouTube Data API key from the Google Cloud Console | | **Amazon S3** | Secret Access Key (the Access Key ID, region, and bucket are entered as config fields) | | **Sentry** | Auth token with `project:read` and `event:read` scopes | +| **PagerDuty** | REST API key from Integrations → API Access Keys | +| **SFTP** | Password or unencrypted private key (host, port, username, and root path are entered as config fields) | +| **Mintlify** | API key — optional for public documentation sites, which sync from `llms.txt` | If you rotate an API key in the external service, update it in Sim as well — OAuth tokens refresh automatically, but API keys do not. diff --git a/apps/docs/content/docs/en/workflows/blocks/condition.mdx b/apps/docs/content/docs/en/workflows/blocks/condition.mdx index 0dcea7ca603..56a86639b53 100644 --- a/apps/docs/content/docs/en/workflows/blocks/condition.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/condition.mdx @@ -31,6 +31,16 @@ Reference an earlier output inside an expression with a [connection tag](/workfl .endsWith('@company.com') && === 'pro' ``` +Read an [environment variable](/workflows/variables#environment-variables) with `{{KEY}}`: + +```javascript +{{MAX_RETRIES}} === 3 +{{FEATURE_ON}} === true +{{TIER}} === 'pro' +``` + +Numbers, booleans, and `null` compare as literals. Every other value is bound as a string, so an apostrophe, quote, or newline inside a secret cannot change what the expression means. + If an expression throws, for example because it reads a field that is not there, the block errors and the run follows the [error path](/workflows/connections) if one is connected. Guard missing values with optional chaining (`?.`) or a null check. diff --git a/apps/docs/content/docs/en/workflows/blocks/function.mdx b/apps/docs/content/docs/en/workflows/blocks/function.mdx index 8950ce8e756..e42ebb9eb4e 100644 --- a/apps/docs/content/docs/en/workflows/blocks/function.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/function.mdx @@ -58,6 +58,25 @@ const apiKey = {{API_KEY}}; Existing quoted and embedded forms are also supported, including `"{{API_KEY}}"`, `"Bearer {{API_KEY}}"`, and placeholders in template literals. Function and Custom Tool code use the same compiler at the execution boundary. It binds the secret separately from the source instead of pasting plaintext into your code, so quotes, backslashes, newlines, and string values such as `"123"` and `"true"` retain their exact contents and do not become JavaScript or Python literals of another type. +### Placeholders are always strings + +A placeholder is bound as a value, never parsed as source. That is what keeps a secret from running as code — but it also means `{{KEY}}` always evaluates to a **string**, whatever the value looks like. Convert it when your code needs another type: + +```javascript +const retries = Number({{MAX_RETRIES}}); +const enabled = {{FEATURE_ON}} === 'true'; +const patterns = JSON.parse({{PATTERN_LIST}}); +``` + +In Python, use `int()`, `== "true"`, and `json.loads()` the same way. + +Two cases are easy to miss: + +- **Booleans.** A bare `if ({{FEATURE_ON}})` is always true, because the string `"false"` is truthy. Compare against `'true'` instead. +- **Lists and objects.** Store the value as JSON so you can parse it back. `["^[A-Z]{2}-\\d{4}$", "^\\d{7,15}$"]` becomes an array; a JavaScript array of regex literals does not. It arrives as one long string, and iterating it walks character by character. + +[Condition](/workflows/blocks/condition) blocks differ in one way: a number, boolean, or `null` value compares as a literal there, so `{{MAX_RETRIES}} === 3` is true. Every other value is a string, exactly as it is here. + JavaScript regex literals can contain a placeholder: ```javascript diff --git a/apps/docs/content/docs/en/workflows/variables.mdx b/apps/docs/content/docs/en/workflows/variables.mdx index 1ea2e7f4ed4..2045c84e277 100644 --- a/apps/docs/content/docs/en/workflows/variables.mdx +++ b/apps/docs/content/docs/en/workflows/variables.mdx @@ -85,6 +85,8 @@ Reference them with double curly braces in any block field, including Agent syst Environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores, like `MY_API_KEY`. +Inside Function and Custom Tool code, `{{KEY}}` always evaluates to a **string** — the value is bound, never parsed as source. Use `Number()`, a `=== 'true'` comparison, or `JSON.parse()` when you need another type. See [secret placeholders in code](/workflows/blocks/function#placeholders-are-always-strings). + ### Personal vs. workspace | Scope | Visible to | Use for | diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 5b513c1c03d..77de6a7d2ca 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -47,7 +47,8 @@ "schema": { "description": "Workspace whose payer should be resolved. Workspace API keys are pinned to their own workspace.", "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128 } } ], @@ -133,7 +134,8 @@ "schema": { "description": "Restrict results to one workspace whose payer the caller can inspect.", "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128 } }, { diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index e842423e106..045fd369674 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -51,6 +51,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose files should be listed." } }, @@ -343,6 +344,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the upload session." } }, @@ -433,6 +435,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the upload session." } }, @@ -537,6 +540,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the upload session." } }, @@ -629,6 +633,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the file." } } @@ -721,6 +726,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the file." } } @@ -965,8 +971,21 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the file." } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "schema": { + "default": "active", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "type": "string", + "enum": ["active", "archived"] + } } ], "responses": { @@ -1060,7 +1079,8 @@ "schema": { "description": "Filter to actions in one workspace.", "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128 } }, { @@ -1361,6 +1381,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the file." } } @@ -1655,6 +1676,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose folders should be listed." } }, @@ -1898,6 +1920,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." } }, @@ -2437,6 +2460,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the file." }, "name": { @@ -2651,6 +2675,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which the file will be registered." }, "name": { @@ -2822,6 +2847,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the file." }, "name": { @@ -2877,6 +2903,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the archived file." } }, @@ -3352,6 +3379,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the files." }, "fileIds": { @@ -3446,6 +3474,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the file." }, "isActive": { @@ -3496,6 +3525,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the file." }, "content": { @@ -3572,6 +3602,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the files." }, "fileIds": { @@ -3683,6 +3714,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the folder." }, "path": { @@ -3701,6 +3733,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." }, "path": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 24157c46f4e..6f3da73eccb 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -47,6 +47,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose knowledge bases should be listed." } }, @@ -885,6 +886,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." } } @@ -1077,6 +1079,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." } }, @@ -1178,6 +1181,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." } }, @@ -1293,6 +1297,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." } }, @@ -1635,6 +1640,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose folders should be listed." } }, @@ -1881,6 +1887,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." } }, @@ -2469,6 +2476,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the knowledge base." }, "name": { @@ -2509,6 +2517,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." }, "name": { @@ -2798,6 +2807,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge bases." }, "knowledgeBaseIds": { @@ -3116,6 +3126,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." }, "operation": { @@ -3467,6 +3478,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." }, "name": { @@ -3918,6 +3930,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the knowledge base." }, "filename": { @@ -4113,6 +4126,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the folder." }, "path": { @@ -4131,6 +4145,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." }, "path": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 0a64c9f581f..46f3e4fec47 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -47,6 +47,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose execution logs should be returned." } }, diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 51e85c8125b..30d0b7a5ff5 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -67,6 +67,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace to retrieve." } } @@ -132,6 +133,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace to retrieve." } }, @@ -221,6 +223,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the MCP server." } }, @@ -424,6 +427,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the MCP server." } } @@ -575,6 +579,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the MCP server." } } @@ -651,6 +656,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the MCP server." } }, @@ -729,6 +735,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the skill." } }, @@ -932,6 +939,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the skill." } } @@ -1086,6 +1094,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the skill." } } @@ -1151,6 +1160,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the custom tool." } }, @@ -1354,6 +1364,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the custom tool." } } @@ -1508,6 +1519,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the custom tool." } } @@ -1573,6 +1585,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose credentials should be listed." } }, @@ -1720,6 +1733,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose secret metadata should be listed." } }, @@ -1969,6 +1983,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." } }, @@ -2291,6 +2306,7 @@ "id": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "name": { @@ -2668,6 +2684,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to register the server." }, "name": { @@ -2854,6 +2871,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the MCP server." }, "name": { @@ -3252,6 +3270,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the skill." }, "name": { @@ -3345,6 +3364,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the skill." }, "name": { @@ -3618,6 +3638,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the custom tool." }, "title": { @@ -3811,6 +3832,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the custom tool." }, "title": { @@ -4182,6 +4204,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." }, "scope": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index b1bff5d7222..aa729c5c616 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -47,6 +47,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose tables should be listed." } }, @@ -739,6 +740,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the table." } }, @@ -2648,6 +2650,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the transfer resource." } }, @@ -2733,6 +2736,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the transfer resource." } }, @@ -2823,6 +2827,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the transfer resource." } }, @@ -2927,6 +2932,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the transfer resource." } }, @@ -3102,6 +3108,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the transfer resource." } } @@ -3176,6 +3183,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the transfer resource." } } @@ -3255,6 +3263,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the transfer resource." } } @@ -3402,6 +3411,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose folders should be listed." } }, @@ -3648,6 +3658,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." } }, @@ -4304,6 +4315,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "schema": { @@ -4455,6 +4467,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "name": { @@ -4588,6 +4601,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the table." }, "column": { @@ -4680,6 +4694,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that owns the table." }, "columnName": { @@ -4768,6 +4783,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "columnName": { @@ -4929,6 +4945,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "rows": { @@ -4950,6 +4967,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "data": { @@ -5161,6 +5179,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "filter": { @@ -5232,6 +5251,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "filter": { @@ -5284,6 +5304,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "data": { @@ -5372,6 +5393,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "data": { @@ -5432,6 +5454,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "predicate": { @@ -5532,6 +5555,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "predicate": { @@ -6481,6 +6505,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "group": { @@ -6668,6 +6693,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "groupId": { @@ -6938,6 +6964,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "groupId": { @@ -6996,6 +7023,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "groupIds": { @@ -7084,6 +7112,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." } }, @@ -7158,6 +7187,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "q": { @@ -7676,6 +7706,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "source": { @@ -8159,6 +8190,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "format": { @@ -8274,6 +8306,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Unique workspace identifier." }, "scope": { @@ -8398,6 +8431,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the folder." }, "path": { @@ -8429,6 +8463,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." }, "path": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 6d9fdd9da01..e0b56db6970 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -51,6 +51,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose workflows should be listed." } }, @@ -1712,6 +1713,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace whose folders should be listed." } }, @@ -1964,6 +1966,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." } }, @@ -2499,6 +2502,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the workflow." }, "name": { @@ -3828,6 +3832,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to import the workflow." }, "workflow": { @@ -4660,7 +4665,7 @@ }, "durablyRecorded": { "type": "boolean", - "description": "Whether cancellation was recorded durably." + "description": "Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written." }, "locallyAborted": { "type": "boolean", @@ -4671,10 +4676,13 @@ "description": "Whether a paused execution was cancelled." }, "reason": { - "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.", + "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.", "type": "string", "enum": [ "recorded", + "already_cancelled", + "already_completed", + "already_failed", "redis_unavailable", "redis_write_failed", "paused_event_publish_failed", @@ -4692,7 +4700,7 @@ ], "additionalProperties": false, "title": "Cancel workflow run result", - "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, so poll the run to observe its final state." + "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed." }, "CancelWorkflowRunResponse": { "type": "object", @@ -4838,6 +4846,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace in which to create the folder." }, "path": { @@ -4881,6 +4890,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace containing the folder." }, "path": { diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 4e7baa85d78..f28543a8004 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -278,34 +278,6 @@ function findDbDescendants(containerId: string, allBlocks: DbBlockRef[]): string return descendants } -/** - * Shared function to handle auto-connect edge insertion - * @param tx - Database transaction - * @param workflowId - The workflow ID - * @param autoConnectEdge - The auto-connect edge data - * @param logger - Logger instance - */ -async function insertAutoConnectEdge( - tx: any, - workflowId: string, - autoConnectEdge: any, - logger: any -) { - if (!autoConnectEdge) return - - await tx.insert(workflowEdges).values({ - id: autoConnectEdge.id, - workflowId, - sourceBlockId: autoConnectEdge.source, - targetBlockId: autoConnectEdge.target, - sourceHandle: normalizeWorkflowEdgeSourceHandle(autoConnectEdge.sourceHandle), - targetHandle: normalizeWorkflowEdgeTargetHandle(autoConnectEdge.targetHandle), - }) - logger.debug( - `Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}` - ) -} - enum SubflowType { LOOP = 'loop', PARALLEL = 'parallel', diff --git a/apps/sim/.env.example b/apps/sim/.env.example index de5ae82cafa..927e437bad4 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default # DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams # FORKING_ENABLED= # Workspace forks +# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only # Instance organization (Optional). Most enterprise features read their settings from the diff --git a/apps/sim/app/(landing)/components/features/components/build-callout/components/workflow-showcase/showcase-data.ts b/apps/sim/app/(landing)/components/features/components/build-callout/components/workflow-showcase/showcase-data.ts index 81099f42ebc..06824548b6a 100644 --- a/apps/sim/app/(landing)/components/features/components/build-callout/components/workflow-showcase/showcase-data.ts +++ b/apps/sim/app/(landing)/components/features/components/build-callout/components/workflow-showcase/showcase-data.ts @@ -1,11 +1,5 @@ -import { - AgentIcon, - AnthropicIcon, - GmailIcon, - LinearIcon, - SlackIcon, - TableIcon, -} from '@/components/icons' +import { Table as TableIcon } from '@sim/emcn/icons' +import { AgentIcon, AnthropicIcon, GmailIcon, LinearIcon, SlackIcon } from '@/components/icons' import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' /** diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/stage-data.ts b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/stage-data.ts index 4dbaf430bb0..fb43f689326 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/stage-data.ts +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/stage-data.ts @@ -1,4 +1,5 @@ -import { AgentIcon, CodeIcon, SlackIcon, StartIcon, TableIcon } from '@/components/icons' +import { Table as TableIcon } from '@sim/emcn/icons' +import { AgentIcon, CodeIcon, SlackIcon, StartIcon } from '@/components/icons' import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-workflow.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-workflow.tsx deleted file mode 100644 index da3d057c0b6..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-workflow.tsx +++ /dev/null @@ -1,133 +0,0 @@ -'use client' - -import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { cn } from '@sim/emcn' -import { WorkflowBlock } from '@/app/(landing)/components/hero/components/hero-visual/workflow-block' -import { - BLOCK_WIDTH, - BLOCKS, - CANVAS, - EDGES, - WORKFLOW_FOCUS_SCALE, -} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' - -/** The camera beat: held on the first block, panning out, or settled on the whole flow. */ -export type WorkflowCameraStage = 'focus' | 'out' | 'hold' - -interface StageWorkflowProps { - stage: WorkflowCameraStage -} - -/** First (GitHub) block center in design space - the camera's focus target. */ -const FOCUS_CENTER = { x: BLOCK_WIDTH / 2, y: 38 } -/** Whole-canvas center - the overview camera target. */ -const CANVAS_CENTER = { x: CANVAS.width / 2, y: CANVAS.height / 2 } -/** Zoomed-in scale while held on the first block (≈ the morphed chat card size). */ -const FOCUS_SCALE = WORKFLOW_FOCUS_SCALE -/** Pulled-back scale that fits the whole workflow in the panel. */ -const OVERVIEW_SCALE = 0.68 - -/** - * The workflow stage of the hero visual - a design-space canvas with a moving - * "camera". It opens **focused** on the first block (the chat card has just - * morphed into it), holds while that block's content lands and the first edge - * draws, then the camera **pans + zooms out together** to reveal the whole - * GitHub → Agent → Jira flow (the {@link stage} prop drives this). - * - * The camera is a transform on the design-space canvas, positioned so the focus - * point lands at the panel center: `translate(vpW/2 - cx·s, vpH/2 - cy·s) - * scale(s)` (origin top-left). The panel size is measured; until it is known, - * and on first mount, the transition is suppressed so the opening focus frame - * doesn't animate in from a fallback. Purely decorative - `aria-hidden`. - */ -export function StageWorkflow({ stage }: StageWorkflowProps) { - const viewportRef = useRef(null) - const [vp, setVp] = useState<{ w: number; h: number } | null>(null) - const [animate, setAnimate] = useState(false) - - useLayoutEffect(() => { - const el = viewportRef.current - if (!el) return - const measure = () => { - const r = el.getBoundingClientRect() - // Guard an unpainted/collapsed panel from poisoning the camera math. - if (r.width > 120 && r.height > 120) setVp({ w: r.width, h: r.height }) - } - measure() - const ro = new ResizeObserver(measure) - ro.observe(el) - return () => ro.disconnect() - }, []) - - // Enable the camera transition only after the opening focus frame is painted, - // so mounting (and the first measurement) snaps into focus rather than gliding. - useEffect(() => { - if (vp) setAnimate(true) - }, [vp]) - - const focused = stage === 'focus' - const center = focused ? FOCUS_CENTER : CANVAS_CENTER - const scale = focused ? FOCUS_SCALE : OVERVIEW_SCALE - const transform = vp - ? `translate(${vp.w / 2 - center.x * scale}px, ${vp.h / 2 - center.y * scale}px) scale(${scale})` - : `translate(0px, 0px) scale(${OVERVIEW_SCALE})` - - return ( -
-
- - {BLOCKS.map((block) => ( - // The first block is already on screen - the chat card morphed into it, - // and the focused camera lands it pixel-matched here; the rest sit in - // design space and are revealed by the camera pull-out. -
- -
- ))} -
-
- ) -} diff --git a/apps/sim/app/(landing)/components/landing-preview/landing-preview-mount.tsx b/apps/sim/app/(landing)/components/landing-preview/landing-preview-mount.tsx deleted file mode 100644 index b4e120fae45..00000000000 --- a/apps/sim/app/(landing)/components/landing-preview/landing-preview-mount.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client' - -import dynamic from 'next/dynamic' -import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar' -import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount' - -/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */ -const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]' - -/** - * Client mount for the {@link LandingPreview} - the heavy, animated workspace - * island (framer-motion + reactflow). Isolated here so the sections that show it - * stay Server Components: only this leaf is `'use client'`. - * - * Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in - * the server-rendered HTML, and gated on viewport proximity via - * {@link useLazyMount} so the below-the-fold previews don't pull the heavy - * bundle into the initial homepage load. A dimension-stable placeholder (the - * preview's exact `aspect-[1116/615]` footprint, filled with the canvas - * surface) holds the space before and during load, so there is zero layout - * shift or flash. - */ -const LandingPreview = dynamic( - () => - import('@/app/(landing)/components/landing-preview/landing-preview').then( - (mod) => mod.LandingPreview - ), - { - ssr: false, - loading: () =>
, - } -) - -interface LandingPreviewMountProps { - /** Forwarded to {@link LandingPreview}; `false` renders a static snapshot. */ - autoplay?: boolean - /** Forwarded to {@link LandingPreview}; the static snapshot's staged view. */ - view?: SidebarView - /** Forwarded to {@link LandingPreview}; the static snapshot's workflow. */ - workflowId?: string -} - -export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) { - const { ref, inView } = useLazyMount('400px') - - return ( -
- {inView ? ( - - ) : ( -
- )} -
- ) -} diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts index d70d636bf2c..bbb7a06986d 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts @@ -1,7 +1,6 @@ import type { SVGProps } from 'react' export const ISO_LINE_STROKE_WIDTH = 3.2 -export const ISO_ENDPOINT_STROKE_WIDTH = 3.3 export const ISO_STROKE = 'color-mix(in srgb, var(--text-subtle) 76%, var(--text-muted))' export const ISO_FILL_LOW = 'var(--surface-6)' export const ISO_FILL_MID = 'color-mix(in srgb, var(--surface-3) 58%, var(--surface-6))' diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts index a0eebb24f2c..e90e6ef2410 100644 --- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts +++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts @@ -1,4 +1,5 @@ -import { AgentIcon, ConditionalIcon, MailIcon, StartIcon, TableIcon } from '@/components/icons' +import { Table as TableIcon } from '@sim/emcn/icons' +import { AgentIcon, ConditionalIcon, MailIcon, StartIcon } from '@/components/icons' import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' /** diff --git a/apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx b/apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx index 9a842fcd87c..abf89357965 100644 --- a/apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx +++ b/apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx @@ -1,6 +1,7 @@ 'use client' -import { AgentIcon, ConditionalIcon, ScheduleIcon, SlackIcon, TableIcon } from '@/components/icons' +import { Table as TableIcon } from '@sim/emcn/icons' +import { AgentIcon, ConditionalIcon, ScheduleIcon, SlackIcon } from '@/components/icons' import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop' import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data' diff --git a/apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx b/apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx index b9c1bd82b2f..dbdd4098442 100644 --- a/apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx +++ b/apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx @@ -1,6 +1,7 @@ 'use client' -import { AgentIcon, ConditionalIcon, MailIcon, StartIcon, TableIcon } from '@/components/icons' +import { Table as TableIcon } from '@sim/emcn/icons' +import { AgentIcon, ConditionalIcon, MailIcon, StartIcon } from '@/components/icons' import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop' import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data' diff --git a/apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx b/apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx index bb324d19f4c..ff564048d90 100644 --- a/apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx +++ b/apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx @@ -1,13 +1,7 @@ 'use client' -import { - AgentIcon, - ConditionalIcon, - JiraIcon, - SlackIcon, - StartIcon, - TableIcon, -} from '@/components/icons' +import { Table as TableIcon } from '@sim/emcn/icons' +import { AgentIcon, ConditionalIcon, JiraIcon, SlackIcon, StartIcon } from '@/components/icons' import { EditorLoop, type EditorLoopContent } from '@/app/(landing)/components/shared/editor-loop' /** diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index fd87f08f5e9..66f28ccef82 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -4,7 +4,13 @@ * @vitest-environment node */ -import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing' +import { + dbChainMockFns, + hybridAuthMockFns, + permissionsMock, + resetDbChainMock, + workflowsUtilsMock, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('should handle unauthenticated user', async () => { @@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => { expect(response.status).toBe(200) expect(data.credentials).toHaveLength(0) }) + + it('does not expose a managed credential requested by exact ID', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'managed-credential-1', + workspaceId: 'workspace-1', + type: 'managed_oauth', + displayName: 'Managed Gmail', + providerId: 'google-email', + accountId: null, + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: null, + accountScope: null, + accountUpdatedAt: null, + }, + ]) + + const response = await GET( + createMockRequestWithQuery('GET', '?credentialId=managed-credential-1') + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ credentials: [] }) + }) }) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index c149d1909b0..a0fb99cdee4 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -12,8 +12,17 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({ +const { + mockAuthenticateManagedOAuthDelegation, + mockAuthorizeCredentialUse, + mockGetToolMetadata, + mockResolveManagedOAuthCredentialToken, + mockResolveServiceAccountToken, +} = vi.hoisted(() => ({ + mockAuthenticateManagedOAuthDelegation: vi.fn(), mockAuthorizeCredentialUse: vi.fn(), + mockGetToolMetadata: vi.fn(), + mockResolveManagedOAuthCredentialToken: vi.fn(), mockResolveServiceAccountToken: vi.fn(), })) @@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mockAuthorizeCredentialUse, })) +vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({ + authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation, + InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {}, +})) + +vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({ + resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken }, +})) + +vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata })) + import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { GET, POST } from '@/app/api/auth/oauth/token/route' @@ -108,6 +128,38 @@ describe('OAuth Token API Routes', () => { expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled() }) + it('does not authenticate managed delegation for an ordinary OAuth credential', async () => { + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'internal_jwt', + requesterUserId: 'workflow-owner-id', + credentialOwnerUserId: 'workflow-owner-id', + }) + authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({ + id: 'credential-id', + accessToken: 'test-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + providerId: 'google', + }) + authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({ + accessToken: 'fresh-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'credential-id', workflowId: 'workflow-id' }, + { 'x-sim-managed-oauth-delegation': 'Bearer stale-delegation' } + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ accessToken: 'fresh-token' }) + expect(mockAuthenticateManagedOAuthDelegation).not.toHaveBeenCalled() + }) + it('should handle missing credentialId', async () => { const req = createMockRequest('POST', {}) @@ -332,6 +384,140 @@ describe('OAuth Token API Routes', () => { ) }) + describe('managed OAuth path', () => { + const managedCredential = { + accountId: '', + credentialId: 'managed-credential-id', + credentialType: 'managed_oauth', + providerId: 'google-email', + workspaceId: 'workspace-id', + usedCredentialTable: true, + } + + beforeEach(() => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }, + }) + }) + + it('fails closed when workflow delegation is missing', async () => { + const response = await POST( + createMockRequest('POST', { + credentialId: 'managed-credential-id', + toolId: 'gmail_read', + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + }) + expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled() + }) + + it('resolves a manually supplied managed credential ID with scoped delegation', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-id', + workspaceId: 'workspace-id', + delegationId: 'delegation-id', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: 'managed-credential-id' }, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-id', + }, + } + mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal) + mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({ + accessToken: 'managed-access-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'managed-credential-id', toolId: 'gmail_read' }, + { 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' } + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' }) + expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({ + principal, + input: { + credentialId: 'managed-credential-id', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + toolId: 'gmail_read', + }, + request: expect.any(NextRequest), + }) + }) + + it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => { + mockGetToolMetadata.mockReturnValueOnce({ + oauth: { + required: true, + provider: 'slack', + }, + }) + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-id', + workspaceId: 'workspace-id', + delegationId: 'delegation-id', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: 'managed-credential-id' }, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-id', + }, + } + mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal) + mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({ + accessToken: 'managed-slack-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'managed-credential-id', toolId: 'slack_message' }, + { 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' } + ) + ) + + expect(response.status).toBe(200) + expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({ + principal, + input: { + credentialId: 'managed-credential-id', + expectedProviderId: 'slack', + requiredScopes: expect.arrayContaining([ + 'channels:read', + 'channels:history', + 'chat:write', + ]), + toolId: 'slack_message', + }, + request: expect.any(NextRequest), + }) + }) + }) + describe('credentialAccountUserId + providerId path', () => { it('should reject unauthenticated requests', async () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index c3e1744dc1f..6d57016744a 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -1,19 +1,30 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { + MANAGED_OAUTH_DELEGATION_HEADER, oauthTokenGetContract, oauthTokenPostContract, } from '@/lib/api/contracts/oauth-connections' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service' +import { + authenticateManagedOAuthDelegation, + InvalidManagedOAuthDelegationError, +} from '@/lib/credentials/application/managed-oauth-delegation' +import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' +import { getToolMetadata } from '@/tools/metadata' export const dynamic = 'force-dynamic' @@ -50,6 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { credentialId, credentialAccountUserId, providerId, + toolId, workflowId, scopes, impersonateEmail, @@ -115,6 +127,129 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null + if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) { + const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER] + if (!managedOAuthDelegation) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + error: 'Managed credentials can only be used by an authenticated workflow execution', + }, + { status: 403 } + ) + } + + let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal + try { + managedOAuthPrincipal = await authenticateManagedOAuthDelegation( + managedOAuthDelegation, + resolved.credentialId + ) + } catch (error) { + if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID', + error: error.message, + }, + { status: 401 } + ) + } + if (!toolId) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED', + error: 'A tool ID is required to use a managed credential', + }, + { status: 400 } + ) + } + + const toolMetadata = getToolMetadata(toolId) + if (!toolMetadata?.oauth?.required) { + logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }, + { status: 500 } + ) + } + const requiredScopes = + toolMetadata.oauth.requiredScopes ?? + getCanonicalScopesForProvider(toolMetadata.oauth.provider) + if (requiredScopes.length === 0) { + logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, { + toolId, + providerId: toolMetadata.oauth.provider, + }) + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }, + { status: 500 } + ) + } + + try { + const result = await resolveManagedOAuthCredentialToken.execute({ + principal: managedOAuthPrincipal, + input: { + credentialId: resolved.credentialId, + expectedProviderId: toolMetadata.oauth.provider, + requiredScopes, + toolId, + }, + request, + }) + + captureServerEvent( + managedOAuthPrincipal.subjectUserId, + 'credential_used', + { + credential_type: 'managed_oauth', + provider_id: toolMetadata.oauth.provider, + workspace_id: managedOAuthPrincipal.workspaceId, + }, + { groups: { workspace: managedOAuthPrincipal.workspaceId } } + ) + + return NextResponse.json( + { + accessToken: result.accessToken, + ...(result.idToken ? { idToken: result.idToken } : {}), + }, + { status: 200 } + ) + } catch (error) { + if (error instanceof ManagedOAuthCredentialError) { + logger.warn(`[${requestId}] Managed OAuth credential rejected`, { + credentialId: resolved.credentialId, + code: error.code, + }) + return NextResponse.json( + { code: error.code, error: error.message }, + { status: error.statusCode } + ) + } + + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_UNAUTHORIZED', + error: orchestrationError.message, + }, + { status: statusForOrchestrationError(orchestrationError.code) } + ) + } + throw error + } + } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) const result = await resolveCredentialToken(auth, { requestId, @@ -124,6 +259,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { impersonateEmail, callerUserId, auditRequest: request, + resolvedCredential: resolved, }) if (!result.ok) { diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts new file mode 100644 index 00000000000..bad8457d5fd --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + complete: vi.fn(), + ipRateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + completePublicCredentialGroupEnrollment: { execute: mocks.complete }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit, +})) + +import { POST } from '@/app/api/credential-groups/enroll/[token]/complete/route' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'alex@example.com', + invitationTokenHash: 'hash-1', +} as const +const context = { params: Promise.resolve({ token: 'invitation-token' }) } + +function request() { + return new NextRequest( + 'http://localhost:3000/api/credential-groups/enroll/invitation-token/complete', + { method: 'POST' } + ) +} + +describe('credential group enrollment completion route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ipRateLimit.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(principal) + mocks.complete.mockResolvedValue({ completed: true }) + }) + + it('submits a fully connected enrollment through its invitation principal', async () => { + const enrollmentRequest = request() + const response = await POST(enrollmentRequest, context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?submitted=1' + ) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.complete).toHaveBeenCalledWith({ + principal, + input: {}, + request: enrollmentRequest, + }) + }) + + it('redirects an incomplete enrollment without marking it complete', async () => { + mocks.complete.mockResolvedValue({ completed: false }) + + const response = await POST(request(), context) + + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=incomplete' + ) + }) + + it('stops before token lookup when the public IP budget is exhausted', async () => { + mocks.ipRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await POST(request(), context) + + expect(response.status).toBe(429) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.complete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts new file mode 100644 index 00000000000..d33a0709cab --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from 'next/server' +import { completeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { completePublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'complete') + if (limited) return limited + + const parsed = await parseRequest(completeCredentialGroupEnrollmentContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const principal = await authenticateCredentialGroupEnrollment(token) + if (!principal) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + const completion = await completePublicCredentialGroupEnrollment + .execute({ principal, input: {}, request }) + .catch((error: unknown) => { + if (asOrchestrationError(error)?.code === 'not_found') return null + throw error + }) + if (!completion) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + const { completed } = completion + if (completed === null) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + return createCredentialGroupEnrollmentRedirect( + token, + completed ? { submitted: '1' } : { oauth: 'incomplete' } + ) + } +) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts new file mode 100644 index 00000000000..acb2344a2b6 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts @@ -0,0 +1,112 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + startOAuth: vi.fn(), + ipRateLimit: vi.fn(), + enrollmentRateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + startPublicCredentialGroupOAuth: { execute: mocks.startOAuth }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit, + enforceCredentialGroupEnrollmentOAuthRateLimit: mocks.enrollmentRateLimit, +})) + +import { GET } from '@/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'alex@example.com', + invitationTokenHash: 'hash-1', +} as const +const context = { + params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }), +} + +function request() { + return new NextRequest( + 'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1' + ) +} + +describe('credential group OAuth start route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ipRateLimit.mockResolvedValue(null) + mocks.enrollmentRateLimit.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(principal) + mocks.startOAuth.mockResolvedValue({ + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=state-1', + }) + }) + + it('redirects a valid enrollment to Google through its application operation', async () => { + const oauthRequest = request() + const response = await GET(oauthRequest, context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toContain('https://accounts.google.com/') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.startOAuth).toHaveBeenCalledWith({ + principal, + input: { invitationToken: 'invitation-token', optionId: 'option-1' }, + request: oauthRequest, + }) + }) + + it('returns an unavailable enrollment to its public page', async () => { + mocks.authenticate.mockResolvedValue(null) + + const response = await GET(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=unavailable' + ) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + + it('returns a rate-limited OAuth start to its enrollment page before token lookup', async () => { + mocks.ipRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await GET(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=rate_limited' + ) + expect(mocks.authenticate).not.toHaveBeenCalled() + }) + + it('returns an exhausted enrollment OAuth budget to the enrollment page', async () => { + mocks.enrollmentRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await GET(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=rate_limited' + ) + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts new file mode 100644 index 00000000000..22921bc1f62 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { startCredentialGroupOAuthContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { startPublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { + enforceCredentialGroupEnrollmentOAuthRateLimit, + enforcePublicCredentialGroupIpRateLimit, +} from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('CredentialGroupOAuthStartAPI') + +export const GET = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ token: string; optionId: string }> } + ) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-start') + + const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context) + if (!parsed.success) return limited ?? parsed.response + const { token, optionId } = parsed.data.params + if (limited) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + } + const principal = await authenticateCredentialGroupEnrollment(token) + if (!principal) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + + const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit( + principal.enrollmentId + ) + if (enrollmentLimited) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + } + + try { + const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({ + principal, + input: { invitationToken: token, optionId }, + request, + }) + const response = NextResponse.redirect(authorizationUrl) + response.headers.set('Cache-Control', 'no-store') + response.headers.set('Referrer-Policy', 'no-referrer') + return response + } catch (error) { + logger.error('Failed to start managed OAuth authorization', { + error: getErrorMessage(error), + }) + return createCredentialGroupEnrollmentRedirect(token, { + oauth: + error instanceof CredentialGroupOAuthError && error.statusCode === 409 + ? 'configuration_changed' + : 'unavailable', + }) + } + } +) diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts new file mode 100644 index 00000000000..a72ec009906 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export function createCredentialGroupEnrollmentRedirect( + token: string, + params: Record +): NextResponse { + const query = new URLSearchParams(params).toString() + const location = `/credential-groups/enroll/${encodeURIComponent(token)}${query ? `?${query}` : ''}` + return new NextResponse(null, { + status: 307, + headers: { + Location: location, + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }, + }) +} diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts new file mode 100644 index 00000000000..cc1e0ee24a8 --- /dev/null +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + completeOAuth: vi.fn(), + consumeAttempt: vi.fn(), + rateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + completePublicCredentialGroupOAuth: { execute: mocks.completeOAuth }, +})) + +vi.mock('@/lib/credential-groups/oauth-state', () => ({ + consumeCredentialGroupOAuthAttempt: mocks.consumeAttempt, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit, +})) + +import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'alex@example.com', + invitationTokenHash: 'hash-1', +} as const +const attempt = { + provider: 'gmail', + invitationToken: 'invitation-token', + optionId: 'option-1', +} +const context = { params: Promise.resolve({ provider: 'gmail' }) } + +function request(query: string) { + return new NextRequest( + `http://localhost:3000/api/credential-groups/oauth/gmail/callback?${query}` + ) +} + +describe('credential group OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.rateLimit.mockResolvedValue(null) + mocks.consumeAttempt.mockResolvedValue(attempt) + mocks.authenticate.mockResolvedValue(principal) + mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' }) + }) + + it('consumes provider-bound state and enters the application operation', async () => { + const callbackRequest = request('state=state-1&code=code-1') + const response = await GET(callbackRequest, context) + + expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1') + expect(mocks.completeOAuth).toHaveBeenCalledWith({ + principal, + input: { attempt, code: 'code-1' }, + request: callbackRequest, + }) + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?connected=option-1' + ) + }) + + it('returns without exchanging when the user denies consent', async () => { + const response = await GET(request('state=state-1&error=access_denied'), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=denied' + ) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + + it('rejects replayed, expired, or cross-provider state', async () => { + mocks.consumeAttempt.mockResolvedValue(null) + + const replayedResponse = await GET(request('state=state-1&code=code-1'), context) + expect(replayedResponse.status).toBe(400) + + mocks.consumeAttempt.mockResolvedValue({ ...attempt, provider: 'slack' }) + const mismatchedResponse = await GET(request('state=state-2&code=code-2'), context) + expect(mismatchedResponse.status).toBe(400) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + + it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => { + mocks.authenticate.mockResolvedValue(null) + + const response = await GET(request('state=state-1&code=code-1'), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=unavailable' + ) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + + it('returns a valid rate-limited callback to the enrollment page without exchanging', async () => { + mocks.rateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await GET(request('state=state-1&code=code-1'), context) + + expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1') + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=rate_limited' + ) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts new file mode 100644 index 00000000000..7ef6b7dc03f --- /dev/null +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { credentialGroupOAuthCallbackContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('CredentialGroupOAuthCallbackAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback') + + const parsed = await parseRequest(credentialGroupOAuthCallbackContract, request, context) + if (!parsed.success) return limited ?? parsed.response + const { provider } = parsed.data.params + const { state, code, error: providerError } = parsed.data.query + + let attempt + try { + attempt = await consumeCredentialGroupOAuthAttempt(state) + } catch (error) { + logger.error('Failed to consume credential group OAuth state', { + error: getErrorMessage(error), + }) + return NextResponse.json( + { error: 'Authorization state is unavailable. Please try again.' }, + { status: 503, headers: { 'Cache-Control': 'no-store' } } + ) + } + if (!attempt || attempt.provider !== provider) { + if (limited) return limited + return NextResponse.json( + { error: 'Authorization state is invalid or expired.' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } + if (limited) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + oauth: 'rate_limited', + }) + } + if (providerError) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' }) + } + if (!code) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' }) + } + + const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken) + if (!principal) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + oauth: 'unavailable', + }) + } + + try { + await completePublicCredentialGroupOAuth.execute({ + principal, + input: { attempt, code }, + request, + }) + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + connected: attempt.optionId, + }) + } catch (error) { + logger.error('Managed OAuth authorization failed', { + provider, + error: getErrorMessage(error), + }) + const status = + error instanceof CredentialGroupOAuthError && error.statusCode === 403 + ? error.message.startsWith('Sign in with') + ? 'account_mismatch' + : 'permissions_required' + : error instanceof CredentialGroupOAuthError && error.statusCode === 409 + ? 'configuration_changed' + : 'failed' + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status }) + } + } +) diff --git a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts new file mode 100644 index 00000000000..441e0978439 --- /dev/null +++ b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { slackCredentialGroupConfigurationCallbackContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' + +const logger = createLogger('SlackCredentialGroupConfigurationCallbackAPI') +const CHANNEL_NAME = 'slack-managed-users' + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function jsonLiteral(value: unknown): string { + return JSON.stringify(value).replace(//g, '\\u003e') +} + +function closePopup(params: { + ok: boolean + message: string + state?: string + credentialGroupId?: string + slackBotCredentialId?: string + reason: string +}): NextResponse { + const title = params.ok ? 'Slack configured' : 'Slack setup failed' + const payload = { + type: CHANNEL_NAME, + ok: params.ok, + state: params.state, + credentialGroupId: params.credentialGroupId, + slackBotCredentialId: params.slackBotCredentialId, + reason: params.reason, + } + const body = `${title}

${escapeHtml(params.message)}

` + return new NextResponse(body, { + headers: { + 'Cache-Control': 'no-store, max-age=0', + 'Content-Type': 'text/html; charset=utf-8', + }, + }) +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const rawState = new URL(request.url).searchParams.get('state')?.slice(0, 512) + const session = await getSession() + if (!session?.user?.id || !session.session?.id) { + return closePopup({ + ok: false, + message: 'Sign in to Sim to complete this Slack setup.', + state: rawState, + reason: 'unauthenticated', + }) + } + const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {}) + if (!parsed.success) { + return closePopup({ + ok: false, + message: 'Slack returned an invalid authorization response.', + state: rawState, + reason: 'invalid_callback', + }) + } + const { state, code, error: providerError } = parsed.data.query + try { + const result = await completeSlackCredentialGroupConfiguration.execute({ + principal: { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }, + input: { state, code, providerError }, + request, + }) + return result.ok + ? closePopup({ + ok: true, + message: 'Slack is ready for this Credential Group. You can close this window.', + state, + credentialGroupId: result.result.credentialGroupId, + slackBotCredentialId: result.result.slackBotCredentialId, + reason: result.reason, + }) + : closePopup({ + ok: false, + message: 'Slack authorization was cancelled.', + state, + reason: result.reason, + }) + } catch (error) { + const orchestrationError = asOrchestrationError(error) + const message = + error instanceof SlackManagedUsersError || orchestrationError + ? getErrorMessage(error) + : 'Slack setup failed. Please try again.' + logger.error('Slack Credential Group configuration callback failed', { + error: getErrorMessage(error), + }) + return closePopup({ + ok: false, + message, + state, + reason: + error instanceof SlackManagedUsersError + ? error.code + : (orchestrationError?.code ?? 'unknown'), + }) + } +}) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 72132ee56d0..7c87041c4d6 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -27,12 +27,19 @@ interface RouteContext { async function requireCredentialAdmin(credentialId: string, userId: string) { const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + }) .from(credential) .where(eq(credential.id, credentialId)) .limit(1) - if (!cred) return null + if (!cred || cred.type === 'managed_oauth') { + return null + } const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) if (perm === null) return null @@ -67,12 +74,17 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route const { id: credentialId } = await context.params const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + }) .from(credential) .where(eq(credential.id, credentialId)) .limit(1) - if (!cred) { + if (!cred || cred.type === 'managed_oauth') { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 3ff1de37444..ca1eee11b9c 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -50,7 +50,7 @@ export const GET = withRouteHandler( try { const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { + if (!access.credential || access.credential.type === 'managed_oauth') { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } if (!canUseCredential(access)) { @@ -82,6 +82,11 @@ export const PUT = withRouteHandler( const { id } = parsed.data.params const body = parsed.data.body + const currentAccess = await getCredentialActorContext(id, session.user.id) + if (!currentAccess.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + const result = await performUpdateCredential({ credentialId: id, userId: session.user.id, @@ -149,6 +154,10 @@ export const DELETE = withRouteHandler( const { id } = await params try { + const currentAccess = await getCredentialActorContext(id, session.user.id) + if (!currentAccess.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } const result = await performDeleteCredential({ credentialId: id, userId: session.user.id, @@ -162,9 +171,11 @@ export const DELETE = withRouteHandler( ? 404 : result.errorCode === 'forbidden' ? 403 - : result.errorCode === 'validation' - ? 400 - : 500 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'validation' + ? 400 + : 500 return NextResponse.json({ error: result.error }, { status }) } diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 2e693609438..15fdfcb5d7f 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -35,7 +35,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (credentialId) { const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) - if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) { + if ( + !access.credential || + access.credential.type === 'managed_oauth' || + access.credential.workspaceId !== workspaceId || + !access.isAdmin + ) { return NextResponse.json( { error: 'Admin access required on the target credential' }, { status: 403 } diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 7e855d2caca..33227c66de0 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' @@ -31,7 +31,9 @@ export const GET = withRouteHandler(async () => { }) .from(credentialMember) .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where(eq(credentialMember.userId, session.user.id)) + .where( + and(eq(credentialMember.userId, session.user.id), ne(credential.type, 'managed_oauth')) + ) return NextResponse.json({ memberships }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 69ec1fb54e2..991b76d712a 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, @@ -117,26 +117,6 @@ async function findExistingCredentialBySourceWith( return null } -/** - * `return await` is load-bearing, not redundant. Next 16.3.0's Turbopack - * optimizer models a bare `return ()` tail call as returning the - * promise object, then propagates that always-truthy fact through the caller's - * `await`. It concludes `if (existingCredential)` is always taken and — because - * every branch inside that block returns — deletes the entire create path from - * the emitted bundle, so a first-time create throws on `existingCredential.id`. - * Awaiting here makes the optimizer model the resolved value instead. - */ -async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - return await findExistingCredentialBySourceWith(db, params) -} - -async function findExistingCredentialBySourceTx( - tx: Parameters[0]>[0], - params: ExistingCredentialSourceParams -) { - return await findExistingCredentialBySourceWith(tx, params) -} - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -187,7 +167,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId: credential.providerId, }) .from(credential) - .where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId))) + .where( + and( + eq(credential.id, lookupCredentialId), + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) .limit(1) if (!row) { @@ -202,7 +188,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where( and( eq(credential.accountId, lookupCredentialId), - eq(credential.workspaceId, workspaceId) + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth') ) ) .limit(1) diff --git a/apps/sim/app/api/desktop/tool/authorize/route.ts b/apps/sim/app/api/desktop/tool/authorize/route.ts index 1ebbae72f2b..27c8515e78c 100644 --- a/apps/sim/app/api/desktop/tool/authorize/route.ts +++ b/apps/sim/app/api/desktop/tool/authorize/route.ts @@ -1,5 +1,6 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { isTerminalToolName } from '@sim/terminal-protocol' +import { isRecordLike } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { authorizeDesktopToolContract } from '@/lib/api/contracts/desktop-tool-authorization' import { parseRequest } from '@/lib/api/server' @@ -43,10 +44,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createNotFoundResponse('Pending client tool call not found') } - const args = - toolCall.args && typeof toolCall.args === 'object' && !Array.isArray(toolCall.args) - ? (toolCall.args as Record) - : {} + const args = isRecordLike(toolCall.args) ? (toolCall.args as Record) : {} const isBrowserTool = isBrowserToolName(toolCall.toolName) const isTerminalTool = isTerminalToolName(toolCall.toolName) const authorized = diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 2acd14d46a2..819081ff2e0 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -711,45 +711,6 @@ async function verifyRegularFileAccess( } } -/** - * Unified authorization function that returns structured result - */ -async function authorizeFileAccess( - key: string, - userId: string, - context?: StorageContext, - storageConfig?: StorageConfig, - isLocal?: boolean -): Promise { - const granted = await verifyFileAccess(key, userId, storageConfig, context, isLocal) - - if (granted) { - let workspaceId: string | undefined - const inferredContext = context || inferContextFromKey(key) - - if (inferredContext === 'workspace') { - const record = await lookupWorkspaceFileByKey(key) - workspaceId = record?.workspaceId - } else { - const extracted = extractWorkspaceIdFromKey(key) - if (extracted) { - workspaceId = extracted - } - } - - return { - granted: true, - reason: 'Access granted', - workspaceId, - } - } - - return { - granted: false, - reason: 'Access denied - insufficient permissions or file not found', - } -} - /** * Guard helper for tool routes that download user files from storage. * diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 9c8810563a4..a7cf3aa4b72 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -251,6 +251,11 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom } case 'delegated': throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + case 'credential_group_enrollment': + throw new UploadSessionError( + 'forbidden', + 'Credential Group enrollment principals cannot create uploads' + ) } } diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index e9baede4e62..6480da5d0b7 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -79,22 +79,6 @@ export const contentTypeMap: Record = { googleFolder: 'application/vnd.google-apps.folder', } -export const binaryExtensions = [ - 'doc', - 'docx', - 'xls', - 'xlsx', - 'ppt', - 'pptx', - 'zip', - 'png', - 'jpg', - 'jpeg', - 'gif', - 'webp', - 'pdf', -] - export function getContentType(filename: string): string { const extension = filename.split('.').pop()?.toLowerCase() || '' return contentTypeMap[extension] || 'application/octet-stream' diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 47d16dcb288..3937235d454 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -2,6 +2,7 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { functionExecuteContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' @@ -1030,12 +1031,6 @@ function inspectMountedWorkspaceFileProvenance( } } -function asRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} -} - function getPositiveNumber(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { return undefined @@ -1054,8 +1049,8 @@ function getBrokerFileArgs(args: unknown): { offset?: number length?: number } { - const record = asRecord(args) - const options = asRecord(record.options) + const record = toRecord(args) + const options = toRecord(record.options) return { file: record.file, maxBytes: clampInlineBytes(options.maxBytes), @@ -1105,8 +1100,8 @@ function createFunctionRuntimeBrokers( 'sim.files.readBase64Chunk': (args) => readFile(args, 'base64', true), 'sim.files.readTextChunk': (args) => readFile(args, 'text', true), 'sim.values.read': async (args) => { - const record = asRecord(args) - const options = asRecord(record.options) + const record = toRecord(args) + const options = toRecord(record.options) const ref = record.ref if (!isLargeValueRef(ref)) { throw new Error('Expected a large execution value reference.') @@ -1125,8 +1120,8 @@ function createFunctionRuntimeBrokers( return value }, 'sim.values.readArray': async (args) => { - const record = asRecord(args) - const options = asRecord(record.options) + const record = toRecord(args) + const options = toRecord(record.options) const manifest = record.ref if (!isLargeArrayManifest(manifest)) { throw new Error('Expected a large array manifest.') @@ -1180,9 +1175,9 @@ async function functionJsonResponse( } function getFunctionResultProvenanceSurface(body: unknown): unknown { - const record = asRecord(body) - const output = asRecord(record.output) - const debug = asRecord(record.debug) + const record = toRecord(body) + const output = toRecord(record.output) + const debug = toRecord(record.debug) return [ Object.hasOwn(record, 'error') ? record.error : undefined, Object.hasOwn(output, 'result') ? output.result : undefined, diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 80c16fdda81..faec1b78e24 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -26,6 +26,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -265,12 +266,9 @@ function toToolInputSchema(schema: unknown): Partial { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return {} const candidate = schema as Record - const properties = - candidate.properties && - typeof candidate.properties === 'object' && - !Array.isArray(candidate.properties) - ? (candidate.properties as Tool['inputSchema']['properties']) - : {} + const properties = isRecordLike(candidate.properties) + ? (candidate.properties as Tool['inputSchema']['properties']) + : {} const required = Array.isArray(candidate.required) ? candidate.required.filter((entry): entry is string => typeof entry === 'string') : undefined @@ -281,23 +279,6 @@ function toToolInputSchema(schema: unknown): Partial { } } -function isJsonObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -function parseJsonValue(text: string): { success: true; value: unknown } | { success: false } { - if (!text) return { success: true, value: {} } - try { - return { success: true, value: JSON.parse(text) } - } catch { - return { success: false } - } -} - -function hasResponseField(value: Record, property: string): boolean { - return Object.hasOwn(value, property) -} - function getWorkflowErrorStatus(status: number): number { return [400, 401, 402, 403, 404, 408, 409, 413, 429, 499, 503].includes(status) ? status : 500 } diff --git a/apps/sim/app/api/table/[tableId]/rows/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/route.test.ts index b6162110b57..0711127e7a4 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.test.ts @@ -208,6 +208,33 @@ describe('GET /api/table/[tableId]/rows', () => { expect(body.data.rows[0].data).toEqual({ Name: 'Ada', Age: 36 }) }) + it('keeps counts but skips execution metadata for an omitted or expanded limit', async () => { + authAs('internal_jwt') + + const omitted = await callGet({ workspaceId: 'workspace-1' }) + expect(omitted.status).toBe(200) + expect(mockQueryRows.mock.calls[0][1]).toEqual( + expect.objectContaining({ limit: undefined, includeTotal: true, withExecutions: false }) + ) + + const expanded = await callGet({ workspaceId: 'workspace-1', limit: '1000000' }) + expect(expanded.status).toBe(200) + expect(mockQueryRows.mock.calls[1][1]).toEqual( + expect.objectContaining({ limit: 1000000, includeTotal: true, withExecutions: false }) + ) + }) + + it('retains metadata loading within the former query limit', async () => { + authAs('internal_jwt') + + const res = await callGet({ workspaceId: 'workspace-1', limit: '1000' }) + + expect(res.status).toBe(200) + expect(mockQueryRows.mock.calls[0][1]).toEqual( + expect.objectContaining({ limit: 1000, includeTotal: true, withExecutions: true }) + ) + }) + it('passes id-keyed filter and rows through untouched for session callers', async () => { authAs('session') diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 869869c18b6..3b7d26a4bdf 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -26,6 +26,7 @@ import { validateRowData, validateRowSize, } from '@/lib/table' +import { TABLE_LIMITS } from '@/lib/table/constants' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged, signalTableRowsChangedByActor } from '@/lib/table/events' import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters' @@ -358,6 +359,13 @@ export const GET = withRouteHandler( } const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema) + /** + * The newly expanded path can return up to the byte budget, so skip the + * per-row execution-sidecar load. Keep the count behavior unchanged so + * Query Rows continues to return totalCount for workflow callers. + */ + const isExpandedQuery = + validated.limit === undefined || validated.limit > TABLE_LIMITS.MAX_QUERY_LIMIT const result = await queryRows( table, { @@ -381,6 +389,7 @@ export const GET = withRouteHandler( offset: validated.offset, after: validated.after, includeTotal: validated.includeTotal, + withExecutions: !isExpandedQuery, }, requestId ) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 037c6c83cc8..9c375aa8736 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -2,11 +2,6 @@ import { createLogger } from '@sim/logger' import { permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' -import { - createTableColumnBodySchema, - deleteTableColumnBodySchema, - updateTableColumnBodySchema, -} from '@/lib/api/contracts/tables' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { asOrchestrationError, @@ -298,26 +293,6 @@ export function accessError( return NextResponse.json({ error: message }, { status: result.status }) } -/** - * Converts a TableAccessDenied result to an appropriate HTTP response. - * Use with checkTableAccess or checkTableWriteAccess. - */ -export function tableAccessError( - result: TableAccessDenied, - requestId: string, - context?: string -): NextResponse { - const status = result.notFound ? 404 : 403 - const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied') - logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`) - return NextResponse.json({ error: message }, { status }) -} - -async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise { - const table = await getTableById(tableId) - return table?.workspaceId === workspaceId -} - export function errorResponse( message: string, status: number, @@ -345,15 +320,3 @@ export function forbiddenResponse(message = 'Access denied') { export function notFoundResponse(message = 'Resource not found') { return errorResponse(message, 404) } - -export function serverErrorResponse(message = 'Internal server error') { - return errorResponse(message, 500) -} - -/** - * Re-exports from `lib/api/contracts/tables` so existing routes that import - * these names keep working while sharing a single source of truth. - */ -export const CreateColumnSchema = createTableColumnBodySchema -export const UpdateColumnSchema = updateTableColumnBodySchema -export const DeleteColumnSchema = deleteTableColumnBodySchema diff --git a/apps/sim/app/api/tools/azure_data_explorer/proxy/route.test.ts b/apps/sim/app/api/tools/azure_data_explorer/proxy/route.test.ts new file mode 100644 index 00000000000..010a68a239b --- /dev/null +++ b/apps/sim/app/api/tools/azure_data_explorer/proxy/route.test.ts @@ -0,0 +1,321 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' + +const { mockSecureFetch, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), + MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithValidation: mockSecureFetch, + MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES, +})) + +import { POST } from '@/app/api/tools/azure_data_explorer/proxy/route' + +const baseBody = { + clusterUri: 'https://mycluster.eastus.kusto.windows.net', + tenantId: 'tenant-1', + clientId: 'client-1', + clientSecret: 'secret-1', + endpoint: 'query', + database: 'Samples', + csl: 'print Test="Hello, World!"', +} + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: async () => body, + text: async () => JSON.stringify(body), + } +} + +/** Queues the Entra token response, then the cluster response. */ +function mockCluster(clusterBody: unknown, status = 200) { + mockSecureFetch.mockReset() + mockSecureFetch + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-1', expires_in: 3600 })) + .mockResolvedValueOnce(jsonResponse(clusterBody, status)) +} + +let secretCounter = 0 + +/** + * The route caches Entra tokens per credential, so every test needs its own + * secret to exercise the token fetch rather than a warm cache entry. + */ +function post(body: Record) { + secretCounter += 1 + return POST( + createMockRequest('POST', { ...body, clientSecret: `secret-${secretCounter}` }) as never, + undefined as never + ) +} + +/** + * A v1 query answer. The primary result deliberately is NOT the first table, so + * a reader that ignores the table of contents picks the wrong one. + */ +function queryResponse(options: { severity: number; statusDescription: string }) { + return { + Tables: [ + { + TableName: 'Table_0', + Columns: [{ ColumnName: 'Value', DataType: 'String', ColumnType: 'string' }], + Rows: [['{"Visualization":null}']], + }, + { + TableName: 'Table_1', + Columns: [{ ColumnName: 'Test', DataType: 'String', ColumnType: 'string' }], + Rows: [['Hello, World!']], + }, + { + TableName: 'Table_2', + Columns: [ + { ColumnName: 'Severity', DataType: 'Int32', ColumnType: 'int' }, + { ColumnName: 'StatusCode', DataType: 'Int32', ColumnType: 'int' }, + { ColumnName: 'StatusDescription', DataType: 'String', ColumnType: 'string' }, + ], + Rows: [[options.severity, 0, options.statusDescription]], + }, + { + TableName: 'Table_3', + Columns: [ + { ColumnName: 'Ordinal', DataType: 'Int64', ColumnType: 'long' }, + { ColumnName: 'Kind', DataType: 'String', ColumnType: 'string' }, + { ColumnName: 'Name', DataType: 'String', ColumnType: 'string' }, + ], + Rows: [ + [0, 'QueryProperties', '@ExtendedProperties'], + [1, 'QueryResult', 'PrimaryResult'], + [2, 'QueryStatus', 'QueryStatus'], + ], + }, + ], + } +} + +describe('POST /api/tools/azure_data_explorer/proxy', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' }) + }) + + it('returns the primary result table named by the table of contents', async () => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + const response = await post(baseBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.tableName).toBe('Table_1') + expect(data.output.columns).toEqual([{ name: 'Test', type: 'string', dataType: 'String' }]) + expect(data.output.rows).toEqual([['Hello, World!']]) + expect(data.output.records).toEqual([{ Test: 'Hello, World!' }]) + expect(data.output.rowCount).toBe(1) + }) + + it('reports a partial query failure even though the cluster answered 200', async () => { + mockCluster(queryResponse({ severity: 2, statusDescription: 'Query execution has exceeded' })) + + const response = await post(baseBody) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toBe('Query execution has exceeded') + }) + + it('does not mistake a result column named Severity for a failed query', async () => { + const shadowed = queryResponse({ + severity: 4, + statusDescription: 'Query completed successfully', + }) + shadowed.Tables[1] = { + TableName: 'Table_1', + Columns: [ + { ColumnName: 'Severity', DataType: 'Int32', ColumnType: 'int' }, + { ColumnName: 'StatusDescription', DataType: 'String', ColumnType: 'string' }, + ], + Rows: [[1, 'disk almost full']], + } + mockCluster(shadowed) + + const response = await post(baseBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.output.records).toEqual([{ Severity: 1, StatusDescription: 'disk almost full' }]) + }) + + it('returns the first table for a management command, which has no table of contents', async () => { + mockCluster({ + Tables: [ + { + TableName: 'Table_0', + Columns: [{ ColumnName: 'TableName', DataType: 'String', ColumnType: 'string' }], + Rows: [['StormEvents'], ['Logs']], + }, + ], + }) + + const response = await post({ ...baseBody, endpoint: 'mgmt', csl: '.show tables' }) + const data = await response.json() + + expect(data.output.records).toEqual([{ TableName: 'StormEvents' }, { TableName: 'Logs' }]) + }) + + it('sends the KQL request to the cluster with a bearer token and read-only header', async () => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + await post({ ...baseBody, readOnly: true }) + + const [url, options] = mockSecureFetch.mock.calls[1] + expect(url).toBe('https://mycluster.eastus.kusto.windows.net/v1/rest/query') + expect(options.headers.Authorization).toBe('Bearer token-1') + expect(options.headers['x-ms-readonly']).toBe('true') + expect(JSON.parse(options.body)).toEqual({ db: 'Samples', csl: baseBody.csl }) + }) + + it('requests an Entra token audience of the cluster origin by default', async () => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + await post(baseBody) + + const [tokenUrl, tokenOptions] = mockSecureFetch.mock.calls[0] + expect(tokenUrl).toBe('https://login.microsoftonline.com/tenant-1/oauth2/token') + expect(Object.fromEntries(new URLSearchParams(tokenOptions.body))).toEqual({ + grant_type: 'client_credentials', + client_id: 'client-1', + client_secret: `secret-${secretCounter}`, + resource: 'https://mycluster.eastus.kusto.windows.net', + }) + }) + + it.each([ + ['https://c.usgovvirginia.kusto.usgovcloudapi.net', 'https://login.microsoftonline.us'], + ['https://c.chinanorth.kusto.chinacloudapi.cn', 'https://login.partner.microsoftonline.cn'], + ['https://c.eastus.kusto.windows.net', 'https://login.microsoftonline.com'], + ])('authenticates %s against its own cloud Entra authority', async (clusterUri, authority) => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + const response = await post({ ...baseBody, clusterUri, resource: undefined }) + + expect(response.status).toBe(200) + const [tokenUrl] = mockSecureFetch.mock.calls[0] + expect(tokenUrl).toBe(`${authority}/tenant-1/oauth2/token`) + }) + + it('rejects a cluster URI outside the Kusto service domains', async () => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + const response = await post({ ...baseBody, clusterUri: 'https://evil.example.com' }) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Azure Data Explorer or Fabric Eventhouse endpoint') + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('surfaces the Kusto error envelope on a failed request', async () => { + mockCluster({ error: { code: 'BadRequest_SyntaxError', message: "Syntax error: 'wher'" } }, 400) + + const response = await post(baseBody) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toBe("[BadRequest_SyntaxError] Syntax error: 'wher'") + }) + + it('rejects an entity name outside the documented Kusto identifier character set', async () => { + const response = await post({ ...baseBody, database: 'Samples"] | drop table X //' }) + + expect(response.status).toBe(400) + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('accepts an apex token audience, which is the Fabric Eventhouse form', async () => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + const response = await post({ ...baseBody, resource: 'https://kusto.fabric.microsoft.com' }) + + expect(response.status).toBe(200) + const [, tokenOptions] = mockSecureFetch.mock.calls[0] + expect(Object.fromEntries(new URLSearchParams(tokenOptions.body)).resource).toBe( + 'https://kusto.fabric.microsoft.com' + ) + }) + + it('caps the rows it returns and reports what the cluster actually produced', async () => { + const wide = queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }) + wide.Tables[1].Rows = Array.from({ length: 10_050 }, (_, i) => [`row-${i}`]) + mockCluster(wide) + + const response = await post(baseBody) + const data = await response.json() + + expect(data.output.rowCount).toBe(10_000) + expect(data.output.rows).toHaveLength(10_000) + expect(data.output.records).toHaveLength(10_000) + expect(data.output.totalRowCount).toBe(10_050) + expect(data.output.truncated).toBe(true) + }) + + it('reports truncated as false when every row fits', async () => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + const response = await post(baseBody) + const data = await response.json() + + expect(data.output.truncated).toBe(false) + expect(data.output.totalRowCount).toBe(1) + }) + + it('bounds the cluster response body rather than reading it unlimited', async () => { + mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })) + + await post(baseBody) + + const [, options] = mockSecureFetch.mock.calls[1] + expect(options.maxResponseBytes).toBe(MOCK_MAX_JSON_BYTES) + }) + + it('asks the caller to narrow the query when the response exceeds the cap', async () => { + mockSecureFetch.mockReset() + mockSecureFetch + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-1', expires_in: 3600 })) + .mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'Azure Data Explorer response', + maxBytes: MOCK_MAX_JSON_BYTES, + }) + ) + + const response = await post(baseBody) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toContain('Narrow the query') + }) + + it('rejects an unauthenticated request before reaching the cluster', async () => { + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: false, + error: 'Authentication required', + }) + + const response = await post(baseBody) + + expect(response.status).toBe(401) + expect(mockSecureFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/tools/azure_data_explorer/proxy/route.ts b/apps/sim/app/api/tools/azure_data_explorer/proxy/route.ts new file mode 100644 index 00000000000..ada24aa4784 --- /dev/null +++ b/apps/sim/app/api/tools/azure_data_explorer/proxy/route.ts @@ -0,0 +1,403 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' +import { type NextRequest, NextResponse } from 'next/server' +import { + type AzureDataExplorerProxyRequest, + assertSafeAzureDataExplorerClusterUri, + azureDataExplorerProxyContract, + resolveEntraAuthority, +} from '@/lib/api/contracts/tools/azure_data_explorer' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + MAX_JSON_API_RESPONSE_BYTES, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AzureDataExplorerProxyAPI') + +const OUTBOUND_FETCH_TIMEOUT_MS = 120_000 +const TOKEN_FETCH_TIMEOUT_MS = 30_000 +const TOKEN_CACHE_MAX_ENTRIES = 500 +const TOKEN_SAFETY_WINDOW_MS = 60_000 +const MAX_ERROR_MESSAGE_LENGTH = 2000 +const MAX_TOKEN_RESPONSE_BYTES = 256 * 1024 +/** Rows a single result may carry into a workflow value. */ +const MAX_PROJECTED_ROWS = 10_000 + +interface CachedToken { + accessToken: string + expiresAt: number +} + +const TOKEN_CACHE = new Map() + +/** The cluster's own origin is the documented default token audience. */ +function resolveResource(req: AzureDataExplorerProxyRequest, clusterUrl: URL): string { + return (req.resource || clusterUrl.origin).replace(/\/+$/, '') +} + +function tokenCacheKey( + req: AzureDataExplorerProxyRequest, + authority: string, + resource: string +): string { + const secretHash = createHash('sha256').update(req.clientSecret).digest('hex').slice(0, 16) + return `${authority}::${req.tenantId}::${req.clientId}::${secretHash}::${resource}` +} + +function rememberToken(key: string, token: CachedToken): void { + if (TOKEN_CACHE.has(key)) TOKEN_CACHE.delete(key) + TOKEN_CACHE.set(key, token) + while (TOKEN_CACHE.size > TOKEN_CACHE_MAX_ENTRIES) { + const oldestKey = TOKEN_CACHE.keys().next().value + if (oldestKey === undefined) break + TOKEN_CACHE.delete(oldestKey) + } +} + +async function fetchAccessToken( + req: AzureDataExplorerProxyRequest, + authority: string, + resource: string, + requestId: string +): Promise { + const cacheKey = tokenCacheKey(req, authority, resource) + const cached = TOKEN_CACHE.get(cacheKey) + if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { + return cached.accessToken + } + + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: req.clientId, + client_secret: req.clientSecret, + resource, + }) + + const response = await secureFetchWithValidation( + `${authority}/${encodeURIComponent(req.tenantId)}/oauth2/token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + timeout: TOKEN_FETCH_TIMEOUT_MS, + maxResponseBytes: MAX_TOKEN_RESPONSE_BYTES, + }, + 'tokenUrl' + ) + + if (!response.ok) { + const text = await response.text().catch(() => '') + logger.warn(`[${requestId}] Entra token fetch failed (${response.status}): ${text}`) + throw new Error( + `Microsoft Entra token request failed: HTTP ${response.status}. Verify tenantId, clientId, clientSecret, and that the app has access to the cluster.` + ) + } + + const data = (await response.json()) as { access_token?: string; expires_in?: string | number } + if (!data.access_token) { + throw new Error('Microsoft Entra token response did not include an access token') + } + + const expiresInSeconds = Number(data.expires_in) + const expiresInMs = (Number.isFinite(expiresInSeconds) ? expiresInSeconds : 3600) * 1000 + rememberToken(cacheKey, { + accessToken: data.access_token, + expiresAt: Date.now() + expiresInMs, + }) + return data.access_token +} + +interface KustoColumn { + ColumnName?: string + DataType?: string + ColumnType?: string +} + +interface KustoTable { + TableName?: string + Columns?: KustoColumn[] + Rows?: unknown[][] +} + +function columnNames(table: KustoTable): string[] { + return (table.Columns ?? []).map((column) => column.ColumnName ?? '') +} + +interface TableOfContents { + primaryOrdinal: number | null + statusOrdinal: number | null +} + +/** + * Reads the trailing table of contents, which maps each ordinal in the response + * to a kind. It is the only thing that identifies which table holds the query's + * results and which holds its status — a management command has no table of + * contents, and returns `null` here. + */ +function readTableOfContents(tables: KustoTable[]): TableOfContents | null { + if (tables.length === 0) return null + + const contents = tables[tables.length - 1] + const names = columnNames(contents) + const ordinalIndex = names.indexOf('Ordinal') + const kindIndex = names.indexOf('Kind') + if (ordinalIndex < 0 || kindIndex < 0) return null + + let primaryOrdinal: number | null = null + let statusOrdinal: number | null = null + for (const row of contents.Rows ?? []) { + const ordinal = Number(row[ordinalIndex]) + if (!Number.isInteger(ordinal) || !tables[ordinal]) continue + if (row[kindIndex] === 'QueryResult' && primaryOrdinal === null) primaryOrdinal = ordinal + if (row[kindIndex] === 'QueryStatus' && statusOrdinal === null) statusOrdinal = ordinal + } + return { primaryOrdinal, statusOrdinal } +} + +/** + * Picks the table holding the query's own results — the first `QueryResult` + * ordinal the table of contents names. A management command returns a single + * table with no table of contents, so the first table is the answer. + */ +function selectPrimaryTable( + tables: KustoTable[], + contents: TableOfContents | null +): KustoTable | null { + if (tables.length === 0) return null + if (contents?.primaryOrdinal != null) return tables[contents.primaryOrdinal] ?? tables[0] + return tables[0] +} + +/** + * Finds a partial query failure. Kusto answers 200 as soon as it starts + * processing, then reports later failures through the QueryStatus table, where a + * severity of 2 or lower means the request did not succeed. + * + * Only the table the table of contents names as `QueryStatus` is inspected. + * Scanning every table for `Severity`/`StatusDescription` columns would + * misread an ordinary log query that happens to select columns of those names + * as a failed request. + */ +function findQueryFailure(tables: KustoTable[], contents: TableOfContents | null): string | null { + if (contents?.statusOrdinal == null) return null + const table = tables[contents.statusOrdinal] + if (!table) return null + + const names = columnNames(table) + const severityIndex = names.indexOf('Severity') + const descriptionIndex = names.indexOf('StatusDescription') + if (severityIndex < 0 || descriptionIndex < 0) return null + + for (const row of table.Rows ?? []) { + const severity = Number(row[severityIndex]) + if (!Number.isFinite(severity) || severity > 2) continue + const description = row[descriptionIndex] + return typeof description === 'string' && description.length > 0 + ? description + : 'Kusto reported a query failure' + } + return null +} + +interface ProjectedTable { + tableName: string | null + columns: Array<{ name: string; type: string | null; dataType: string | null }> + rows: unknown[][] + records: Array> + rowCount: number + totalRowCount: number + truncated: boolean +} + +const EMPTY_PROJECTION: ProjectedTable = { + tableName: null, + columns: [], + rows: [], + records: [], + rowCount: 0, + totalRowCount: 0, + truncated: false, +} + +/** + * Projects the result table into a bounded payload. + * + * Kusto's own result truncation is a request property the caller can raise or + * disable, so neither the row count nor the byte count of a response is bounded + * upstream. `MAX_PROJECTED_ROWS` is the ceiling on what a single workflow value + * may carry; `truncated` tells the caller to narrow the query rather than + * silently trusting a short answer. + */ +function projectTable(table: KustoTable | null): ProjectedTable { + if (!table) return EMPTY_PROJECTION + + const columns = (table.Columns ?? []).map((column) => ({ + name: column.ColumnName ?? '', + type: column.ColumnType ?? null, + dataType: column.DataType ?? null, + })) + const allRows = table.Rows ?? [] + const rows = allRows.length > MAX_PROJECTED_ROWS ? allRows.slice(0, MAX_PROJECTED_ROWS) : allRows + const records = rows.map((row) => { + const record: Record = {} + columns.forEach((column, index) => { + if (column.name) record[column.name] = row[index] ?? null + }) + return record + }) + + return { + tableName: table.TableName ?? null, + columns, + rows, + records, + rowCount: rows.length, + totalRowCount: allRows.length, + truncated: allRows.length > rows.length, + } +} + +/** + * Kusto failures follow the Microsoft REST guidelines envelope, but a request + * without a JSON body (or a gateway error) can answer with plain text. + */ +function extractKustoError(body: unknown, status: number): string { + if (body && typeof body === 'object') { + const error = (body as { error?: { code?: unknown; message?: unknown } }).error + if (error && typeof error === 'object') { + const message = typeof error.message === 'string' ? error.message : '' + const code = typeof error.code === 'string' ? error.code : '' + if (message) return code ? `[${code}] ${message}` : message + if (code) return code + } + } + if (typeof body === 'string' && body.length > 0) { + return truncate(body, MAX_ERROR_MESSAGE_LENGTH) + } + return `Azure Data Explorer request failed with HTTP ${status}` +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Azure Data Explorer request: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + azureDataExplorerProxyContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { success: false, error: getValidationErrorMessage(error, 'Validation failed') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + const proxyReq = parsed.data.body + + const clusterUrl = assertSafeAzureDataExplorerClusterUri(proxyReq.clusterUri) + const resource = resolveResource(proxyReq, clusterUrl) + const authority = resolveEntraAuthority(clusterUrl.hostname) + const accessToken = await fetchAccessToken(proxyReq, authority, resource, requestId) + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + 'x-ms-client-request-id': `Sim.Workflow;${requestId}`, + 'x-ms-app': 'Sim', + } + if (proxyReq.readOnly) headers['x-ms-readonly'] = 'true' + + const response = await secureFetchWithValidation( + `${clusterUrl.origin}/v1/rest/${proxyReq.endpoint}`, + { + method: 'POST', + headers, + body: JSON.stringify({ + ...(proxyReq.database ? { db: proxyReq.database } : {}), + csl: proxyReq.csl, + ...(proxyReq.properties ? { properties: proxyReq.properties } : {}), + }), + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + }, + 'clusterUri' + ) + + const raw = await response.text() + let body: unknown = null + if (raw.length > 0) { + try { + body = JSON.parse(raw) + } catch { + body = raw + } + } + + if (!response.ok) { + const message = extractKustoError(body, response.status) + logger.warn(`[${requestId}] Azure Data Explorer error (${response.status}): ${message}`) + return NextResponse.json( + { success: false, error: message, status: response.status }, + { status: response.status } + ) + } + + const tables = Array.isArray((body as { Tables?: KustoTable[] } | null)?.Tables) + ? ((body as { Tables: KustoTable[] }).Tables ?? []) + : [] + + const contents = readTableOfContents(tables) + + const failure = findQueryFailure(tables, contents) + if (failure) { + logger.warn(`[${requestId}] Azure Data Explorer partial query failure: ${failure}`) + return NextResponse.json( + { success: false, error: truncate(failure, MAX_ERROR_MESSAGE_LENGTH), status: 200 }, + { status: 400 } + ) + } + + return NextResponse.json({ + success: true, + output: projectTable(selectPrimaryTable(tables, contents)), + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + logger.warn(`[${requestId}] Azure Data Explorer response exceeded the size cap`) + return NextResponse.json( + { + success: false, + error: + 'The Azure Data Explorer response was too large to return. Narrow the query — add a `where` filter, aggregate with `summarize`, or bound it with `take` or `top N by`.', + }, + { status: 413 } + ) + } + logger.error(`[${requestId}] Unexpected Azure Data Explorer proxy error:`, error) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 71191f1887d..48936493e09 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -2,6 +2,7 @@ import { Buffer, isUtf8 } from 'buffer' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import JSZip from 'jszip' import { type NextRequest, NextResponse } from 'next/server' import { fileManageContract } from '@/lib/api/contracts/tools/file' @@ -480,7 +481,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { fileId, fileInput } = body const selectedFileId = fileId || - (fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput) + (isRecordLike(fileInput) ? (() => { const obj = fileInput as Record return typeof obj.id === 'string' diff --git a/apps/sim/app/api/tools/grafana/check_data_source_health/route.test.ts b/apps/sim/app/api/tools/grafana/check_data_source_health/route.test.ts new file mode 100644 index 00000000000..f92fbe8c0de --- /dev/null +++ b/apps/sim/app/api/tools/grafana/check_data_source_health/route.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSecureFetch, mockValidateUrl, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), + mockValidateUrl: vi.fn(), + MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithPinnedIP: mockSecureFetch, + validateUrlWithDNS: mockValidateUrl, + MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES, +})) + +import { POST } from '@/app/api/tools/grafana/check_data_source_health/route' + +const baseBody = { + apiKey: 'glsa_token', + baseUrl: 'https://grafana.example.com', + dataSourceUid: 'P1234AB5678', +} + +function grafanaResponse(body: unknown, status: number) { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + headers: new Headers(), + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } +} + +function post(body: Record = baseBody) { + return POST(createMockRequest('POST', body) as never, undefined as never) +} + +describe('POST /api/tools/grafana/check_data_source_health', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' }) + mockValidateUrl.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + }) + + it('reports a healthy data source', async () => { + mockSecureFetch.mockResolvedValue( + grafanaResponse({ status: 'OK', message: 'Data source is working' }, 200) + ) + + const response = await post() + const data = await response.json() + + expect(data.success).toBe(true) + expect(data.output).toEqual({ status: 'OK', message: 'Data source is working' }) + }) + + it('reports an UNHEALTHY data source, which Grafana answers with HTTP 400', async () => { + mockSecureFetch.mockResolvedValue( + grafanaResponse({ status: 'ERROR', message: 'dial tcp: connection refused' }, 400) + ) + + const response = await post() + const data = await response.json() + + expect(data.success).toBe(true) + expect(data.output.status).toBe('ERROR') + expect(data.output.message).toBe('dial tcp: connection refused') + }) + + it('surfaces the plugin details when Grafana supplies them', async () => { + mockSecureFetch.mockResolvedValue( + grafanaResponse( + { status: 'ERROR', message: 'bad query', details: { verboseMessage: 'x' } }, + 400 + ) + ) + + const response = await post() + const data = await response.json() + + expect(data.output.details).toEqual({ verboseMessage: 'x' }) + }) + + it('treats a failure with no health verdict as a real request failure', async () => { + mockSecureFetch.mockResolvedValue(grafanaResponse({ message: 'Data source not found' }, 404)) + + const response = await post() + const data = await response.json() + + expect(data.success).toBe(false) + expect(data.error).toContain('404') + }) + + it('bounds and protects the outbound call', async () => { + mockSecureFetch.mockResolvedValue(grafanaResponse({ status: 'OK', message: 'ok' }, 200)) + + await post() + + const [url, resolvedIP, options] = mockSecureFetch.mock.calls[0] + expect(resolvedIP).toBe('203.0.113.10') + expect(url).toBe('https://grafana.example.com/api/datasources/uid/P1234AB5678/health') + expect(options.maxResponseBytes).toBe(MOCK_MAX_JSON_BYTES) + expect(options.timeout).toBeGreaterThan(0) + expect(options.stripAuthOnRedirect).toBe(true) + expect(options.headers.Authorization).toBe('Bearer glsa_token') + }) + + it('encodes the UID so it cannot re-target the request path', async () => { + mockSecureFetch.mockResolvedValue(grafanaResponse({ status: 'OK', message: 'ok' }, 200)) + + await post({ ...baseBody, dataSourceUid: 'a/../../admin' }) + + const [url] = mockSecureFetch.mock.calls[0] + expect(url).toBe('https://grafana.example.com/api/datasources/uid/a%2F..%2F..%2Fadmin/health') + }) + + it('rejects an unauthenticated request before reaching Grafana', async () => { + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: false, + error: 'Authentication required', + }) + + const response = await post() + + expect(response.status).toBe(401) + expect(mockSecureFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/tools/grafana/check_data_source_health/route.ts b/apps/sim/app/api/tools/grafana/check_data_source_health/route.ts new file mode 100644 index 00000000000..970fce774f5 --- /dev/null +++ b/apps/sim/app/api/tools/grafana/check_data_source_health/route.ts @@ -0,0 +1,139 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' +import { type NextRequest, NextResponse } from 'next/server' +import { grafanaCheckDataSourceHealthContract } from '@/lib/api/contracts/tools/grafana' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + MAX_JSON_API_RESPONSE_BYTES, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('GrafanaCheckDataSourceHealthAPI') + +const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 +const MAX_ERROR_MESSAGE_LENGTH = 2000 + +/** + * Runs a data source health check. + * + * Grafana answers an *unhealthy* data source with HTTP 400 carrying the same + * `{status, message}` payload it uses for a healthy one, so the diagnostic the + * caller actually wants only exists on the failure status. A plain tool would + * have that converted into an opaque tool error, making the check able to report + * health and never ill-health — hence this route, which reads the payload off + * either status and reports it as a successful check. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Grafana health check: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + grafanaCheckDataSourceHealthContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const baseUrl = params.baseUrl.replace(/\/$/, '') + const healthUrl = `${baseUrl}/api/datasources/uid/${encodeURIComponent( + params.dataSourceUid.trim() + )}/health` + + const urlValidation = await validateUrlWithDNS(healthUrl, 'baseUrl') + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json({ + success: false, + error: `Invalid Grafana baseUrl: ${urlValidation.error}`, + }) + } + + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + if (params.organizationId) { + headers['X-Grafana-Org-Id'] = params.organizationId + } + + const response = await secureFetchWithPinnedIP(healthUrl, urlValidation.resolvedIP, { + method: 'GET', + headers, + maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + stripAuthOnRedirect: true, + }) + + const raw = await response.text() + let body: unknown = null + if (raw.length > 0) { + try { + body = JSON.parse(raw) + } catch { + body = null + } + } + + const payload = + body && typeof body === 'object' + ? (body as { status?: unknown; message?: unknown; details?: unknown }) + : null + + /** + * A `status` in the body means Grafana ran the check and reported a verdict, + * whatever the HTTP status. Anything else — an auth failure, a missing data + * source, a plugin with no health endpoint — is a genuine request failure. + */ + if (payload && typeof payload.status === 'string') { + return NextResponse.json({ + success: true, + output: { + status: payload.status, + message: typeof payload.message === 'string' ? payload.message : null, + ...(payload.details === undefined ? {} : { details: payload.details }), + }, + }) + } + + logger.warn(`[${requestId}] Grafana health check did not report a status (${response.status})`) + return NextResponse.json({ + success: false, + error: `Failed to check data source health: HTTP ${response.status} ${truncate( + raw, + MAX_ERROR_MESSAGE_LENGTH + )}`, + }) + } catch (error) { + logger.error(`[${requestId}] Error checking Grafana data source health:`, error) + return NextResponse.json({ success: false, error: getErrorMessage(error) }) + } +}) diff --git a/apps/sim/app/api/tools/grafana/update_alert_rule/route.ts b/apps/sim/app/api/tools/grafana/update_alert_rule/route.ts index 3d759919d42..e9a79380e0e 100644 --- a/apps/sim/app/api/tools/grafana/update_alert_rule/route.ts +++ b/apps/sim/app/api/tools/grafana/update_alert_rule/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { type NextRequest, NextResponse } from 'next/server' import { grafanaUpdateAlertRuleContract } from '@/lib/api/contracts/tools/grafana' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' @@ -17,6 +18,11 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GrafanaUpdateAlertRuleAPI') +/** Grafana is reached over two sequential hops, so each one needs its own bound. */ +const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 +/** Upstream error bodies can be a full HTML page; only a prefix is useful. */ +const MAX_ERROR_MESSAGE_LENGTH = 2000 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -64,7 +70,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { getHeaders['X-Grafana-Org-Id'] = params.organizationId } - const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}` + const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}` const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl') if (!getValidation.isValid || !getValidation.resolvedIP) { return NextResponse.json({ @@ -78,10 +84,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { method: 'GET', headers: getHeaders, maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + stripAuthOnRedirect: true, }) if (!getResponse.ok) { - const errorText = await getResponse.text() + const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH) return NextResponse.json({ success: false, output: {}, @@ -89,7 +97,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const existingRule = (await getResponse.json()) as any + const existingRule = (await getResponse.json()) as Record if (!existingRule || !existingRule.uid) { return NextResponse.json({ @@ -193,7 +201,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers['X-Disable-Provenance'] = 'true' } - const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}` + const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}` const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl') if (!urlValidation.isValid || !urlValidation.resolvedIP) { return NextResponse.json({ @@ -208,10 +216,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers, body: JSON.stringify(updatedRule), maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + stripAuthOnRedirect: true, }) if (!updateResponse.ok) { - const errorText = await updateResponse.text() + const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH) return NextResponse.json({ success: false, output: {}, diff --git a/apps/sim/app/api/tools/grafana/update_dashboard/route.ts b/apps/sim/app/api/tools/grafana/update_dashboard/route.ts index bb005bcf3f6..e7ceebf7f4a 100644 --- a/apps/sim/app/api/tools/grafana/update_dashboard/route.ts +++ b/apps/sim/app/api/tools/grafana/update_dashboard/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { type NextRequest, NextResponse } from 'next/server' import { grafanaUpdateDashboardContract } from '@/lib/api/contracts/tools/grafana' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' @@ -16,6 +17,11 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GrafanaUpdateDashboardAPI') +/** Grafana is reached over two sequential hops, so each one needs its own bound. */ +const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 +/** Upstream error bodies can be a full HTML page; only a prefix is useful. */ +const MAX_ERROR_MESSAGE_LENGTH = 2000 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -63,7 +69,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { getHeaders['X-Grafana-Org-Id'] = params.organizationId } - const getUrl = `${baseUrl}/api/dashboards/uid/${params.dashboardUid.trim()}` + const getUrl = `${baseUrl}/api/dashboards/uid/${encodeURIComponent(params.dashboardUid.trim())}` const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl') if (!getValidation.isValid || !getValidation.resolvedIP) { return NextResponse.json({ @@ -77,10 +83,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { method: 'GET', headers: getHeaders, maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + stripAuthOnRedirect: true, }) if (!getResponse.ok) { - const errorText = await getResponse.text() + const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH) return NextResponse.json({ success: false, output: {}, @@ -88,7 +96,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const existing = (await getResponse.json()) as any + /** + * `GET /api/dashboards/uid/:uid` answers `{dashboard, meta}`. Only the few + * fields this route reads are narrowed — the rest of the dashboard is + * arbitrary user JSON that is spread through untouched. + */ + const existing = (await getResponse.json()) as { + dashboard?: Record + meta?: { folderUid?: string } + } const existingDashboard = existing.dashboard const existingMeta = existing.meta @@ -100,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const updatedDashboard: Record = { + const updatedDashboard: Record = { ...existingDashboard, } @@ -131,7 +147,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { updatedDashboard.version = existingDashboard.version } - const body: Record = { + const body: Record = { dashboard: updatedDashboard, overwrite: params.overwrite === true, } @@ -169,10 +185,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers, body: JSON.stringify(body), maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + stripAuthOnRedirect: true, }) if (!updateResponse.ok) { - const errorText = await updateResponse.text() + const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH) return NextResponse.json({ success: false, output: {}, diff --git a/apps/sim/app/api/tools/grafana/update_folder/route.ts b/apps/sim/app/api/tools/grafana/update_folder/route.ts index 87e09b6c7a6..9623cef77fe 100644 --- a/apps/sim/app/api/tools/grafana/update_folder/route.ts +++ b/apps/sim/app/api/tools/grafana/update_folder/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { type NextRequest, NextResponse } from 'next/server' import { grafanaUpdateFolderContract } from '@/lib/api/contracts/tools/grafana' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' @@ -16,6 +17,11 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GrafanaUpdateFolderAPI') +/** Grafana is reached over two sequential hops, so each one needs its own bound. */ +const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 +/** Upstream error bodies can be a full HTML page; only a prefix is useful. */ +const MAX_ERROR_MESSAGE_LENGTH = 2000 + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -61,7 +67,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers['X-Grafana-Org-Id'] = params.organizationId } - const folderUrl = `${baseUrl}/api/folders/${params.folderUid.trim()}` + const folderUrl = `${baseUrl}/api/folders/${encodeURIComponent(params.folderUid.trim())}` const urlValidation = await validateUrlWithDNS(folderUrl, 'baseUrl') if (!urlValidation.isValid || !urlValidation.resolvedIP) { return NextResponse.json({ @@ -75,10 +81,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { method: 'GET', headers, maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + stripAuthOnRedirect: true, }) if (!getResponse.ok) { - const errorText = await getResponse.text() + const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH) return NextResponse.json({ success: false, output: {}, @@ -86,7 +94,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const existingFolder = (await getResponse.json()) as any + const existingFolder = (await getResponse.json()) as Record if (!existingFolder || !existingFolder.uid) { return NextResponse.json({ @@ -96,10 +104,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } + /** + * Grafana treats `version` and `overwrite` as alternatives: `version` is + * "not needed if overwrite=true". Sending both made the version we just + * fetched decorative and silently clobbered a concurrent rename, so only + * the version is sent and a conflicting edit surfaces as Grafana's 412 + * instead of being lost. + */ const body: Record = { - title: params.title ?? existingFolder.title, + title: params.title, version: existingFolder.version, - overwrite: true, } const updateResponse = await secureFetchWithPinnedIP(folderUrl, urlValidation.resolvedIP, { @@ -107,10 +121,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers, body: JSON.stringify(body), maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + timeout: OUTBOUND_FETCH_TIMEOUT_MS, + stripAuthOnRedirect: true, }) if (!updateResponse.ok) { - const errorText = await updateResponse.text() + const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH) return NextResponse.json({ success: false, output: {}, diff --git a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts index 56438618aa6..eb78c96325f 100644 --- a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts +++ b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts @@ -124,6 +124,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 'x-ms-file-name': validatedData.fileName, }, body: fileBuffer, + /** + * The tool's own `stripAuthOnRedirect` only covers the hop to this + * route. Dataverse redirects file operations to signed storage hosts, + * so this outbound call has to drop the bearer token itself or the + * redirect target receives a reusable OAuth credential. + */ + stripAuthOnRedirect: true, }, 'environmentUrl' ) diff --git a/apps/sim/app/api/tools/neo4j/utils.ts b/apps/sim/app/api/tools/neo4j/utils.ts index ac0bdf0eb0e..75df20798d6 100644 --- a/apps/sim/app/api/tools/neo4j/utils.ts +++ b/apps/sim/app/api/tools/neo4j/utils.ts @@ -62,33 +62,6 @@ export function validateCypherQuery(query: string): { isValid: boolean; error?: return { isValid: true } } -export function sanitizeLabelName(name: string): string { - if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { - throw new Error( - 'Invalid label name. Must start with a letter and contain only letters, numbers, and underscores.' - ) - } - return name -} - -export function sanitizePropertyKey(key: string): string { - if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { - throw new Error( - 'Invalid property key. Must start with a letter and contain only letters, numbers, and underscores.' - ) - } - return key -} - -export function sanitizeRelationshipType(type: string): string { - if (!/^[A-Z][A-Z0-9_]*$/.test(type)) { - throw new Error( - 'Invalid relationship type. Must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.' - ) - } - return type -} - export function convertNeo4jTypesToJSON(value: unknown): unknown { if (value === null || value === undefined) { return value diff --git a/apps/sim/app/api/tools/sftp/utils.ts b/apps/sim/app/api/tools/sftp/utils.ts index ea81b52793c..17ad9c57623 100644 --- a/apps/sim/app/api/tools/sftp/utils.ts +++ b/apps/sim/app/api/tools/sftp/utils.ts @@ -1,8 +1,13 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { toError } from '@sim/utils/errors' import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { readNodeStreamToBufferWithLimit } from '@/lib/core/utils/stream-limits' +const logger = createLogger('SftpUtils') + const S_IFMT = 0o170000 const S_IFDIR = 0o040000 const S_IFREG = 0o100000 @@ -15,9 +20,44 @@ export interface SftpConnectionConfig { password?: string | null privateKey?: string | null passphrase?: string | null + /** + * Idle socket timeout in ms, forwarded to ssh2's `sock.setTimeout`. Left + * unset the socket has no idle timeout at all (ssh2 defaults it to `0`). + */ timeout?: number keepaliveInterval?: number readyTimeout?: number + /** + * Expected SHA-256 host key fingerprint in the format `ssh-keyscan` and + * OpenSSH print (`SHA256:`). The `SHA256:` prefix and any base64 + * padding are optional. When set, a server presenting a different host key is + * rejected before authentication runs. When omitted, the host is not + * verified — ssh2's default behavior. + */ + hostFingerprint?: string | null +} + +/** + * Normalizes a user-supplied SHA-256 fingerprint for comparison: trims, drops + * an optional `SHA256:` prefix, and strips base64 `=` padding, which OpenSSH + * omits but copy/paste sources sometimes include. + */ +function normalizeSha256Fingerprint(value: string): string { + return value + .trim() + .replace(/^sha256:/i, '') + .replace(/=+$/, '') + .trim() +} + +/** + * Computes the OpenSSH SHA-256 fingerprint of a host key. ssh2 hands the + * verifier the raw SSH wire-format public key blob — the same bytes OpenSSH + * base64-encodes into `known_hosts` — so hashing it directly reproduces the + * unpadded base64 digest that `ssh-keyscan | ssh-keygen -lf -` prints. + */ +function computeHostKeyFingerprint(hostKey: Buffer): string { + return createHash('sha256').update(hostKey).digest('base64').replace(/=+$/, '') } /** @@ -93,6 +133,11 @@ function formatSftpError(err: Error, config: { host: string; port: number }): Er /** * Creates an SSH connection for SFTP using the provided configuration. * Uses ssh2 library defaults which align with OpenSSH standards. + * + * When `hostFingerprint` is supplied the server's host key is pinned to it and + * a mismatch aborts the handshake before any credential is sent. Without it + * ssh2 accepts whatever host key answers, which is the pre-existing behavior + * kept for backward compatibility. */ export async function createSftpConnection(config: SftpConnectionConfig): Promise { const host = config.host @@ -132,6 +177,50 @@ export async function createSftpConnection(config: SftpConnectionConfig): Promis if (config.keepaliveInterval !== undefined) { connectConfig.keepaliveInterval = config.keepaliveInterval } + if (config.timeout !== undefined) { + connectConfig.timeout = config.timeout + } + + const suppliedFingerprint = config.hostFingerprint?.trim() + const expectedFingerprint = suppliedFingerprint + ? normalizeSha256Fingerprint(suppliedFingerprint) + : undefined + + /** + * Fail closed rather than silently skipping verification. A value that is + * non-blank but normalizes away (`SHA256:`, `=`) would otherwise leave no + * `hostVerifier` installed, trusting whatever host answers — the opposite + * of what supplying a fingerprint asks for. + */ + if (suppliedFingerprint && !expectedFingerprint) { + throw new Error( + 'Host key fingerprint is not a valid SHA-256 fingerprint. Expected the base64 form printed by `ssh-keyscan | ssh-keygen -lf -`.' + ) + } + + /** + * Set when the pinned fingerprint does not match. ssh2 reports the + * rejection through a generic `'error'` event, so the precise cause is + * carried out of the verifier rather than re-derived from that message. + */ + let hostKeyRejection: Error | undefined + + if (expectedFingerprint) { + connectConfig.hostVerifier = (hostKey: Buffer): boolean => { + const actualFingerprint = computeHostKeyFingerprint(hostKey) + if (safeCompare(actualFingerprint, expectedFingerprint)) { + return true + } + hostKeyRejection = new Error( + `Host key verification failed for ${host}:${port}. ` + + `Expected SHA256:${expectedFingerprint} but the server presented SHA256:${actualFingerprint}. ` + + `Either the server's host key changed, or the connection was intercepted. ` + + `Re-run "ssh-keyscan -t rsa,ecdsa,ed25519 ${host}" to confirm the current key before updating the fingerprint.` + ) + logger.warn('SFTP host key fingerprint mismatch', { host, port }) + return false + } + } if (hasPrivateKey) { connectConfig.privateKey = config.privateKey! @@ -147,7 +236,21 @@ export async function createSftpConnection(config: SftpConnectionConfig): Promis }) client.on('error', (err) => { - reject(formatSftpError(err, { host, port })) + reject(hostKeyRejection ?? formatSftpError(err, { host, port })) + }) + + /** + * ssh2 only re-emits the socket's `'timeout'` event; it never destroys the + * socket, so without this the connection would sit open forever after the + * idle timeout elapsed. + */ + client.on('timeout', () => { + client.destroy() + reject( + new Error( + `Connection to ${host}:${port} timed out after ${config.timeout}ms of inactivity.` + ) + ) }) try { diff --git a/apps/sim/app/api/tools/ssh/utils.ts b/apps/sim/app/api/tools/ssh/utils.ts index 3d64440e22d..9f375ca2796 100644 --- a/apps/sim/app/api/tools/ssh/utils.ts +++ b/apps/sim/app/api/tools/ssh/utils.ts @@ -365,22 +365,6 @@ export function escapeShellArg(arg: string): string { return arg.replace(/'/g, "'\\''") } -/** - * Validate that authentication credentials are provided - */ -export function validateAuth(params: { password?: string; privateKey?: string }): { - isValid: boolean - error?: string -} { - if (!params.password && !params.privateKey) { - return { - isValid: false, - error: 'Either password or privateKey must be provided for authentication', - } - } - return { isValid: true } -} - /** * Parse file permissions from octal string */ diff --git a/apps/sim/app/api/tools/tts/unified/route.ts b/apps/sim/app/api/tools/tts/unified/route.ts index 80cc10db05b..f86d6332970 100644 --- a/apps/sim/app/api/tools/tts/unified/route.ts +++ b/apps/sim/app/api/tools/tts/unified/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { @@ -167,12 +168,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { modelId: body.modelId, voice: body.voice, language: body.language, - outputFormat: - body.outputFormat && - typeof body.outputFormat === 'object' && - !Array.isArray(body.outputFormat) - ? (body.outputFormat as CartesiaTtsParams['outputFormat']) - : undefined, + outputFormat: isRecordLike(body.outputFormat) + ? (body.outputFormat as CartesiaTtsParams['outputFormat']) + : undefined, speed: body.speed, emotion: body.emotion, }) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index a6062ca8eee..3113d85e8a9 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -48,19 +48,6 @@ export interface PaginationMeta { export const DEFAULT_LIMIT = 50 export const MAX_LIMIT = 250 -export function parsePaginationParams(url: URL): PaginationParams { - return { - limit: parsePaginationNumber(url.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT), - offset: parsePaginationNumber(url.searchParams.get('offset'), 0), - } -} - -function parsePaginationNumber(value: string | null, fallback: number, max?: number): number { - const parsed = value ? Number.parseInt(value, 10) : fallback - if (!Number.isInteger(parsed) || parsed < 1) return fallback - return max === undefined ? parsed : Math.min(parsed, max) -} - export function createPaginationMeta(total: number, limit: number, offset: number): PaginationMeta { return { total, diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts index ca4784712e4..11a5b6956c0 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -188,6 +188,56 @@ describe('/api/v2/custom-tools/[id]', () => { expect(mocks.update).not.toHaveBeenCalled() }) + /** + * The list omits a row it cannot project, so this surface must not answer the + * same row with a 500 — a caller who lists and sees nothing, then fetches by + * id and sees a server fault, can act on neither answer. Both surfaces say + * "not addressable here"; the recoveries (DELETE, or a PATCH carrying a valid + * schema) do not go through the projection and still work. + */ + describe('a stored row that cannot be projected onto the contract', () => { + const unrepairable = { ...tool, schema: 'this is not json' } + const repairable = { ...tool, schema: JSON.stringify(tool.schema) } + + it('answers a read with the same 404 the list implies by omitting it', async () => { + mocks.get.mockResolvedValue({ tool: unrepairable }) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Custom tool not found', + }) + }) + + it('answers a write with the same 404, leaving delete and a full-schema patch as the recoveries', async () => { + mocks.update.mockResolvedValue({ tool: unrepairable }) + expect( + (await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID, code: 'return 2' }), context)) + .status + ).toBe(404) + + mocks.remove.mockResolvedValue({ tool: unrepairable }) + expect((await DELETE(request('DELETE'), context)).status).toBe(200) + }) + + it('serves a repairable row on both single-resource verbs', async () => { + mocks.get.mockResolvedValue({ tool: repairable }) + const read = await GET(request('GET'), context) + expect(read.status).toBe(200) + expect((await read.json()).data.schema).toEqual(tool.schema) + + mocks.update.mockResolvedValue({ tool: repairable }) + const written = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, code: 'return 2' }), + context + ) + expect(written.status).toBe(200) + expect((await written.json()).data.schema).toEqual(tool.schema) + }) + }) + it('conceals cross-tenant access while preserving same-workspace role denials', async () => { mocks.get.mockRejectedValueOnce(new NoWorkspaceAccessError()) expect((await GET(request('GET'), context)).status).toBe(404) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts index 199c2930a07..bd654423142 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -15,13 +15,25 @@ import { getWorkspaceCustomToolUseCase, updateWorkspaceCustomToolUseCase, } from '@/lib/custom-tools/application/use-cases' -import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' +import { MalformedCustomToolRowError, toV2CustomTool } from '@/app/api/v2/custom-tools/utils' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +const NOT_FOUND_MESSAGE = 'Custom tool not found' + +/** + * Conceals cross-tenant denials, and answers a row that cannot be projected + * onto the contract with the same `404` — so this surface and the list, which + * omits such a row, tell one caller one story. See {@link toV2CustomTool}. + */ const customToolResourceErrorPolicy = createV2ResourceConcealmentPolicy({ - notFoundMessage: 'Custom tool not found', + notFoundMessage: NOT_FOUND_MESSAGE, + render: (error) => + error instanceof MalformedCustomToolRowError + ? v2Error('NOT_FOUND', NOT_FOUND_MESSAGE) + : v2CaughtOrchestrationError(error), }) /** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts index 3b32d07ccab..bc43d671e36 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -4,8 +4,26 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { +const { mocks, log, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { class MockV2ApiKeyUnauthenticatedError extends Error {} + /** + * The same surface `createMockLogger` provides, because this stub *replaces* + * the global `@sim/logger` mock for this file. A narrower one is not merely + * incomplete — the first module in this route's graph to call `logger.trace` + * or `logger.child` would throw `TypeError` here and nowhere else, which reads + * as a route bug rather than a missing mock method. `child`/`withMetadata` + * return the same instance so a chained call still records on `log`. + */ + const log: Record = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + } + log.child = vi.fn(() => log) + log.withMetadata = vi.fn(() => log) return { mocks: { authenticate: vi.fn(), @@ -15,10 +33,32 @@ const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { list: vi.fn(), create: vi.fn(), }, + log: log as { + info: ReturnType + warn: ReturnType + error: ReturnType + debug: ReturnType + trace: ReturnType + fatal: ReturnType + child: ReturnType + withMetadata: ReturnType + }, MockV2ApiKeyUnauthenticatedError, } }) +/** + * Overrides the global logger mock with one stable instance so the malformed-row + * warnings can be asserted — `createLogger` is called at module load, before any + * `beforeEach` could capture the per-call mock the global stub returns. + */ +vi.mock('@sim/logger', () => ({ + createLogger: () => log, + logger: log, + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, +})) + vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ authenticateV2ApiKey: mocks.authenticate, V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, @@ -230,6 +270,145 @@ describe('/api/v2/custom-tools', () => { expect(mocks.create).not.toHaveBeenCalled() }) + /** + * Both shapes below are real production rows. A single one of them used to + * throw out of the shared response validator and 500 the entire page, and + * because the list is keyset-paginated the caller could never page past it. + */ + describe('malformed stored rows', () => { + const malformed = (id: string, schema: unknown) => ({ ...tool, id, title: id, schema }) + + async function list() { + const response = await GET(request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}`)) + return { status: response.status, body: await response.json() } + } + + it('recovers a schema stored as a stringified JSON object', async () => { + mocks.list.mockResolvedValue({ + tools: [malformed('stringified', JSON.stringify(TOOL_SCHEMA)), tool], + }) + + const { status, body } = await list() + + expect(status).toBe(200) + expect(body.data.map((t: { id: string }) => t.id)).toEqual(['stringified', 'tool-1']) + expect(body.data[0].schema).toEqual(TOOL_SCHEMA) + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('Repaired'), + expect.objectContaining({ toolId: 'stringified', repairs: ['parsed-json-string'] }) + ) + }) + + it('recovers a declaration missing the `type` discriminator', async () => { + mocks.list.mockResolvedValue({ + tools: [malformed('no-type', { function: TOOL_SCHEMA.function }), tool], + }) + + const { status, body } = await list() + + expect(status).toBe(200) + expect(body.data.map((t: { id: string }) => t.id)).toEqual(['no-type', 'tool-1']) + expect(body.data[0].schema).toEqual(TOOL_SCHEMA) + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('Repaired'), + expect.objectContaining({ + toolId: 'no-type', + repairs: ['filled-function-discriminator'], + }) + ) + }) + + it('serves the rest of the page when a row is not safely repairable', async () => { + mocks.list.mockResolvedValue({ + tools: [ + malformed('unparseable', 'this is not json'), + malformed('no-parameters-type', { + type: 'function', + function: { name: 'x', parameters: { properties: {} } }, + }), + tool, + ], + }) + + const { status, body } = await list() + + expect(status).toBe(200) + expect(body.data.map((t: { id: string }) => t.id)).toEqual(['tool-1']) + for (const toolId of ['unparseable', 'no-parameters-type']) { + expect(log.error).toHaveBeenCalledWith( + expect.stringContaining('cannot be projected'), + expect.objectContaining({ toolId, workspaceId: WORKSPACE_ID }) + ) + } + }) + + /** + * The repair guards must be unreachable for a row that already validates — + * otherwise the recovery path could rewrite rows it was never meant to + * touch. Pinned on a stored schema carrying an unrelated extension key, so + * a repair that rebuilt the object rather than leaving it alone would show + * up as a lost field rather than passing on a shallow shape check. + */ + it('emits a valid row exactly as stored, with no repair applied', async () => { + const stored = { + ...TOOL_SCHEMA, + 'x-vendor': { owner: 'billing' }, + function: { ...TOOL_SCHEMA.function, description: 'Look up an order' }, + } + mocks.list.mockResolvedValue({ tools: [{ ...tool, schema: stored }] }) + + const { status, body } = await list() + + expect(status).toBe(200) + expect(body.data[0].schema).toEqual(stored) + expect(log.warn).not.toHaveBeenCalledWith( + expect.stringContaining('Repaired'), + expect.anything() + ) + expect(log.error).not.toHaveBeenCalledWith( + expect.stringContaining('cannot be projected'), + expect.anything() + ) + }) + + /** + * A page whose rows all skip returns `data: []` with a non-null + * `nextCursor`, so `nextCursor` — never page length — is this list's + * completeness signal. Pins that the documented client loop terminates and + * observes every projectable row across an all-skipped page. + */ + it('lets a client following nextCursor terminate and see every projectable row', async () => { + const second = { ...tool, id: 'tool-2', title: 'refund_order' } + const pages = [ + { tools: [tool, malformed('bad-1', 'this is not json')], nextCursorKeys: ['a', 'bad-1'] }, + { tools: [malformed('bad-2', 'this is not json')], nextCursorKeys: ['b', 'bad-2'] }, + { tools: [second] }, + ] + mocks.list.mockImplementation(async ({ input }) => + input.cursorKeys === undefined ? pages[0] : pages[input.cursorKeys[0] === 'a' ? 1 : 2] + ) + + const seen: string[] = [] + const pageSizes: number[] = [] + let cursor: string | null = null + do { + expect(pageSizes.length).toBeLessThan(pages.length) + const query = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' + const response = await GET( + request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}${query}`) + ) + expect(response.status).toBe(200) + const body = await response.json() + for (const t of body.data) seen.push(t.id) + pageSizes.push(body.data.length) + cursor = body.nextCursor + } while (cursor !== null) + + expect(pageSizes).toEqual([1, 0, 1]) + expect(seen).toEqual(['tool-1', 'tool-2']) + }) + }) + it('rejects invalid list sort fields before application execution', async () => { const response = await GET( request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&sortBy=invalid`) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 4cbfafb5492..8f0a9d2c65e 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -14,7 +14,7 @@ import { createWorkspaceCustomToolUseCase, listWorkspaceCustomToolsUseCase, } from '@/lib/custom-tools/application/use-cases' -import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' +import { toV2CustomTool, toV2CustomToolList } from '@/app/api/v2/custom-tools/utils' import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' @@ -46,7 +46,7 @@ export const GET = defineV2JsonRoute({ }), useCase: listWorkspaceCustomToolsUseCase, present: ({ tools, nextCursorKeys }, { query }) => ({ - data: tools.map(toV2CustomTool), + data: toV2CustomToolList(tools), nextCursor: writeSortedCursor( nextCursorKeys, query.sortBy, diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts index d3f1181a10e..e70c0c879dc 100644 --- a/apps/sim/app/api/v2/custom-tools/utils.ts +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -1,21 +1,183 @@ import type { customTools } from '@sim/db/schema' -import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' +import { createLogger } from '@sim/logger' +import { isPlainRecord } from '@sim/utils/object' +import { type V2CustomTool, v2CustomToolSchema } from '@/lib/api/contracts/v2/custom-tools' /** Shared serialization + error mapping for the v2 custom tool surface. */ +const logger = createLogger('V2CustomToolsSerialization') + type CustomToolRow = typeof customTools.$inferSelect /** - * Public custom tool projection. `workspaceId` and `userId` are internal - * scoping columns and are not exposed. + * A stored row whose `schema` column cannot be projected onto the public + * contract even after the safe repairs in {@link repairStoredSchema}. + * + * Thrown by {@link toV2CustomTool} and consumed by the single-resource route's + * error policy, which renders it as the same `404 Custom tool not found` the + * list surface implies by omitting the row. The identifying detail lives in the + * message and in the structured `error` log at the throw site, so this carries + * no fields of its own. */ -export function toV2CustomTool(row: CustomToolRow): V2CustomTool { - return { +export class MalformedCustomToolRowError extends Error { + constructor(toolId: string, reason: string) { + super(`Custom tool ${toolId} has a malformed stored schema: ${reason}`) + this.name = 'MalformedCustomToolRowError' + } +} + +/** Identity fields safe to log for locating a bad row. Never includes `code`. */ +function rowIdentity(row: CustomToolRow) { + return { toolId: row.id, workspaceId: row.workspaceId, title: row.title } +} + +/** + * Safe, information-preserving normalizations for a `schema` column that drifted + * from the contract shape. Both are hypotheses — nothing here is trusted; the + * result is still validated against the response contract before it is emitted, + * so a wrong guess can only downgrade a row to "skipped", never emit bad data. + * + * 1. A `schema` persisted as a JSON *string* is parsed. This is a pure encoding + * fix: the stored bytes already describe the right object. + * 2. A declaration missing the top-level `type` discriminator gets `'function'`. + * The contract types that field as `z.literal('function')`, so there is + * exactly one legal value and filling it invents no information. + * + * Neither branch can fire on a row that was already contract-valid: a valid + * `schema` is an object, never a string, and the contract types its top-level + * `type` as a required `z.literal('function')`, so it is never `undefined`. + * Both guards therefore only see shapes that had already failed validation, and + * repair can only turn a rejection into an acceptance — never alter a row that + * would have been emitted as stored. + * + * Deliberately NOT repaired: `function.parameters.type`, which the contract + * types as an open `z.string()`. Substituting `'object'` there would be a guess + * about JSON-Schema semantics that changes how a model calls the tool. + */ +function repairStoredSchema(stored: unknown): { value: unknown; repairs: string[] } { + const repairs: string[] = [] + let value = stored + + if (typeof value === 'string') { + try { + value = JSON.parse(value) + repairs.push('parsed-json-string') + } catch { + return { value: stored, repairs } + } + } + + if (isPlainRecord(value) && value.type === undefined && isPlainRecord(value.function)) { + value = { ...value, type: 'function' } + repairs.push('filled-function-discriminator') + } + + return { value, repairs } +} + +/** + * Projects a stored row onto the public contract, repairing what is safely + * repairable. Reports a reason instead when the row cannot be made + * contract-valid, and logs that failure here — one signal per defective row, so + * both surfaces raise it identically rather than each describing the row in its + * own words. + * + * `workspaceId` and `userId` are internal scoping columns and are not exposed. + */ +function projectV2CustomTool(row: CustomToolRow): { tool: V2CustomTool } | { reason: string } { + const { value, repairs } = repairStoredSchema(row.schema) + + const parsed = v2CustomToolSchema.safeParse({ id: row.id, title: row.title, - schema: row.schema as V2CustomTool['schema'], + schema: value, code: row.code, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), + }) + + if (!parsed.success) { + const reason = parsed.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; ') + logger.error('Custom tool row cannot be projected onto the v2 contract', { + ...rowIdentity(row), + reason, + }) + return { reason } } + + if (repairs.length > 0) { + logger.warn('Repaired a malformed stored custom tool schema', { + ...rowIdentity(row), + repairs, + }) + } + + return { tool: parsed.data } +} + +/** + * Public custom tool projection for single-resource surfaces (read, create, + * update). + * + * @throws {MalformedCustomToolRowError} when the row is not contract-valid. + * The single-resource routes render that as `404 Custom tool not found`, which + * is the same answer {@link toV2CustomToolList} gives by omitting the row. A + * `500` there would leave the two surfaces contradicting each other about one + * row — listed as absent, fetched as a server fault — and a caller could act on + * neither. `404` states the one thing that is true of the row on this API: it + * cannot be addressed here. It also stays actionable, because the recoveries + * do not go through this projection — `DELETE` removes the row, and a `PATCH` + * supplying a contract-valid `schema` repairs it and returns `200`. + */ +export function toV2CustomTool(row: CustomToolRow): V2CustomTool { + const result = projectV2CustomTool(row) + if ('reason' in result) throw new MalformedCustomToolRowError(row.id, result.reason) + return result.tool +} + +/** + * Public custom tool projection for the keyset-paginated list. + * + * Rows that stay malformed after repair are omitted rather than thrown. + * Throwing here fails the whole page, and because the list is keyset-paginated + * the caller cannot page past the bad row — every page containing it becomes + * permanently unreachable. An incomplete page is a real cost, but it is + * strictly smaller than no page at all. + * + * The page is deliberately **not** drained back up to the requested limit. A + * page whose rows all skip therefore returns `data: []` with a non-null + * `nextCursor`, which is safe because `nextCursor` — not page length — is this + * list's completeness signal: + * + * - `listWorkspaceCustomTools` reads `limit + 1` rows and `keysetPage` mints + * `nextCursorKeys` from the extra row, so `nextCursor` is null exactly when + * the keyset is exhausted, independent of anything this projection does. + * - Each page resumes strictly after the last row the previous page *read*, not + * the last row it *emitted*, so a skipped row still advances the cursor past + * itself and is never revisited. + * - A caller looping `while (nextCursor !== null)` therefore terminates in + * `ceil(rows / limit)` requests over any workspace and observes every + * projectable row exactly once — including rows that follow an all-skipped + * page. + * + * Draining instead would mean re-entering the authorized list use case from a + * presenter, which is the surface adapter re-reading protected data, and it + * would still need a bound — so a workspace of mostly-defective rows would + * return a short page anyway, just less predictably. The trap it removes is a + * caller looping on `data.length`, which this list has never been able to + * promise: the response carries no total, and page length has always been an + * artifact of the read rather than a statement about the keyset. + */ +export function toV2CustomToolList(rows: CustomToolRow[]): V2CustomTool[] { + const tools: V2CustomTool[] = [] + + for (const row of rows) { + const result = projectV2CustomTool(row) + if ('reason' in result) continue + tools.push(result.tool) + } + + return tools } diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index c947dff15a9..82b8deb787f 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -35,6 +35,7 @@ vi.mock('@/lib/users/queries', () => ({ })) import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' const WORKSPACE_ID = 'workspace-1' @@ -79,6 +80,15 @@ function buildRecord() { const callGet = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), context) +/** + * Stands in for the application use case's lifecycle predicate: a soft-deleted row only + * resolves when the caller opted into the archived set through `includeDeleted`. + */ +const archivedFileUseCase = async ({ input }: { input: { includeDeleted?: boolean } }) => { + if (!input.includeDeleted) throw new OrchestrationError('not_found', 'File not found') + return { file: { ...buildRecord(), deletedAt: new Date('2024-01-03T00:00:00Z') }, share: SHARE } +} + describe('GET /api/v2/files/[fileId]/metadata', () => { beforeEach(() => { vi.clearAllMocks() @@ -138,11 +148,96 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { }) expect(mocks.readMetadata).toHaveBeenCalledWith({ principal: auth.principal, - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false }, request: expect.anything(), }) }) + it('leaves an archived file unreachable when scope is omitted', async () => { + mocks.readMetadata.mockImplementation(archivedFileUseCase) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.readMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false }, + }) + ) + }) + + it('leaves an archived file unreachable under an explicit scope=active', async () => { + mocks.readMetadata.mockImplementation(archivedFileUseCase) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=active`) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + expect(mocks.readMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false }, + }) + ) + }) + + it('returns archived metadata when scope=archived opts into the archived set', async () => { + mocks.readMetadata.mockImplementation(archivedFileUseCase) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=archived`) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: '2024-01-03T00:00:00.000Z', + share: SHARE, + }, + }) + expect(mocks.readMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: true }, + }) + ) + }) + + it('still conceals an unauthorized archived read behind the same 404', async () => { + mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError()) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=archived`) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + /** + * `includeDeleted: true` is what makes this the *archived* read being + * concealed rather than the plain cross-workspace 404 the suite already + * pins: without it the request never reaches the archived set and the test + * proves only `NoWorkspaceAccessError → 404`. + */ + expect(mocks.readMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: true }, + }) + ) + }) + + it('rejects an unrecognized scope before reaching the use case', async () => { + const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=all`) + + expect(response.status).toBe(400) + expect(mocks.readMetadata).not.toHaveBeenCalled() + }) + it('returns a null share when the file has no share configuration', async () => { mocks.readMetadata.mockResolvedValueOnce({ file: buildRecord(), share: null }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 68d1420c541..722d125e4da 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -8,7 +8,15 @@ import { toV2File } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ +/** + * GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. + * + * `scope` mirrors the list endpoint's lifecycle selector: it defaults to `active`, and only an + * explicit `scope=archived` relaxes the soft-delete predicate on the row lookup so a caller can + * inspect an archived file before restoring it. Authorization is unaffected — the use case still + * resolves the canonical workspace context for the file and authorizes `files.read_metadata` + * against it either way. + */ export const GET = defineV2JsonRoute({ contract: v2GetFileContract, auth: v2ApiKeyAuth, @@ -18,6 +26,7 @@ export const GET = defineV2JsonRoute({ mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, + includeDeleted: query.scope === 'archived', }), useCase: readWorkspaceFileMetadata, present: async ({ file, share }) => ({ data: { ...(await toV2File(file)), share } }), diff --git a/apps/sim/app/api/v2/lib/response.test.ts b/apps/sim/app/api/v2/lib/response.test.ts index f84dd84a203..b45aa898340 100644 --- a/apps/sim/app/api/v2/lib/response.test.ts +++ b/apps/sim/app/api/v2/lib/response.test.ts @@ -2,7 +2,8 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { v2Error } from '@/app/api/v2/lib/response' +import { HttpError } from '@/lib/core/utils/http-error' +import { v2Error, v2HttpError, v2RateLimitError } from '@/app/api/v2/lib/response' describe('v2Error retry guidance', () => { it('sends Retry-After on 503 so a client does not retry a degraded dependency immediately', () => { @@ -46,3 +47,90 @@ describe('v2Error retry guidance', () => { expect(response.headers.get('Retry-After')).toBeNull() }) }) + +/** + * RFC 9110 §11.6.1 makes `WWW-Authenticate` a MUST on 401, and `v2Error` is the + * one funnel every v2 401 passes through — the missing key, the invalid key, an + * `unauthorized` orchestration failure, and the v1 middleware's auth result all + * render here. + */ +describe('v2 401 authentication challenge', () => { + /** + * Pinned exactly at the primary funnel rather than asserted as merely present. + * `toBeTruthy` accepts any string, so the scheme token, the realm, and the + * `header=` parameter that names the only channel v2 reads could all change + * without a test noticing. The reachability tests below stay loose on purpose + * — they pin that the header arrives down each path, not its value twice. + */ + const EXPECTED_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' + + const challenge = () => + v2Error('UNAUTHORIZED', 'API key required').headers.get('WWW-Authenticate') + + it('sends a challenge on 401', () => { + const response = v2Error('UNAUTHORIZED', 'Invalid API key') + + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBe(EXPECTED_CHALLENGE) + }) + + it('names the x-api-key header, the only channel v2 actually reads', () => { + expect(challenge()).toContain('x-api-key') + }) + + it('does not advertise a scheme v2 does not accept', () => { + const value = challenge() ?? '' + const scheme = value.split(' ')[0].toLowerCase() + + expect(scheme).not.toBe('bearer') + expect(scheme).not.toBe('basic') + expect(scheme).not.toBe('digest') + }) + + it('challenges on a 401 reached through the rate-limit auth result', () => { + const response = v2RateLimitError({ + allowed: false, + remaining: 0, + resetAt: new Date(), + limit: 0, + error: 'Invalid API key', + }) + + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBeTruthy() + }) + + it('challenges on a 401 reached through a typed HTTP error', () => { + class UnauthorizedError extends HttpError { + readonly statusCode = 401 + } + + const response = v2HttpError(new UnauthorizedError('Invalid API key')) + + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBeTruthy() + }) + + it('does not challenge on statuses that are not 401', () => { + for (const code of ['BAD_REQUEST', 'FORBIDDEN', 'NOT_FOUND', 'RATE_LIMITED'] as const) { + expect(v2Error(code, 'nope').headers.get('WWW-Authenticate')).toBeNull() + } + }) + + /** + * `options.headers` is spread *after* the challenge, so a caller-supplied + * `WWW-Authenticate` replaces the default rather than being ignored. That + * precedence is the whole reason the default is safe to install + * unconditionally on 401 — a route with a genuinely different challenge is + * not fighting it — but it also means an accidental override silently wins. + * Documented here so a reordering of the spread is a test failure either way. + */ + it('lets a caller-supplied challenge override the default', () => { + const response = v2Error('UNAUTHORIZED', 'Invalid token', { + headers: { 'WWW-Authenticate': 'Bearer realm="mcp"' }, + }) + + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBe('Bearer realm="mcp"') + }) +}) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 8f5f61215b5..c96763ac4e9 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -100,6 +100,28 @@ const RETRY_AFTER_SECONDS_BY_STATUS: Partial> = { 503: ADMISSION_RETRY_AFTER_SECONDS, } +/** + * The challenge every v2 `401` carries, so a 401 is a complete one. + * + * RFC 9110 §11.6.1 makes `WWW-Authenticate` a MUST on 401 — a 401 without it is + * a refusal that never says what would have been accepted, and a generic HTTP + * client has nothing to react to. + * + * The scheme name is deliberately Sim-specific rather than a registered one. + * v2 authenticates from the `x-api-key` header and accepts no `Authorization` + * scheme at all — `Authorization: Bearer ` is not a channel here — so + * `Bearer` and `Basic` would both be false advertising. `Basic` is worse than + * false: a browser reacts to it by opening a native credential prompt that + * cannot produce an API key. An unregistered scheme is what remains, and it is + * legal: §11.6.1's grammar requires *an* `auth-scheme` token, not a registered + * one. Every challenge implies "retry via `Authorization: …`" by + * construction, so the token is chosen to be one no client has a built-in + * handler for — the challenge surfaces to a human instead of triggering an + * automatic retry down a channel v2 does not read — and the real channel is + * named outright in the `header` parameter beside it. + */ +const V2_AUTH_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' + type RateLimitHeaderSource = Pick function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { @@ -183,6 +205,7 @@ export function v2Error( status, headers: { ...PRIVATE_NO_STORE, + ...(status === 401 ? { 'WWW-Authenticate': V2_AUTH_CHALLENGE } : {}), ...(retryAfterSeconds === undefined ? {} : { 'Retry-After': retryAfterSeconds.toString() }), ...options.headers, }, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.test.ts new file mode 100644 index 00000000000..12f0b1fbea5 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node + */ + +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cancel: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/workflows/application/cancel-run', () => ({ + cancelWorkflowRun: { operation: { id: 'workflows.runs.cancel' }, execute: mocks.cancel }, +})) + +import { POST } from '@/app/api/v2/workflows/[id]/runs/[runId]/cancel/route' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' +const RUN_ID = 'run-1' + +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'], + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const context = { params: Promise.resolve({ id: WORKFLOW_ID, runId: RUN_ID }) } + +function request() { + return new NextRequest( + `http://localhost:3000/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/cancel`, + { method: 'POST', headers: { 'x-api-key': 'secret' } } + ) +} + +/** The service result the use case hands back, minus the outcome under test. */ +function serviceResult(overrides: Record) { + return { + executionId: RUN_ID, + redisAvailable: true, + locallyAborted: false, + pausedCancelled: false, + workflowId: WORKFLOW_ID, + workspaceId: WORKSPACE_ID, + ...overrides, + } +} + +describe('POST /api/v2/workflows/[id]/runs/[runId]/cancel', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + }) + + it('reports a durable write when an active run is cancelled', async () => { + mocks.cancel.mockResolvedValue( + serviceResult({ success: true, durablyRecorded: true, reason: 'recorded' }) + ) + + const response = await POST(request(), context) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + success: true, + runId: RUN_ID, + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + }) + + /** + * The published outcome of a cancel against a run that had already finished. + * `durablyRecorded: true` here is the defect this suite pins: nothing was + * written, so a caller reconciling on that flag would trust a write that never + * happened. + */ + it.each([ + ['cancelled', 'already_cancelled'], + ['completed', 'already_completed'], + ['failed', 'already_failed'], + ])('reports a terminal %s run as a no-op the caller can tell apart', async (_status, reason) => { + mocks.cancel.mockResolvedValue(serviceResult({ success: true, durablyRecorded: false, reason })) + + const response = await POST(request(), context) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ + success: true, + runId: RUN_ID, + durablyRecorded: false, + reason, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts index 5418355124d..c492fe46957 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts @@ -27,8 +27,16 @@ export const POST = defineV2JsonRoute({ reason: result.reason, }, }), + /** + * Reports a cancellation, so it needs the run to have actually been + * cancelled. `success` alone no longer implies that: a cancel against an + * already-terminal run satisfies the request without writing anything, and + * reports `success: true` with `durablyRecorded: false`. Requiring both also + * keeps the event off a cancellation that reached the row but failed its + * paused reconciliation, which reports the inverse pair. + */ onSuccess: ({ principal, result }) => { - if (!result.success || principal.kind !== 'personal_api_key') return + if (!result.success || !result.durablyRecorded || principal.kind !== 'personal_api_key') return captureServerEvent( principal.userId, 'workflow_execution_cancelled', diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index f01e90861b0..16743d93a6a 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -53,14 +53,6 @@ interface ChatMessage { content: string } -function safeStringify(value: unknown): string { - try { - return JSON.stringify(value) - } catch { - return '[unserializable]' - } -} - /** * Wand enricher function type. * Enrichers add context to the system prompt based on generationType. diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index 84ba7ec5d3a..21c2fa13e9b 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -1010,6 +1010,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ success: true, + durablyRecorded: false, reason: 'already_cancelled', }) expect(mockCancelByExecution).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts new file mode 100644 index 00000000000..d2d5dac5045 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts @@ -0,0 +1,31 @@ +import { resendCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { resendCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: resendCredentialGroupEnrollmentContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.resendEnrollment, + rateLimit: internalRateLimits.none({ + reason: 'Credential Group invitation resends use a shared per-workspace delivery budget', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to resend credential group enrollment' + ), + async mapInput({ params }) { + await enforceCredentialGroupInvitationRouteRateLimit(params.id) + return { + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + enrollmentId: params.enrollmentId, + } + }, + useCase: resendCredentialGroupEnrollmentSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts new file mode 100644 index 00000000000..1a3fc67b10f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts @@ -0,0 +1,27 @@ +import { revokeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { revokeCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeCredentialGroupEnrollmentContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.revokeEnrollment, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group revocation behavior', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to revoke credential group enrollment' + ), + mapInput: ({ params }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + enrollmentId: params.enrollmentId, + }), + useCase: revokeCredentialGroupEnrollmentSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts new file mode 100644 index 00000000000..4e74e4d5e6b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getSession: vi.fn(), + rateLimit: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/credential-groups/application/manage-enrollments', () => ({ + inviteCredentialGroupEnrollmentsSettings: { + operation: { id: 'credential_groups.invites.send_batch' }, + execute: mocks.execute, + }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => { + class CredentialGroupInvitationRateLimitError extends Error { + readonly statusCode = 429 + + constructor( + readonly retryAfterSeconds: number, + readonly resetAt: Date + ) { + super('Rate limit exceeded') + } + } + return { + CredentialGroupInvitationRateLimitError, + enforceCredentialGroupInvitationRouteRateLimit: mocks.rateLimit, + } +}) + +import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit' +import { POST } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const GROUP_ID = 'group-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) } + +function createRequest(body: unknown): NextRequest { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/enrollments`, + { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + } + ) +} + +describe('credential group enrollment invitation route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.rateLimit.mockResolvedValue(undefined) + mocks.execute.mockResolvedValue({ + results: [{ email: 'alex@example.com', success: false, error: 'Delivery failed' }], + sentCount: 0, + failedCount: 1, + }) + }) + + it('authenticates before parsing the batch', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await POST(createRequest({}), context) + + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('sends the entire validated batch through one application command', async () => { + const body = { emails: ['alex@example.com', 'sam@example.com'] } + const request = createRequest(body) + + const response = await POST(request, context) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + assertedWorkspaceId: WORKSPACE_ID, + credentialGroupId: GROUP_ID, + emails: body.emails, + }, + request, + }) + expect(await response.json()).toMatchObject({ sentCount: 0, failedCount: 1 }) + }) + + it('rejects a batch larger than 100 before admission or delivery', async () => { + const response = await POST( + createRequest({ + emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`), + }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.rateLimit).not.toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('applies the shared workspace invitation rate limit', async () => { + mocks.rateLimit.mockRejectedValue( + new CredentialGroupInvitationRateLimitError(30, new Date('2026-08-14T12:00:00Z')) + ) + + const response = await POST(createRequest({ emails: ['alex@example.com'] }), context) + + expect(response.status).toBe(429) + expect(mocks.rateLimit).toHaveBeenCalledWith(WORKSPACE_ID) + expect(response.headers.get('retry-after')).toBe('30') + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts new file mode 100644 index 00000000000..455400482f1 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts @@ -0,0 +1,31 @@ +import { inviteCredentialGroupEnrollmentsContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: inviteCredentialGroupEnrollmentsContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.inviteBatch, + rateLimit: internalRateLimits.none({ + reason: 'Credential Group invitations use a shared per-workspace delivery budget', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to invite credential group enrollments' + ), + async mapInput({ params, body }) { + await enforceCredentialGroupInvitationRouteRateLimit(params.id) + return { + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + emails: body.emails, + } + }, + useCase: inviteCredentialGroupEnrollmentsSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts new file mode 100644 index 00000000000..66a6c609dc8 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts @@ -0,0 +1,63 @@ +import { + deleteCredentialGroupContract, + getCredentialGroupContract, + updateCredentialGroupContract, +} from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + deleteCredentialGroupSettings, + getCredentialGroupSettings, + updateCredentialGroupSettings, +} from '@/lib/credential-groups/application/manage-groups' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +const rateLimit = internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group detail behavior', +}) + +export const GET = defineInternalJsonRoute({ + contract: getCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.readSettings, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to get credential group'), + mapInput: ({ params, query }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + limit: query.limit, + cursor: query.cursor, + }), + useCase: getCredentialGroupSettings, +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updateCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.update, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update credential group'), + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + update: body, + }), + useCase: updateCredentialGroupSettings, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.delete, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'), + mapInput: ({ params }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + }), + useCase: deleteCredentialGroupSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts new file mode 100644 index 00000000000..21a9e594ede --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts @@ -0,0 +1,38 @@ +import { startSlackCredentialGroupConfigurationContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +const errorPolicy = extendInternalErrorPolicy( + createCredentialGroupInternalErrorPolicy('Failed to configure Slack for Credential Group'), + (error) => + error instanceof SlackManagedUsersError + ? internalErrorResponse(400, { error: error.message }) + : null +) + +export const POST = defineInternalJsonRoute({ + contract: startSlackCredentialGroupConfigurationContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.startSlackConfiguration, + rateLimit: internalRateLimits.none({ + reason: 'Slack applies provider authorization limits and setup requires a workspace admin', + }), + errorPolicy, + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + slackBotCredentialId: body.slackBotCredentialId, + clientId: body.clientId, + clientSecret: body.clientSecret, + }), + useCase: startSlackCredentialGroupConfiguration, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/error-policy.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/error-policy.ts new file mode 100644 index 00000000000..5e9d82df67d --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/error-policy.ts @@ -0,0 +1,43 @@ +import { + createInternalResourceConcealmentPolicy, + type InternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit' + +export function createCredentialGroupInternalErrorPolicy( + unhandledMessage: string, + notFoundMessage = 'Credential group not found' +): InternalErrorPolicy { + if (!unhandledMessage.trim()) { + throw new Error('Credential Group error policy requires an unhandled message') + } + const base: InternalErrorPolicy = { + project(error) { + if (error instanceof CredentialGroupProviderConfigurationError) { + return internalErrorResponse(503, { error: error.message }) + } + if (error instanceof CredentialGroupEnrollmentError) { + return internalErrorResponse(error.status, { error: error.message }) + } + if (error instanceof CredentialGroupInvitationRateLimitError) { + return internalErrorResponse( + 429, + { error: error.message, retryAfter: error.resetAt.getTime() }, + { + 'Retry-After': String(error.retryAfterSeconds), + 'X-RateLimit-Reset': error.resetAt.toISOString(), + } + ) + } + return internalOrchestrationErrorPolicy.project(error) + }, + unhandled() { + return internalErrorResponse(500, { error: unhandledMessage }) + }, + } + return createInternalResourceConcealmentPolicy({ base, notFoundMessage }) +} diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts new file mode 100644 index 00000000000..2bbb4e1b378 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + getSession: vi.fn(), + list: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ + createCredentialGroupSettings: { + operation: { id: 'credential_groups.create' }, + execute: mocks.create, + }, + listCredentialGroupSettings: { + operation: { id: 'credential_groups.settings.list' }, + execute: mocks.list, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function createRequest(method: 'GET' | 'POST', body?: Record): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, { + method, + ...(body + ? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } } + : {}), + }) +} + +describe('credential groups collection route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.list.mockResolvedValue({ credentialGroups: [] }) + }) + + it('authenticates before parsing the request body', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await POST(createRequest('POST', {}), context) + + expect(response.status).toBe(401) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('enters the application use case with the authenticated session principal', async () => { + const request = createRequest('GET') + const response = await GET(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentialGroups: [] }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + }) + + it('preserves concealed entitlement failures from the application boundary', async () => { + mocks.list.mockRejectedValue( + new OrchestrationError('not_found', 'Credential Groups are not available') + ) + + const response = await GET(createRequest('GET'), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Credential Groups are not available' }) + }) + + it('fails fast when managed Gmail OAuth is not configured', async () => { + mocks.create.mockRejectedValue( + new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured') + ) + + const response = await POST( + createRequest('POST', { + name: 'Support inboxes', + options: [{ provider: 'gmail', label: 'Gmail', required: true }], + }), + context + ) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: 'Managed Gmail authorization is not configured', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts new file mode 100644 index 00000000000..c776f985698 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts @@ -0,0 +1,45 @@ +import { + createCredentialGroupContract, + listCredentialGroupsContract, +} from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + createCredentialGroupSettings, + listCredentialGroupSettings, +} from '@/lib/credential-groups/application/manage-groups' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const GET = defineInternalJsonRoute({ + contract: listCredentialGroupsContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.listSettings, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group list behavior', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to list credential groups', + 'Workspace not found' + ), + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: listCredentialGroupSettings, +}) + +export const POST = defineInternalJsonRoute({ + contract: createCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.create, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group create behavior', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to create credential group', + 'Workspace not found' + ), + mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }), + useCase: createCredentialGroupSettings, +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx new file mode 100644 index 00000000000..7df36ad1d79 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx @@ -0,0 +1,27 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + chipVariants: () => 'chip', +})) + +import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' + +describe('OAuthConnectLink', () => { + it('presents enrollment authorization as Connect', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + + act(() => root.render()) + + const link = container.querySelector('a') + expect(link?.textContent).toBe('Connect') + expect(link?.getAttribute('href')).toBe('/oauth/start') + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx new file mode 100644 index 00000000000..f460446b24f --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx @@ -0,0 +1,15 @@ +'use client' + +import { chipVariants } from '@sim/emcn' + +interface OAuthConnectLinkProps { + href: string +} + +export function OAuthConnectLink({ href }: OAuthConnectLinkProps) { + return ( + + Connect + + ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx new file mode 100644 index 00000000000..b311fdaf86e --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx @@ -0,0 +1,74 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockError, mockSetOAuthStatus, mockSuccess } = vi.hoisted(() => ({ + mockError: vi.fn(), + mockSetOAuthStatus: vi.fn().mockResolvedValue(null), + mockSuccess: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + useToast: () => ({ + toast: { + error: mockError, + success: mockSuccess, + }, + }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [{}, mockSetOAuthStatus], +})) + +import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' + +function renderToast(variant: 'success' | 'error', message: string): Root { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + act(() => root.render()) + return root +} + +describe('CredentialGroupOAuthToast', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows OAuth success once and removes callback state from the URL', () => { + const root = renderToast('success', 'Gmail connected successfully.') + + expect(mockSuccess).toHaveBeenCalledOnce() + expect(mockSuccess).toHaveBeenCalledWith('Gmail connected successfully.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) + + it('uses the error toast and preserves unrelated query parameters', () => { + const root = renderToast('error', 'Authorization was canceled.') + + expect(mockError).toHaveBeenCalledWith('Authorization was canceled.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) + + it('removes the submitted state after showing the completion toast', () => { + const root = renderToast('success', 'Accounts submitted successfully.') + + expect(mockSuccess).toHaveBeenCalledWith('Accounts submitted successfully.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx new file mode 100644 index 00000000000..c3029508020 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useToast } from '@sim/emcn' +import { useQueryStates } from 'nuqs' +import { + credentialGroupEnrollmentStatusParsers, + credentialGroupEnrollmentStatusUrlKeys, +} from '@/app/credential-groups/enroll/[token]/search-params' + +interface CredentialGroupOAuthToastProps { + message: string + variant: 'success' | 'error' +} + +export function CredentialGroupOAuthToast({ message, variant }: CredentialGroupOAuthToastProps) { + const { toast } = useToast() + const [, setOAuthStatus] = useQueryStates( + credentialGroupEnrollmentStatusParsers, + credentialGroupEnrollmentStatusUrlKeys + ) + const shownRef = useRef(false) + + useEffect(() => { + if (shownRef.current) return + shownRef.current = true + + if (variant === 'success') toast.success(message) + else toast.error(message) + + void setOAuthStatus( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + }, [message, setOAuthStatus, toast, variant]) + + return null +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx new file mode 100644 index 00000000000..29e8ac35ac3 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -0,0 +1,191 @@ +import { type ReactNode, Suspense } from 'react' +import { Chip, ToastProvider } from '@sim/emcn' +import type { Metadata } from 'next' +import { headers } from 'next/headers' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { SupportFooter } from '@/app/(auth)/components' +import { LogoShell } from '@/app/(landing)/components' +import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' +import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +export const metadata: Metadata = { + title: 'Connect accounts', + robots: { index: false, follow: false }, +} + +export const dynamic = 'force-dynamic' + +interface CredentialGroupEnrollmentPageProps { + params: Promise<{ token: string }> + searchParams: Promise> +} + +interface PageShellProps { + children: ReactNode +} + +function PageShell({ children }: PageShellProps) { + return ( + + }> +
+ {children} +
+
+
+ ) +} + +function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) { + return ( + +
+

+ {rateLimited ? 'Too many requests' : 'Invitation unavailable'} +

+

+ {rateLimited + ? 'This link has been opened too many times. Wait a few minutes and try again.' + : 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'} +

+
+
+ ) +} + +const OAUTH_MESSAGES = { + denied: 'Authorization was canceled. Nothing was connected.', + account_mismatch: 'Choose the account matching the email address on this invitation.', + permissions_required: 'All requested permissions are required to connect this account.', + configuration_changed: 'This credential option changed. Reload the page and try again.', + rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', + incomplete: 'Connect every account before submitting.', + unavailable: 'Account authorization is temporarily unavailable. Please try again.', + failed: 'Account authorization did not complete. Please try again.', +} as const + +function getSearchParam( + searchParams: Record, + key: string +): string | undefined { + const value = searchParams[key] + return Array.isArray(value) ? value[0] : value +} + +export default async function CredentialGroupEnrollmentPage({ + params, + searchParams, +}: CredentialGroupEnrollmentPageProps) { + const requestHeaders = await headers() + const limited = await enforcePublicCredentialGroupIpRateLimit( + { headers: requestHeaders }, + 'metadata' + ) + if (limited) return + + const { token } = await params + if (!token || token.length > 128) return + + const principal = await authenticateCredentialGroupEnrollment(token) + if (!principal) return + const enrollmentResult = await readPublicCredentialGroupEnrollment + .execute({ principal, input: {} }) + .catch((error: unknown) => { + if (asOrchestrationError(error)?.code === 'not_found') return null + throw error + }) + if (!enrollmentResult) return + const { enrollment } = enrollmentResult + + const resolvedSearchParams = await searchParams + const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') + const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') + const submitted = getSearchParam(resolvedSearchParams, 'submitted') + const oauthMessage = + oauthStatus && oauthStatus in OAUTH_MESSAGES + ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] + : null + const activeOptions = enrollment.options.filter((option) => option.status === 'active') + const connectedOption = connectedOptionId + ? activeOptions.find((option) => option.id === connectedOptionId) + : undefined + const notification = submitted + ? { message: 'Accounts submitted successfully.', variant: 'success' as const } + : connectedOptionId + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null + const allConnected = + activeOptions.length > 0 && + activeOptions.every( + (option) => option.connections.length === 1 && option.connections[0]?.status === 'connected' + ) + + return ( + + {notification && ( + + + + )} +
+

+ Connect your accounts +

+

+ {enrollment.inviterName}{' '} + invited you to connect accounts for{' '} + {enrollment.workspaceName}. +

+
+ +
+ +
+ {activeOptions.map((option) => { + const ProviderIcon = getCredentialGroupProviderService(option.provider).icon + const connection = option.connections[0] + return ( + } + title={option.label} + description={connection?.email ?? 'Not connected'} + trailing={ + + } + /> + ) + })} +
+
+ {(allConnected || enrollment.status === 'completed') && ( +
+ + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/search-params.ts b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts new file mode 100644 index 00000000000..0b6411b0423 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts @@ -0,0 +1,13 @@ +import { parseAsString } from 'nuqs/server' + +/** One-shot OAuth result signals are nullable because absence means no toast. */ +export const credentialGroupEnrollmentStatusParsers = { + connected: parseAsString, + oauth: parseAsString, + submitted: parseAsString, +} as const + +export const credentialGroupEnrollmentStatusUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index b00b5bc043a..cfddbb4815c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -238,7 +238,10 @@ export function ToolCallItem({ return (
{BlockIcon && ( - + )} {isExecuting ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0562ce7c616..3dab7328451 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -14,6 +14,7 @@ import { toast, } from '@sim/emcn' import { TerminalWindow } from '@sim/emcn/icons' +import { isRecordLike } from '@sim/utils/object' import { useParams } from 'next/navigation' import { ThinkingLoader } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' @@ -353,22 +354,23 @@ export const SPECIAL_TAG_NAMES = [ 'question', ] as const -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function isOptionsItemData(value: unknown): value is OptionsItemData { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return typeof value.title === 'string' && typeof value.description === 'string' } +/** + * Arrays are accepted alongside keyed objects: an agent that emits + * `[{title,description},…]` still renders, with the array + * index standing in as the option key. + */ function isOptionsTagData(value: unknown): value is OptionsTagData { - if (!isRecord(value)) return false + if (!isRecordLike(value) && !Array.isArray(value)) return false return Object.values(value).every(isOptionsItemData) } function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return ( typeof value.reason === 'string' && typeof value.message === 'string' && @@ -378,7 +380,7 @@ function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData { } function isCredentialItemData(value: unknown): value is CredentialItemData { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false if ( typeof value.type !== 'string' || !(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type) @@ -452,7 +454,7 @@ export function parseLastCredentialTag(content: string): CredentialTagData | nul } function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return ( typeof value.message === 'string' && (value.code === undefined || typeof value.code === 'string') && @@ -461,7 +463,7 @@ function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagDa } function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceTagData { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false if ( typeof value.type !== 'string' || !(WORKSPACE_RESOURCE_TAG_TYPES as readonly string[]).includes(value.type) @@ -479,7 +481,7 @@ function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceT } function isQuestionOption(value: unknown): value is QuestionOption { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return typeof value.id === 'string' && typeof value.label === 'string' } @@ -497,7 +499,7 @@ const SELF_PROVIDED_OPTION_LABELS = new Set([ ]) function isQuestionItem(value: unknown): value is QuestionItem { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false if ( typeof value.type !== 'string' || !(QUESTION_TYPES as readonly string[]).includes(value.type) @@ -551,7 +553,7 @@ function recoverQuestionPrompts(body: string): string | null { const parsed = JSON.parse(body) as unknown const items = Array.isArray(parsed) ? parsed : [parsed] const prompts = items - .filter(isRecord) + .filter(isRecordLike) .map((item) => (typeof item.prompt === 'string' ? item.prompt.trim() : '')) .filter((prompt) => prompt.length > 0) return prompts.length > 0 ? prompts.join('\n\n') : null diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts index 02ca681bf37..1ae960be93b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts @@ -7,6 +7,7 @@ import { useCallback, useRef, } from 'react' +import { isRecordLike } from '@sim/utils/object' import { useQueryClient } from '@tanstack/react-query' import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' @@ -32,9 +33,7 @@ interface FilePreviewControllerDeps { } function asPayloadRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined + return isRecordLike(value) ? (value as Record) : undefined } /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 6ac6b32e3f3..9139fe504ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -1,4 +1,4 @@ -import { isRecordLike as isRecord } from '@sim/utils/object' +import { isRecordLike } from '@sim/utils/object' import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' import { MothershipStreamV1CompletionStatus, @@ -220,10 +220,10 @@ function rebindResolvedIntegrationCall(node: ToolNode, toolName: string): void { /** * Reads a wire event payload as a generic record. The payload is a wide * discriminated union; the reducer accesses fields uniformly, so this narrows - * through the `unknown`-typed {@link isRecord} guard rather than a double cast. + * through the `unknown`-typed {@link isRecordLike} guard rather than a double cast. */ function payloadRecord(payload: unknown): Record { - return isRecord(payload) ? payload : {} + return isRecordLike(payload) ? payload : {} } /** Parses a wire `ts` to epoch ms, or undefined when absent/unparseable. */ @@ -523,7 +523,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // back into an ordinary running row without waiting for the result. node.status = 'running' } - if (isRecord(payload.arguments)) node.args = payload.arguments + if (isRecordLike(payload.arguments)) node.args = payload.arguments // Only the snapshot-replay path (contentBlocksToModel) carries this // field — the live wire never does; it restores the rebound gateway // description across a preserve-state rebuild. @@ -531,7 +531,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (restoredDescription) node.integrationDescription = restoredDescription // Tool-call titles are derived from the tool name (+args) at serialize // time; the stream only carries behavioral flags now. - const ui = isRecord(payload.ui) ? payload.ui : undefined + const ui = isRecordLike(payload.ui) ? payload.ui : undefined if (ui?.hidden === true) node.hidden = true } else if (phase === MothershipStreamV1ToolPhase.args_delta) { const node = upsertToolNode( @@ -559,7 +559,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve case MothershipStreamV1EventType.span: { const payload = envelope.payload if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) break - const data = isRecord(payload.data) ? payload.data : undefined + const data = isRecordLike(payload.data) ? payload.data : undefined const triggerToolCallId = scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' @@ -686,7 +686,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const payload = payloadRecord(envelope.payload) // An async pause is not a turn terminal — the paused tools/subagents // legitimately stay open until a later resume leg completes them. - const response = isRecord(payload.response) ? payload.response : undefined + const response = isRecordLike(payload.response) ? payload.response : undefined if (response && 'async_pause' in response) break const status = payload.status if (status === MothershipStreamV1CompletionStatus.cancelled) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 705746d348f..a547065ff13 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -82,6 +82,9 @@ export function AddConnectorModal({ const connectorConfig = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : null const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey' + /** True when the connector declares its key optional (public sources need none). */ + const isApiKeyOptional = + connectorConfig?.auth.mode === 'apiKey' && connectorConfig.auth.optional === true const connectorProviderId = useMemo( () => connectorConfig && connectorConfig.auth.mode === 'oauth' @@ -160,7 +163,7 @@ export function AddConnectorModal({ const canSubmit = useMemo(() => { if (!connectorConfig) return false if (isApiKeyMode) { - if (!apiKeyValue.trim()) return false + if (!isApiKeyOptional && !apiKeyValue.trim()) return false } else { if (!effectiveCredentialId) return false } @@ -174,6 +177,7 @@ export function AddConnectorModal({ }, [ connectorConfig, isApiKeyMode, + isApiKeyOptional, apiKeyValue, effectiveCredentialId, isFieldVisible, @@ -207,7 +211,11 @@ export function AddConnectorModal({ { knowledgeBaseId, connectorType: selectedType, - ...(isApiKeyMode ? { apiKey: apiKeyValue } : { credentialId: effectiveCredentialId! }), + ...(isApiKeyMode + ? apiKeyValue.trim() + ? { apiKey: apiKeyValue } + : {} + : { credentialId: effectiveCredentialId! }), sourceConfig: finalSourceConfig, syncIntervalMinutes: syncInterval, }, 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 8db6e3e7c87..449e287a438 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 @@ -358,27 +358,22 @@ function ConnectorCard({ >
-
-
- {Icon && ( - - )} -
- {connector.status === 'disabled' && ( - +
+ {Icon && ( + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index b3dc95416bb..7b05e1dcf23 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -135,21 +135,3 @@ export function getDisplayName(span: TraceSpan): string { export function formatCostAmount(value: number | undefined): string | undefined { return formatCreditCost(value, { emptyForZeroOrLess: true }) } - -export function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined { - if (!tokens) return undefined - const parts: string[] = [] - const input = formatTokenCount(tokens.input) - const output = formatTokenCount(tokens.output) - const total = formatTokenCount(tokens.total) - const cacheRead = formatTokenCount(tokens.cacheRead) - const cacheWrite = formatTokenCount(tokens.cacheWrite) - const reasoning = formatTokenCount(tokens.reasoning) - if (input) parts.push(`${input} in`) - if (cacheRead) parts.push(`${cacheRead} cached`) - if (cacheWrite) parts.push(`${cacheWrite} cache write`) - if (output) parts.push(`${output} out`) - if (reasoning) parts.push(`${reasoning} reasoning`) - if (total) parts.push(`${total} total`) - return parts.length > 0 ? parts.join(' · ') : undefined -} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts index 8c2c4661122..9a132b10f15 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts @@ -36,12 +36,6 @@ export interface Recurrence { cron?: string } -export const DEFAULT_RECURRENCE: Recurrence = { - frequency: 'once', - weekdays: [], - end: { type: 'never' }, -} - /** Upper bound on occurrences materialized for one schedule in a single view. */ const MAX_OCCURRENCES_PER_VIEW = 500 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx new file mode 100644 index 00000000000..9b54c76899d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCanOpenOrganizationSettingsSection, + mockGetSession, + mockGetWorkspaceHostContext, + mockHasWorkspaceInboxAccess, + mockHasWorkspaceSandboxAccess, + mockIsForkingAvailable, + mockIsOrganizationOnEnterprisePlan, + mockIsOrganizationSettingsSectionAvailable, + mockNotFound, + mockRedirect, + mockResolveWorkspaceGroup, + mockResolveWorkspaceNavigation, +} = vi.hoisted(() => ({ + mockCanOpenOrganizationSettingsSection: vi.fn(), + mockGetSession: vi.fn(), + mockGetWorkspaceHostContext: vi.fn(), + mockHasWorkspaceInboxAccess: vi.fn(), + mockHasWorkspaceSandboxAccess: vi.fn(), + mockIsForkingAvailable: vi.fn(), + mockIsOrganizationOnEnterprisePlan: vi.fn(), + mockIsOrganizationSettingsSectionAvailable: vi.fn(), + mockNotFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND') + }), + mockRedirect: vi.fn((href: string) => { + throw new Error(`NEXT_REDIRECT:${href}`) + }), + mockResolveWorkspaceGroup: vi.fn(), + mockResolveWorkspaceNavigation: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + notFound: mockNotFound, + redirect: mockRedirect, +})) + +vi.mock('@/components/settings/navigation', () => ({ + getOrganizationSettingsFeatures: vi.fn(() => ({})), + isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable, + resolveWorkspaceNavigation: mockResolveWorkspaceNavigation, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess, + hasWorkspaceSandboxAccess: mockHasWorkspaceSandboxAccess, +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: {}, + getEnv: vi.fn(), + isTruthy: vi.fn(() => false), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isAppConfigEnabled: false, + isBillingEnabled: true, + isHosted: true, +})) + +vi.mock('@/lib/organizations/settings-access', () => ({ + canOpenOrganizationSettingsSection: mockCanOpenOrganizationSettingsSection, +})) + +vi.mock('@/lib/permissions/super-user', () => ({ + isPlatformAdmin: vi.fn(() => false), +})) + +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mockGetWorkspaceHostContext, +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({ + allNavigationItems: [{ id: 'general' }, { id: 'billing' }, { id: 'secrets' }, { id: 'sessions' }], + getSettingsSectionMeta: vi.fn(() => null), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveWorkspaceGroup: mockResolveWorkspaceGroup, +})) + +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ + isForkingAvailableForWorkspace: mockIsForkingAvailable, +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({ + prefetchGeneralSettings: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({ + SettingsPage: vi.fn(() => null), +})) + +import WorkspaceSettingsSectionPage from '@/app/workspace/[workspaceId]/settings/[section]/page' + +const PERSONAL_HOST_CONTEXT = { + workspace: { + id: 'workspace-b', + billedAccountUserId: 'owner-b', + }, + hostOrganizationId: null, + ownerBilling: { + isEnterprise: false, + }, + viewer: { + permission: 'admin', + isHostOrganizationAdmin: false, + }, +} + +function pageProps(section: string) { + return { + params: Promise.resolve({ workspaceId: 'workspace-b', section }), + } +} + +describe('WorkspaceSettingsSectionPage unavailable sections', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } }) + mockGetWorkspaceHostContext.mockResolvedValue(PERSONAL_HOST_CONTEXT) + mockResolveWorkspaceNavigation.mockReturnValue([]) + mockResolveWorkspaceGroup.mockResolvedValue(null) + mockIsForkingAvailable.mockResolvedValue(false) + mockHasWorkspaceInboxAccess.mockResolvedValue(false) + mockHasWorkspaceSandboxAccess.mockResolvedValue(false) + mockCanOpenOrganizationSettingsSection.mockResolvedValue(false) + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + mockIsOrganizationSettingsSectionAvailable.mockReturnValue(true) + }) + + it('redirects an unavailable subscription section to General', async () => { + await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow( + 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' + ) + }) + + it('redirects a workspace section hidden in the destination workspace to General', async () => { + await expect(WorkspaceSettingsSectionPage(pageProps('secrets'))).rejects.toThrow( + 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' + ) + }) + + it('redirects an organization section when the destination has no organization', async () => { + await expect(WorkspaceSettingsSectionPage(pageProps('sessions'))).rejects.toThrow( + 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' + ) + }) + + it('keeps unknown settings sections fail-fast', async () => { + await expect(WorkspaceSettingsSectionPage(pageProps('unknown'))).rejects.toThrow( + 'NEXT_NOT_FOUND' + ) + expect(mockGetWorkspaceHostContext).not.toHaveBeenCalled() + }) + + it('keeps inaccessible workspaces fail-fast', async () => { + mockGetWorkspaceHostContext.mockResolvedValue(null) + + await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow( + 'NEXT_NOT_FOUND' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 22d0fbc4f9a..1a64d6206a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -14,6 +14,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import { hasWorkspaceInboxAccess, hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isPlatformAdmin } from '@/lib/permissions/super-user' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -48,6 +49,7 @@ const TOP_LEVEL_REDIRECTS: Readonly stri const WORKSPACE_SECTION_MAP: Partial> = { teammates: 'teammates', secrets: 'secrets', + 'credential-groups': 'credential-groups', byok: 'byok', sandboxes: 'sandboxes', 'custom-tools': 'custom-tools', @@ -67,6 +69,7 @@ const ORGANIZATION_SECTION_MAP: Partial { @@ -109,14 +120,16 @@ export default async function WorkspaceSettingsSectionPage({ const workspaceSection = WORKSPACE_SECTION_MAP[parsed] if (workspaceSection) { - const [permissionGroup, forksAvailable, inboxAvailable, sandboxes] = await Promise.all([ - hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise - ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) - : null, - isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id), - hasWorkspaceInboxAccess(workspaceId), - hasWorkspaceSandboxAccess(workspaceId), - ]) + const [permissionGroup, forksAvailable, inboxAvailable, sandboxes, credentialGroupsAvailable] = + await Promise.all([ + hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise + ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) + : null, + isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id), + hasWorkspaceInboxAccess(workspaceId), + hasWorkspaceSandboxAccess(workspaceId), + isCredentialGroupsAvailable(hostContext.ownerBilling), + ]) const customBlocksAvailable = isHosted ? hostContext.ownerBilling.isEnterprise : isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED')) @@ -125,26 +138,31 @@ export default async function WorkspaceSettingsSectionPage({ permissionConfig: permissionGroup?.config ?? {}, entitlements: { byok: isHosted, + credentialGroups: credentialGroupsAvailable, inbox: inboxAvailable, customBlocks: customBlocksAvailable, forks: forksAvailable, sandboxes, }, }) - if (!navigation.some((item) => item.id === workspaceSection)) notFound() + if (!navigation.some((item) => item.id === workspaceSection)) { + redirectToGeneralSettings(workspaceId) + } } const organizationSection = ORGANIZATION_SECTION_MAP[parsed] if (organizationSection) { if (!isBillingEnabled && (parsed === 'billing' || parsed === 'organization')) { - redirect(`/workspace/${workspaceId}/settings/general`) + redirectToGeneralSettings(workspaceId) } if (!hostContext.hostOrganizationId) { if (parsed !== 'billing' || hostContext.workspace.billedAccountUserId !== session.user.id) { - notFound() + redirectToGeneralSettings(workspaceId) } } else { - if (!hostContext.viewer.isHostOrganizationAdmin) notFound() + if (!hostContext.viewer.isHostOrganizationAdmin) { + redirectToGeneralSettings(workspaceId) + } if ( !(await canOpenOrganizationSettingsSection( hostContext.hostOrganizationId, @@ -152,7 +170,7 @@ export default async function WorkspaceSettingsSectionPage({ organizationSection )) ) { - notFound() + redirectToGeneralSettings(workspaceId) } const hasEnterprisePlan = organizationSection !== 'members' && @@ -164,7 +182,7 @@ export default async function WorkspaceSettingsSectionPage({ getOrganizationSettingsFeatures(hasEnterprisePlan) ) ) { - notFound() + redirectToGeneralSettings(workspaceId) } } } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index bde90b3f029..3f85cdc3d8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -80,6 +80,30 @@ export const groupIdUrlKeys = { clearOnDefault: true, } as const +/** `credential-group-id` deep-links Credential Groups to one collection's detail view. */ +export const credentialGroupIdParam = { + key: 'credential-group-id', + parser: parseAsString, +} as const + +/** Opening a credential group is a destination; closing replaces the detail URL. */ +export const credentialGroupIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** Active view inside a credential-group detail page. */ +export const credentialGroupTabParam = { + key: 'credential-group-tab', + parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const credentialGroupTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + /** * `group-tab` is the active tab inside the deep-linked permission-group detail * view, so a shared `group-id` link can land on the same tab (mirrors diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 632e818dec1..9ba369ef92a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -84,6 +84,9 @@ const AccessControl = dynamic(() => const CustomBlocks = dynamic(() => import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks) ) +const CredentialGroups = dynamic(() => + import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings) +) const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) @@ -158,6 +161,9 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'browser' && } {effectiveSection === 'terminal' && } {effectiveSection === 'secrets' && } + {effectiveSection === 'credential-groups' && ( + + )} {effectiveSection === 'access-control' && organizationId && ( { { id: 'teammates', label: 'Teammates', section: 'workspace' }, { id: 'organization', label: 'Members', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, + { id: 'credential-groups', label: 'Credential groups', section: 'workspace' }, { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, { id: 'mcp', label: 'MCP tools', section: 'workspace' }, { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, @@ -68,6 +69,7 @@ describe('unified settings navigation', () => { expect(idsForSection('workspace')).toEqual([ 'teammates', 'secrets', + 'credential-groups', 'mcp', 'custom-tools', 'byok', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index dd89bcd56a4..1b5ebe5ce06 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -11,6 +11,7 @@ import { Blimp, Duplicate, Eye, + ListFilter, Pencil, PlayOutline, RefreshCw, @@ -37,6 +38,12 @@ interface ContextMenuProps { onViewExecution?: () => void canViewExecution?: boolean canEditCell?: boolean + /** + * Narrows the table to rows whose cell in this column reads the same as the + * one under the cursor. Omit when the cell cannot be expressed as a filter + * (a structured value, or an operator its column type rejects). + */ + onFilterByCellValue?: () => void selectedRowCount?: number /** Fires every workflow group on the row(s), skipping already-completed * cells. Mirrors the action bar's Play. */ @@ -91,6 +98,7 @@ export function ContextMenu({ onViewExecution, canViewExecution = false, canEditCell = true, + onFilterByCellValue, selectedRowCount = 1, onRunWorkflows, onRefreshWorkflows, @@ -175,6 +183,15 @@ export function ContextMenu({ Edit cell )} + {/* Cell-scoped like Edit cell above it, and a read action every viewer + can take — deliberately not gated on `disableEdit`. The grid only + supplies the handler for a cell that has a filter to offer. */} + {onFilterByCellValue && ( + + + Filter by cell value + + )} {/* Run, Re-run, Stop, then View execution — the order the action bar presents the same four, so the user reads one sequence in both. diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index d18e69cbc14..74b6e297b7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { ChevronDown } from '@sim/emcn/icons' -import type { WorkflowGroup } from '@/lib/table' +import type { SortDirection, WorkflowGroup } from '@/lib/table' import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { COL_WIDTH, SELECTION_TINT_BG } from '../constants' @@ -42,6 +42,10 @@ interface ColumnHeaderMenuProps { /** Opens a popup preview of the column's underlying workflow. Surfaced in * the chevron menu for workflow-output columns. */ onViewWorkflow?: (workflowId: string) => void + onSortColumn?: (columnId: string, direction: SortDirection) => void + onClearSort?: () => void + /** This column's active sort direction. Absent when another column owns the sort. */ + sortDirection?: SortDirection /** Whether this column is currently pinned to the left. */ isPinned?: boolean /** Toggle the pinned state for this column. */ @@ -84,6 +88,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ sourceInfo, onOpenConfig, onViewWorkflow, + onSortColumn, + onClearSort, + sortDirection, isPinned, onPinToggle, stickyLeft, @@ -343,6 +350,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onViewWorkflow={ onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined } + onSortColumn={onSortColumn} + onClearSort={onClearSort} + sortDirection={sortDirection} isPinned={isPinned} onPinToggle={onPinToggle} /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index cf495235221..7d680610643 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -14,8 +14,10 @@ import { DropdownMenuTrigger, } from '@sim/emcn' import { + ArrowDown, ArrowLeft, ArrowRight, + ArrowUp, Eye, EyeOff, Pencil, @@ -24,9 +26,10 @@ import { PlayOutline, Trash, Workflow, + X, } from '@sim/emcn/icons' import type { RunLimit, RunMode } from '@/lib/api/contracts/tables' -import type { WorkflowGroupType } from '@/lib/table' +import type { SortDirection, WorkflowGroupType } from '@/lib/table' import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label' import { getEnrichment } from '@/enrichments/registry' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -84,6 +87,15 @@ interface ColumnOptionsMenuProps { /** When set, the menu surfaces a "View workflow" item that opens a popup * preview of the configured workflow. */ onViewWorkflow?: () => void + /** Sorts the table by this column. Omit to hide the sort items — the + * workflow-group meta header spans several columns, so there is no single + * column for it to sort by. */ + onSortColumn?: (columnId: string, direction: SortDirection) => void + /** Clears the sort. Only rendered while {@link ColumnOptionsMenuProps.sortDirection} + * says this column owns it. */ + onClearSort?: () => void + /** This column's active sort direction. Absent when it is not the sorted one. */ + sortDirection?: SortDirection /** Whether this column is currently pinned to the left. */ isPinned?: boolean /** Toggle the pinned state of this column. */ @@ -115,6 +127,9 @@ export function ColumnOptionsMenu({ selectedRowCount = 0, hasActiveFilter = false, onViewWorkflow, + onSortColumn, + onClearSort, + sortDirection, isPinned, onPinToggle, }: ColumnOptionsMenuProps) { @@ -174,6 +189,37 @@ export function ColumnOptionsMenu({ )} + {/* Sort leads the column-scoped block: the options bar reads Filter · + Sort · Columns, and this menu carries no Filter item, so Sort is the + first of that set to appear — a column-scoped Filter item added later + belongs ABOVE it. Direction words, not "A to Z": the same items sort + dates and numbers, and the options-bar Sort menu already speaks + ascending/descending. */} + {onSortColumn && ( + <> + {sortDirection && onClearSort && ( + + + Clear sort + + )} + onSortColumn(column.key, 'asc')} + > + + Sort ascending + + onSortColumn(column.key, 'desc')} + > + + Sort descending + + + + )} {onViewWorkflow && ( onViewWorkflow()}> 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 3471f73e419..59222045098 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 @@ -15,6 +15,8 @@ import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection- import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, + Predicate, + SortDirection, TableLocks, TableMetadata, TablePredicate, @@ -24,6 +26,7 @@ import type { 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 { 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' @@ -91,6 +94,7 @@ const logger = createLogger('TableView') const EMPTY_RUNNING_BY_ROW: Readonly> = Object.freeze({}) const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([]) +const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([]) const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 @@ -238,6 +242,14 @@ interface TableGridProps { onSelectionChange: (state: SelectionSnapshot) => void /** Filter + sort. Lifted to wrapper so a single `useTable` call serves both. */ queryOptions: QueryOptions + /** + * Narrows the active filter with the conditions matching one cell's value + * ("Filter by cell value"). The wrapper owns the filter, so the grid only + * reports the conditions the clicked cell produced. + */ + onFilterByCellValue?: (conditions: readonly Predicate[]) => void + onSortColumn?: (columnId: string, direction: SortDirection) => void + onClearSort?: () => void /** * **Column ids** to hide from the grid. Owned by the wrapper because the filter * panel's Columns section edits the same list and the active view persists it. @@ -438,6 +450,9 @@ export function TableGrid({ onStopRow, onSelectionChange, queryOptions, + onFilterByCellValue, + onSortColumn, + onClearSort, hiddenColumns, viewLayout, viewLayoutKey = null, @@ -556,6 +571,9 @@ export function TableGrid({ filter: effectiveFilter, } = useTable({ workspaceId, tableId, queryOptions }) + /** Sort is single-column, so only the first spec entry can be active. */ + const activeSort = queryOptions.sort?.[0] + const { data: tableRunState } = useTableRunState(tableId) const activeDispatches = tableRunState?.dispatches const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW @@ -1203,16 +1221,21 @@ export function TableGrid({ [] ) + /** The right-clicked cell's column. One lookup shared by every menu item that + * needs it, rather than a scan per item. */ + const contextMenuColumn = contextMenu.columnName + ? columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName) + : undefined + function handleContextMenuEditCell() { if (contextMenu.row && contextMenu.columnName) { - const column = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName) - if (column && columnTypeOf(column).editor === 'toggle') { + if (contextMenuColumn && columnTypeOf(contextMenuColumn).editor === 'toggle') { toggleBooleanCell( contextMenu.row.id, contextMenu.columnName, contextMenu.row.data[contextMenu.columnName] ) - } else if (column) { + } else if (contextMenuColumn) { setEditingCell({ rowId: contextMenu.row.id, columnName: contextMenu.columnName }) setInitialCharacter(null) } @@ -1220,6 +1243,21 @@ export function TableGrid({ closeContextMenu() } + /** Conditions matching the right-clicked cell; empty when it has none the + * filter grammar can express (see `cellValueFilterConditions`). Gated on + * `isOpen` because closing the menu leaves `row`/`columnName` set, and this + * would otherwise rebuild on every render of the grid for the rest of the + * session. */ + const contextMenuFilterConditions = + contextMenu.isOpen && contextMenu.row && contextMenu.columnName + ? cellValueFilterConditions(contextMenuColumn, contextMenu.row.data[contextMenu.columnName]) + : EMPTY_FILTER_CONDITIONS + + function handleContextMenuFilterByCellValue() { + onFilterByCellValue?.(contextMenuFilterConditions) + closeContextMenu() + } + function handleContextMenuDelete() { const contextRow = contextMenu.row if (!contextRow) { @@ -1301,7 +1339,7 @@ export function TableGrid({ // cascade re-runs dependents on its own) instead of every group on the row. let contextMenuGroupId: string | null = null if (contextMenu.row && contextMenu.columnName) { - const _col = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName) + const _col = contextMenuColumn const _gid = _col?.workflowGroupId if (_col && _gid) { const _exec = contextMenu.row.executions?.[_gid] @@ -4402,6 +4440,11 @@ export function TableGrid({ sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} onViewWorkflow={handleViewWorkflow} + onSortColumn={onSortColumn} + onClearSort={onClearSort} + sortDirection={ + activeSort?.field === column.key ? activeSort.direction : undefined + } isPinned={colIsPinned} onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} stickyLeft={colStickyLeft} @@ -4574,6 +4617,11 @@ export function TableGrid({ Boolean(contextMenuEnrichment) } canEditCell={!contextMenuIsWorkflowColumn} + onFilterByCellValue={ + onFilterByCellValue && contextMenuFilterConditions.length > 0 + ? handleContextMenuFilterByCellValue + : undefined + } selectedRowCount={selectedRowCount} onRunWorkflows={ userPermissions.canEdit && hasWorkflowColumns && contextMenuStats.hasIncompleteOrFailed diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 8db321108dc..7a2ba8f792e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -12,6 +12,7 @@ import type { RunLimit, RunMode, TableViewWire } from '@/lib/api/contracts/table import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, + Predicate, SortDirection, SortSpec, TableMetadata, @@ -21,6 +22,7 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' +import { withCellValueFilter } from '@/lib/table/query-builder/cell-filter' import { type BreadcrumbItem, type ColumnOption, @@ -270,6 +272,9 @@ export function Table({ }) const [filter, setFilter] = useState(null) const [filterOpen, setFilterOpen] = useState(false) + /** Bumped whenever the filter is replaced from outside the panel, to re-seed + * its rule rows. See {@link replaceFilter}. */ + const [filterSeed, setFilterSeed] = useState(0) /** Hidden **column ids**. Lives here (not in the grid) because the filter * panel's Columns section edits it and the active view persists it. */ const [hiddenColumns, setHiddenColumns] = useState([]) @@ -423,6 +428,20 @@ export function Table({ */ const pendingCreatedViewIdRef = useRef(null) + /** + * Replaces the filter from OUTSIDE the filter panel — a view switch, or + * "Filter by cell value". Bumps {@link filterSeed} so the panel re-seeds: it + * builds its draft rule rows from the predicate once at mount, so without + * this an open panel keeps showing the rules of the filter it replaced. + * + * The remount discards an unapplied draft, which is the point — the rules on + * screen must be the rules in effect. + */ + const replaceFilter = useCallback((next: TablePredicate | null) => { + setFilter(next) + setFilterSeed((seed) => seed + 1) + }, []) + /** * Applies a view's config to the live state. `keep` marks slices the user has * already set by hand, which win over the view's stored values on the FIRST @@ -436,7 +455,7 @@ export function Table({ config: TableViewConfig | null, keep?: { sort?: boolean; filter?: boolean; hiddenColumns?: boolean } ) => { - if (!keep?.filter) setFilter(config?.filter ?? null) + if (!keep?.filter) replaceFilter(config?.filter ?? null) if (!keep?.hiddenColumns) setHiddenColumns(config?.hiddenColumns ?? []) if (keep?.sort) return const sortEntry = config?.sort?.[0] @@ -445,7 +464,7 @@ export function Table({ dir: sortEntry ? (sortEntry.direction as SortDirection) : null, }) }, - [setTableParams] + [replaceFilter, setTableParams] ) /** Reader for the grid's CURRENT column layout, populated by the grid itself. @@ -1103,24 +1122,45 @@ export function Table({ [columns] ) + const handleSortColumn = useCallback( + (column: string, direction: SortDirection) => setTableParams({ sort: column, dir: direction }), + [setTableParams] + ) + + /** + * Clearing writes the default direction (stripped by clearOnDefault) and + * drops the column, leaving a clean URL with no active sort. + */ + const handleClearSort = useCallback( + () => setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }), + [setTableParams] + ) + const sortConfig = useMemo( () => ({ options: columnOptions, active: sortColumn ? { column: sortColumn, direction: sortDirection } : null, - onSort: (column, direction) => setTableParams({ sort: column, dir: direction }), - /** - * Clearing writes the default direction (stripped by clearOnDefault) and - * drops the column, leaving a clean URL with no active sort. - */ - onClear: () => setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }), + onSort: handleSortColumn, + onClear: handleClearSort, }), - [columnOptions, sortColumn, sortDirection, setTableParams] + [columnOptions, sortColumn, sortDirection, handleSortColumn, handleClearSort] ) const handleFilterApply = (next: TablePredicate | null) => { setFilter(next) } + /** + * "Filter by cell value" from the grid's cell context menu. Narrows the + * PRUNED filter, so a condition the current schema already invalidated is not + * resurrected, and opens the panel — a silently narrowed table would leave the + * user no way to see what was applied. + */ + const handleFilterByCellValue = (conditions: readonly Predicate[]) => { + replaceFilter(withCellValueFilter(effectiveFilter, conditions)) + setFilterOpen(true) + } + const breadcrumbs = useMemo( (): BreadcrumbItem[] => folderBreadcrumbItems({ @@ -1448,6 +1488,7 @@ export function Table({ /> {filterOpen && ( } - -/** - * Next.js Global Error Page Component - * Renders for application-level errors - */ -export function NextGlobalError({ - error, - reset, -}: { - error: Error & { digest?: string } - reset: () => void -}) { - useEffect(() => { - logger.error('Global workspace error:', { error }) - }, [error]) - - return ( - - - - - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index d9cdf9702ac..85acddb8a22 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -210,17 +210,6 @@ export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [ { id: 'actions', label: 'Actions' }, ] as const -/** - * Maps UI command IDs to API command IDs. - * Some commands have different IDs for display vs API (e.g., "actions" -> "superagent") - */ -export function getApiCommandId(uiCommandId: string): string { - const commandMapping: Record = { - actions: 'superagent', - } - return commandMapping[uiCommandId] || uiCommandId -} - export const WEB_COMMANDS: readonly SlashCommand[] = [ { id: 'search', label: 'Search' }, { id: 'read', label: 'Read' }, @@ -230,37 +219,7 @@ export const WEB_COMMANDS: readonly SlashCommand[] = [ export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] -export const ALL_COMMAND_IDS = ALL_SLASH_COMMANDS.map((cmd) => cmd.id) - -/** - * Get display label for a command ID - */ -export function getCommandDisplayLabel(commandId: string): string { - const command = ALL_SLASH_COMMANDS.find((cmd) => cmd.id === commandId) - return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1) -} - -/** - * Threshold for considering input "near top" of viewport (in pixels) - */ -export const NEAR_TOP_THRESHOLD = 300 - /** * Scroll tolerance for mention menu positioning (in pixels) */ export const SCROLL_TOLERANCE = 8 - -/** - * Shared CSS classes for menu state text (loading, empty states) - */ -export const MENU_STATE_TEXT_CLASSES = 'px-2 py-2 text-caption text-[var(--text-muted)]' - -/** - * Calculates the next index for circular navigation (wraps around at bounds) - */ -export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number { - if (direction === 'down') { - return current >= maxIndex ? 0 : current + 1 - } - return current <= 0 ? maxIndex : current - 1 -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 3e8c4d8be5d..9ecf8397c7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import { FOLDER_CONFIGS, type MentionFolderId, @@ -123,42 +122,6 @@ export function computeMentionHighlightRanges( return ranges } -/** - * Builds React nodes with highlighted mention tokens - * @param text - Text to render - * @param contexts - Chat contexts to highlight - * @param createHighlightSpan - Function to create highlighted span element - * @returns Array of React nodes with highlighted mentions - */ -export function buildMentionHighlightNodes( - text: string, - contexts: ChatContext[], - createHighlightSpan: (token: string, key: string) => ReactNode -): ReactNode[] { - const tokens = extractContextTokens(contexts) - if (!tokens.length) return [text] - - const ranges = computeMentionHighlightRanges(text, tokens) - if (!ranges.length) return [text] - - const nodes: ReactNode[] = [] - let lastIndex = 0 - - for (const range of ranges) { - if (range.start > lastIndex) { - nodes.push(text.slice(lastIndex, range.start)) - } - nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`)) - lastIndex = range.end - } - - if (lastIndex < text.length) { - nodes.push(text.slice(lastIndex)) - } - - return nodes -} - /** * Gets the data array for a folder ID from mentionData. * Uses FOLDER_CONFIGS as the source of truth for key mapping. @@ -169,18 +132,6 @@ export function getFolderData(mentionData: MentionDataReturn, folderId: MentionF return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || [] } -/** - * Gets the loading state for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - */ -export function getFolderLoading( - mentionData: MentionDataReturn, - folderId: MentionFolderId -): boolean { - const config = FOLDER_CONFIGS[folderId] - return mentionData[config.loadingKey as keyof MentionDataReturn] as boolean -} - /** * Gets the ensure loaded function for a folder ID from mentionData. * Uses FOLDER_CONFIGS as the source of truth for key mapping. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 58704bfd47f..bea443e2f7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -15,7 +15,6 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/ import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { usePermissionConfig } from '@/hooks/use-permission-config' -import { getProviderFromModel } from '@/providers/utils' import { useSubBlockStore } from '@/stores/workflows/subblock/store' /** @@ -108,30 +107,18 @@ export const ComboBox = memo(function ComboBox({ const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue // Permission-based filtering for model dropdowns - const { - isProviderAllowed, - isModelAllowed, - isLoading: isPermissionLoading, - } = usePermissionConfig() + const { isModelUsable, isLoading: isPermissionLoading } = usePermissionConfig() // Evaluate static options if provided as a function const staticOptions = useMemo(() => { const opts = typeof options === 'function' ? options() : options if (subBlockId === 'model') { - return opts.filter((opt) => { - const modelId = typeof opt === 'string' ? opt : opt.id - if (!isModelAllowed(modelId)) return false - try { - return isProviderAllowed(getProviderFromModel(modelId)) - } catch { - return true - } - }) + return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id)) } return opts - }, [options, subBlockId, isProviderAllowed, isModelAllowed]) + }, [options, subBlockId, isModelUsable]) const { fetchedOptions, @@ -210,15 +197,7 @@ export const ComboBox = memo(function ComboBox({ fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) { - opts = opts.filter((opt) => { - const modelId = typeof opt === 'string' ? opt : opt.id - if (!isModelAllowed(modelId)) return false - try { - return isProviderAllowed(getProviderFromModel(modelId)) - } catch { - return true - } - }) + opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id)) } // Merge hydrated option if not already present @@ -251,8 +230,7 @@ export const ComboBox = memo(function ComboBox({ hydratedOption, createdOption, subBlockId, - isProviderAllowed, - isModelAllowed, + isModelUsable, ]) // Convert options to Combobox format 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 bd1cd4d6114..4449886f4a0 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 @@ -253,7 +253,9 @@ export function CredentialSelector({ const comboboxOptions = useMemo(() => { if (isAllCredentials) { - const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth') + const oauthCredentials = allWorkspaceCredentials.filter( + (credential) => credential.type === 'oauth' + ) return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id })) } if (isMergedKinds) return [] @@ -409,7 +411,9 @@ export function CredentialSelector({ } const matchedCred = ( - isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials + isAllCredentials + ? allWorkspaceCredentials.filter((credential) => credential.type === 'oauth') + : credentials ).find((c) => c.id === value) if (matchedCred) { handleSelect(value) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 1eb22d6b5a8..996fbf7f3f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -2,6 +2,10 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { ChipTag, Combobox, type ComboboxOption } from '@sim/emcn' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' +import { + NO_DENIED_OPERATIONS, + OPERATION_SUBBLOCK_ID, +} from '@/lib/permission-groups/operation-access' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options' @@ -11,7 +15,7 @@ import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' -import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useOperationAccess } from '@/hooks/use-operation-access' import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** Selected-value badges shown before folding the rest into a "+N" badge. */ @@ -97,7 +101,7 @@ export const Dropdown = memo(function Dropdown({ preserveLabelCase = false, }: DropdownProps) { const activeSearchTarget = useActiveSearchTarget() - const { isToolAllowed } = usePermissionConfig() + const { getDeniedOperations, resolveDefaultOperation, isPermissionLoading } = useOperationAccess() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) as [ string | string[] | null | undefined, (value: string | string[]) => void, @@ -189,26 +193,17 @@ export const Dropdown = memo(function Dropdown({ /** * Operation IDs whose resolved tool is denied by the caller's permission - * group. Only the `operation` selector of a block with a tool selector is - * gated. Denied operations are hidden from the picker (still resolvable for - * label display); the server is the authoritative gate regardless. + * group. Only the `operation` selector is gated. Denied operations are hidden + * from the picker (still resolvable for label display); the server is the + * authoritative gate regardless. */ const deniedOperationIds = useMemo(() => { - const denied = new Set() - if (subBlockId !== 'operation') return denied - const selectTool = blockConfig?.tools?.config?.tool - if (!selectTool) return denied - for (const opt of allOptions) { - const optionId = typeof opt === 'string' ? opt : opt.id - try { - const toolId = selectTool({ operation: optionId }) - if (toolId && !isToolAllowed(toolId)) denied.add(optionId) - } catch { - // Unresolvable from the operation alone — leave it visible; the server still enforces. - } - } - return denied - }, [subBlockId, blockConfig, allOptions, isToolAllowed]) + if (subBlockId !== OPERATION_SUBBLOCK_ID) return NO_DENIED_OPERATIONS + return getDeniedOperations( + blockConfig, + allOptions.map((opt) => (typeof opt === 'string' ? opt : opt.id)) + ) + }, [subBlockId, blockConfig, allOptions, getDeniedOperations]) const comboboxOptions = useMemo((): ComboboxOption[] => { const toLabel = (raw: string) => (preserveLabelCase ? raw : raw.toLowerCase()) @@ -232,17 +227,22 @@ export const Dropdown = memo(function Dropdown({ const defaultOptionValue = useMemo(() => { if (multiSelect) return undefined - const firstSelectable = comboboxOptions.find((opt) => !opt.hidden) - if (defaultValue !== undefined) { - // Don't seed a denied operation as the default; use the first allowed option. - if (deniedOperationIds.has(defaultValue)) { - return firstSelectable?.value - } - return defaultValue + /** + * The operation field defaults through the permission gate, which withholds + * a value until the group config has loaded. Seeding the static first + * option in that window would persist an operation the group denies — + * nothing revisits a field that already holds a value, so the correction + * that arrives with the config would never apply. + */ + if (subBlockId === OPERATION_SUBBLOCK_ID) { + const selectableIds = comboboxOptions.filter((opt) => !opt.hidden).map((opt) => opt.value) + return resolveDefaultOperation(blockConfig, selectableIds, defaultValue) } - return firstSelectable?.value - }, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect]) + if (defaultValue !== undefined) return defaultValue + + return comboboxOptions.find((opt) => !opt.hidden)?.value + }, [defaultValue, comboboxOptions, multiSelect, subBlockId, blockConfig, resolveDefaultOperation]) useEffect(() => { if (multiSelect || defaultOptionValue === undefined) { @@ -437,7 +437,9 @@ export const Dropdown = memo(function Dropdown({ onChange={handleChange} onMultiSelectChange={handleMultiSelectChange} placeholder={placeholder} - disabled={disabled} + /* The operation list only drops denied entries once the config resolves, + and a pick here persists — matching the agent tool selector. */ + disabled={disabled || (subBlockId === OPERATION_SUBBLOCK_ID && isPermissionLoading)} editable={false} onOpenChange={handleOpenChange} overlayContent={multiSelectOverlay ?? singleSelectOverlay} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index fe30b4dc656..55920dce208 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -26,6 +26,10 @@ import { } from '@/lib/mcp/tool-validation' import type { McpToolSchema } from '@/lib/mcp/types' import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth' +import { + NO_DENIED_OPERATIONS, + OPERATION_SUBBLOCK_ID, +} from '@/lib/permission-groups/operation-access' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { resolveStoredToolName } from '@/lib/workflows/subblocks/display' import { buildToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' @@ -65,7 +69,7 @@ import { getAllBlocks, getBlock } from '@/blocks' import { isCustomBlockType } from '@/blocks/custom/build-config' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getTileIconColorClass } from '@/blocks/icon-color' -import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' +import type { BlockConfig, SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' @@ -85,6 +89,7 @@ import { import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows' import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useOperationAccess } from '@/hooks/use-operation-access' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' @@ -355,25 +360,23 @@ function resolveCustomToolFromReference( /** * Checks if a block supports multiple operations. * - * @param blockType - The block type to check + * @param block - The block config to check * @returns `true` if the block has more than one tool operation available */ -function hasMultipleOperations(blockType: string): boolean { - const block = getAllBlocks().find((b) => b.type === blockType) +function hasMultipleOperations(block: BlockConfig | undefined): boolean { return (block?.tools?.access?.length || 0) > 1 } /** * Gets the available operation options for a multi-operation tool. * - * @param blockType - The block type to get operations for + * @param block - The block config to get operations for * @returns Array of operation options with label and id properties */ -function getOperationOptions(blockType: string): { label: string; id: string }[] { - const block = getAllBlocks().find((b) => b.type === blockType) +function getOperationOptions(block: BlockConfig | undefined): { label: string; id: string }[] { if (!block || !block.tools?.access) return [] - const operationSubBlock = block.subBlocks.find((sb) => sb.id === 'operation') + const operationSubBlock = block.subBlocks.find((sb) => sb.id === OPERATION_SUBBLOCK_ID) if ( operationSubBlock && operationSubBlock.type === 'dropdown' && @@ -662,13 +665,47 @@ export const ToolInput = memo(function ToolInput({ const provider = model ? getProviderFromModel(model) : '' const supportsToolControl = provider ? supportsToolUsageControl(provider) : false - const { filterBlocks, config: permissionConfig } = usePermissionConfig() + const { + filterBlocks, + config: permissionConfig, + isLoading: isPermissionLoading, + } = usePermissionConfig() + const { getDeniedOperations } = useOperationAccess() + + /** + * A tool block's selectable operations paired with the ones the caller's + * permission group denies. + * + * Both callers derive from this single result so they cannot drift: the + * picker *removes* denied operations (it must never offer or default to one), + * while the editor's selector *hides* them (a tool already saved on one keeps + * showing its name). + */ + const getOperationChoices = useCallback( + (block: BlockConfig | undefined) => { + const options = getOperationOptions(block).filter((option) => option.id !== '') + return { + options, + denied: getDeniedOperations( + block, + options.map((option) => option.id) + ), + } + }, + [getDeniedOperations] + ) const customBlockOverlayVersion = useCustomBlockOverlayVersion() const toolBlocks = useMemo(() => { const allToolBlocks = getAllBlocks().filter(isAgentToolBlock) - return filterBlocks(allToolBlocks) - }, [filterBlocks, customBlockOverlayVersion]) + /* An empty option list means the block declares no selectable operation, so + there is nothing to gate — only a wholly denied one leaves the picker. */ + return filterBlocks(allToolBlocks).filter((block) => { + if (!hasMultipleOperations(block)) return true + const { options, denied } = getOperationChoices(block) + return options.length === 0 || options.some((option) => !denied.has(option.id)) + }) + }, [filterBlocks, customBlockOverlayVersion, getOperationChoices]) const hasBackfilledRef = useRef(false) useEffect(() => { @@ -744,7 +781,7 @@ export const ToolInput = memo(function ToolInput({ * @returns `true` if tool is already selected (for single-operation tools only) */ const isToolAlreadySelected = (toolId: string, blockType: string) => { - if (hasMultipleOperations(blockType)) { + if (hasMultipleOperations(getBlock(blockType))) { return false } // Custom blocks all share toolId `workflow_executor`, so dedup-by-toolId would @@ -783,9 +820,10 @@ export const ToolInput = memo(function ToolInput({ (toolBlock: (typeof toolBlocks)[0]) => { if (isPreview || disabled) return - const hasOperations = hasMultipleOperations(toolBlock.type) - const operationOptions = hasOperations ? getOperationOptions(toolBlock.type) : [] - const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined + const { options, denied } = hasMultipleOperations(toolBlock) + ? getOperationChoices(toolBlock) + : { options: [], denied: NO_DENIED_OPERATIONS } + const defaultOperation = options.find((option) => !denied.has(option.id))?.id const toolId = getToolIdForOperation(toolBlock.type, defaultOperation, toolBlock) if (!toolId) return @@ -821,7 +859,7 @@ export const ToolInput = memo(function ToolInput({ setOpen(false) }, - [isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue] + [isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue, getOperationChoices] ) const handleAddCustomTool = useCallback( @@ -1670,7 +1708,11 @@ export const ToolInput = memo(function ToolInput({ options={[]} groups={toolGroups} placeholder='Add tool...' - disabled={disabled} + /* Every list this picker offers — blocks, operations, MCP and custom + tools — reads as unrestricted until the permission config resolves, + and adding a tool is a one-shot write that nothing revisits. Closed + rather than optimistic for that beat. */ + disabled={disabled || isPermissionLoading} searchable searchPlaceholder='Search tools...' maxHeight={240} @@ -1799,7 +1841,8 @@ export const ToolInput = memo(function ToolInput({ ) : [] - const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) + const hasOperations = + !isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined) const hasParams = useSubBlocks ? displaySubBlocks.length > 0 : displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0 @@ -2059,26 +2102,33 @@ export const ToolInput = memo(function ToolInput({
{/* Operation dropdown for tools with multiple operations */} {(() => { - const hasOperations = hasMultipleOperations(tool.type) - const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] + if (!hasOperations) return null + const { options: operationOptions, denied } = getOperationChoices( + toolBlock ?? undefined + ) + if (operationOptions.length === 0) return null - return hasOperations && operationOptions.length > 0 ? ( + return (
Operation
option.id !== '') - .map((option) => ({ - label: option.label, - value: option.id, - }))} - value={tool.operation || operationOptions[0].id} + options={operationOptions.map((option) => ({ + label: option.label, + value: option.id, + hidden: denied.has(option.id), + }))} + value={ + tool.operation || + operationOptions.find((option) => !denied.has(option.id))?.id + } onChange={(value) => handleOperationChange(toolIndex, value)} placeholder='Select operation' - disabled={disabled} + /* Denied operations only drop out once the config + resolves, and picking one rewrites the stored tool. */ + disabled={disabled || isPermissionLoading} />
- ) : null + ) })()} {(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 97fb6a3f507..f999a2294ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' @@ -132,10 +133,6 @@ async function persistExecutionPointerProgress( await saveExecutionPointer({ workflowId, executionId, lastEventId }) } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function isRecoverableStreamRecoveryError( error: unknown ): error is SSEEventHandlerError | SSEStreamInterruptedError { @@ -158,12 +155,12 @@ function normalizeErrorMessage(error: unknown): string { if (message) return message } - if (isRecord(error)) { + if (isRecordLike(error)) { const directMessage = sanitizeMessage(error.message) if (directMessage) return directMessage const nestedError = error.error - if (isRecord(nestedError)) { + if (isRecordLike(nestedError)) { const nestedMessage = sanitizeMessage(nestedError.message) if (nestedMessage) return nestedMessage } else { @@ -181,7 +178,7 @@ interface ChatWorkflowInput { } function isChatWorkflowInput(value: unknown): value is ChatWorkflowInput { - return isRecord(value) && 'input' in value + return isRecordLike(value) && 'input' in value } export interface ChatWorkflowRunResult { @@ -199,7 +196,7 @@ export class WorkflowAttachmentUploadError extends Error { export function isChatWorkflowRunResult(value: unknown): value is ChatWorkflowRunResult { return ( - isRecord(value) && + isRecordLike(value) && value.success === true && value.stream instanceof ReadableStream && Array.isArray(value.uploadedAttachments) @@ -1688,10 +1685,11 @@ export function useWorkflowExecution() { } let notificationMessage = WORKFLOW_EXECUTION_FAILURE_MESSAGE - const requestError = isRecord(error) && isRecord(error.request) ? error.request : undefined + const requestError = + isRecordLike(error) && isRecordLike(error.request) ? error.request : undefined if (requestError && sanitizeMessage(requestError.url)) { notificationMessage += `: Request to ${(requestError.url as string).trim()} failed` - if (isRecord(error) && typeof error.status === 'number') { + if (isRecordLike(error) && typeof error.status === 'number') { notificationMessage += ` (Status: ${error.status})` } } else if (sanitizeMessage(errorResult.error)) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-derivation.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-derivation.ts index b1f9e45b49c..e361720baa0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-derivation.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-derivation.ts @@ -19,14 +19,3 @@ export function computeContainerZIndex( return depth } - -export function computeBlockZIndex( - block: Pick, - allBlocks: Record> -): number { - if (block.type === 'loop' || block.type === 'parallel') { - return computeContainerZIndex(block, allBlocks) - } - - return block.data?.parentId ? Z_INDEX.CHILD_BLOCK : Z_INDEX.ROOT_BLOCK -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index da1c4c12591..4c2120940c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -198,46 +198,6 @@ export function computeClampedPositionUpdates( })) } -interface ParentUpdateEntry { - blockId: string - newParentId: string - affectedEdges: Edge[] -} - -/** - * Computes parent update entries for nodes being moved into a subflow. - * Only includes "boundary edges" - edges that cross the selection boundary - * (one end inside selection, one end outside). Edges between nodes in the - * selection are preserved. - */ -export function computeParentUpdateEntries( - validNodes: Node[], - allEdges: Edge[], - targetParentId: string -): ParentUpdateEntry[] { - const movingNodeIds = new Set(validNodes.map((n) => n.id)) - - // Find edges that cross the boundary (one end inside selection, one end outside) - // Edges between nodes in the selection should stay intact - const boundaryEdges = allEdges.filter((e) => { - const sourceInSelection = movingNodeIds.has(e.source) - const targetInSelection = movingNodeIds.has(e.target) - // Only remove if exactly one end is in the selection (crosses boundary) - return sourceInSelection !== targetInSelection - }) - - // Build updates for all valid nodes - return validNodes.map((n) => { - // Only include boundary edges connected to this specific node - const edgesForThisNode = boundaryEdges.filter((e) => e.source === n.id || e.target === n.id) - return { - blockId: n.id, - newParentId: targetParentId, - affectedEdges: edgesForThisNode, - } - }) -} - /** * Resolves parent-child selection conflicts by deselecting children whose parent is also selected. */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index fdfc5a2ef8e..2d035136aeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -17,6 +17,7 @@ import 'reactflow/dist/style.css' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { omit } from '@sim/utils/object' import type { SubflowNodeData } from '@sim/workflow-renderer' import { BLOCK_DIMENSIONS, @@ -40,6 +41,7 @@ import { useSession } from '@/lib/auth/auth-client' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import type { OAuthProvider } from '@/lib/oauth' +import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' import { getDefaultBlockName } from '@/lib/workflows/blocks/canvas-presentation' import { requestNoteImage, requestNoteRename } from '@/lib/workflows/notes/canvas-requests' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' @@ -126,6 +128,7 @@ import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows' import { useCanvasViewport } from '@/hooks/use-canvas-viewport' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return' +import { useOperationAccess } from '@/hooks/use-operation-access' import { useCanvasModeStore } from '@/stores/canvas-mode' import { useChatStore } from '@/stores/chat/store' import { @@ -867,6 +870,8 @@ const WorkflowContent = React.memo( */ const pendingFocusBlockIdRef = useRef(null) + const { resolveSeedGate } = useOperationAccess() + const addBlock = useCallback( ( id: string, @@ -888,6 +893,8 @@ const WorkflowContent = React.memo( if (parentId) blockData.parentId = parentId if (extent) blockData.extent = extent + const seedGate = resolveSeedGate(getBlock(type)) + const block = prepareBlockState({ id, type, @@ -897,6 +904,7 @@ const WorkflowContent = React.memo( parentId, extent, triggerMode, + isSeededValueAllowed: seedGate, }) const subBlockValues: Record> = {} @@ -914,7 +922,21 @@ const WorkflowContent = React.memo( if (!subBlockValues[id]) { subBlockValues[id] = {} } - Object.assign(subBlockValues[id], presetSubBlockValues) + /* The same gate as the declared default, deliberately: a preset is + offered by search and the connection picker, whose index reads as + unrestricted until the config resolves — so it is not the informed + pick it looks like, and honouring it would persist an operation + from an unfiltered list. */ + const presetOperation = presetSubBlockValues[OPERATION_SUBBLOCK_ID] + const presetOperationDenied = + typeof presetOperation === 'string' && !seedGate(OPERATION_SUBBLOCK_ID, presetOperation) + + Object.assign( + subBlockValues[id], + presetOperationDenied + ? omit(presetSubBlockValues, [OPERATION_SUBBLOCK_ID]) + : presetSubBlockValues + ) } collaborativeBatchAddBlocks( @@ -926,7 +948,7 @@ const WorkflowContent = React.memo( ) usePanelEditorStore.getState().setCurrentBlockId(id) }, - [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection] + [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection, resolveSeedGate] ) const { activeBlockIds, pendingBlocks, isDebugging, isExecuting } = useExecutionStore( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 504105afec6..3119c40fe2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -141,6 +141,12 @@ export function SettingsSidebar({ if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) { return false } + if ( + item.id === 'credential-groups' && + (!hostContext.features?.credentialGroups || !canAdminWorkspace) + ) { + return false + } if (item.selfHostedOverride && !isHosted) { /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.test.tsx index cfba54d6f8d..0a1501a66e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.test.tsx @@ -20,6 +20,7 @@ const { })) vi.mock('next/navigation', () => ({ + usePathname: () => '/workspace/workspace-denied', useRouter: () => ({ push: mockPush }), })) @@ -49,7 +50,52 @@ vi.mock('@/stores/workflows/registry/store', () => ({ ) => selector({ switchToWorkspace: mockSwitchToWorkspace }), })) -import { useWorkspaceManagement } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management' +import { + resolveWorkspaceSwitchHref, + useWorkspaceManagement, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management' + +describe('resolveWorkspaceSwitchHref', () => { + it('preserves the active settings section', () => { + expect( + resolveWorkspaceSwitchHref({ + pathname: '/workspace/workspace-a/settings/mcp', + currentWorkspaceId: 'workspace-a', + targetWorkspaceId: 'workspace-b', + }) + ).toBe('/workspace/workspace-b/settings/mcp') + }) + + it('drops workspace-scoped settings detail segments', () => { + expect( + resolveWorkspaceSwitchHref({ + pathname: '/workspace/workspace-a/settings/secrets/credential-a', + currentWorkspaceId: 'workspace-a', + targetWorkspaceId: 'workspace-b', + }) + ).toBe('/workspace/workspace-b/settings/secrets') + }) + + it('navigates to the workspace root outside settings', () => { + expect( + resolveWorkspaceSwitchHref({ + pathname: '/workspace/workspace-a/w/workflow-a', + currentWorkspaceId: 'workspace-a', + targetWorkspaceId: 'workspace-b', + }) + ).toBe('/workspace/workspace-b') + }) + + it('fails fast when a settings pathname has no section', () => { + expect(() => + resolveWorkspaceSwitchHref({ + pathname: '/workspace/workspace-a/settings/', + currentWorkspaceId: 'workspace-a', + targetWorkspaceId: 'workspace-b', + }) + ).toThrow('Settings pathname is missing a section') + }) +}) function Harness() { useWorkspaceManagement({ workspaceId: 'workspace-denied', sessionUserId: 'user-1' }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts index e4a444de479..0deca94ef97 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useRouter } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { requestJson } from '@/lib/api/client/request' import { updateUserSettingsContract } from '@/lib/api/contracts' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' @@ -25,6 +25,33 @@ interface UseWorkspaceManagementProps { sessionUserId?: string } +interface ResolveWorkspaceSwitchHrefParams { + pathname: string + currentWorkspaceId: string + targetWorkspaceId: string +} + +/** + * Keeps the active settings section across workspace switches without carrying + * workspace-scoped detail IDs into the destination workspace. + */ +export function resolveWorkspaceSwitchHref({ + pathname, + currentWorkspaceId, + targetWorkspaceId, +}: ResolveWorkspaceSwitchHrefParams): string { + const targetWorkspaceHref = `/workspace/${targetWorkspaceId}` + const settingsPrefix = `/workspace/${currentWorkspaceId}/settings/` + if (!pathname.startsWith(settingsPrefix)) return targetWorkspaceHref + + const [section] = pathname.slice(settingsPrefix.length).split('/') + if (!section) { + throw new Error(`Settings pathname is missing a section: ${pathname}`) + } + + return `${targetWorkspaceHref}/settings/${section}` +} + /** * Manages workspace operations including fetching, switching, creating, deleting, and leaving workspaces. * Handles URL synchronization and recency-based ordering. Route access is @@ -40,6 +67,7 @@ export function useWorkspaceManagement({ sessionUserId, }: UseWorkspaceManagementProps) { const router = useRouter() + const pathname = usePathname() const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) const { data: workspaces = [], isLoading: isWorkspacesLoading } = useWorkspacesQuery( @@ -157,15 +185,21 @@ export function useWorkspaceManagement({ return } + const href = resolveWorkspaceSwitchHref({ + pathname, + currentWorkspaceId: workspaceIdRef.current, + targetWorkspaceId: workspace.id, + }) + try { switchToWorkspace(workspace.id) - routerRef.current?.push(`/workspace/${workspace.id}`) + routerRef.current.push(href) logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`) } catch (error) { logger.error('Error switching workspace:', error) } }, - [switchToWorkspace] + [pathname, switchToWorkspace] ) const handleCreateWorkspace = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 696fa18ef49..e800bc91553 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -457,6 +457,7 @@ export const Sidebar = memo(function Sidebar({ config: permissionConfig, filterBlocks, isBlockAllowed, + isToolAllowed, integrationAvailability, } = usePermissionConfig() const { navigateToSettings } = useSettingsNavigation() @@ -472,8 +473,14 @@ export const Sidebar = memo(function Sidebar({ ) useEffect(() => { - initializeSearchData(filterBlocks) - }, [initializeSearchData, filterBlocks, providerModelSignature, customBlockOverlayVersion]) + initializeSearchData(filterBlocks, isToolAllowed) + }, [ + initializeSearchData, + filterBlocks, + isToolAllowed, + providerModelSignature, + customBlockOverlayVersion, + ]) const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth) const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 60b631ba6e3..f16bcb57b38 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -6,6 +6,7 @@ import { getModelCapabilityCondition, getModelOptions, getProviderCredentialSubBlocks, + getSerializedModelProviderId, normalizeFileInput, RESPONSE_FORMAT_WAND_CONFIG, } from '@/blocks/utils' @@ -29,6 +30,9 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { ToolResponse } from '@/tools/types' const logger = createLogger('AgentBlock') + +/** Model the agent block falls back to when `model` is unset or the auto pseudo-model. */ +const AGENT_FALLBACK_MODEL = 'claude-sonnet-5' const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() const MODELS_WITH_THINKING = getModelsWithThinking() @@ -521,21 +525,10 @@ Return ONLY the JSON array.`, ], config: { tool: (params: Record) => { - const model = params.model || 'claude-sonnet-5' - if (!model) { - throw new Error('No model selected') - } - // sim-auto resolves to a concrete pool model at execution time, where - // the agent handler derives the provider from the resolved model and - // never reads this serialized value. Serialization still needs the - // same provider-id shape every other model stores, so look up the - // runtime fallback model's provider. - const lookupModel = isAutoModel(model) ? 'claude-sonnet-5' : model - const tool = getBaseModelProviders()[lookupModel] - if (!tool) { - throw new Error(`Invalid model selected: ${model}`) - } - return tool + const model = params.model || AGENT_FALLBACK_MODEL + // sim-auto has no provider of its own until the pool resolves it at execution time. + const lookupModel = isAutoModel(model) ? AGENT_FALLBACK_MODEL : model + return getSerializedModelProviderId(lookupModel, AGENT_FALLBACK_MODEL) }, params: (params: Record) => { const normalizedFiles = normalizeFileInput(params.files) diff --git a/apps/sim/blocks/blocks/ashby.test.ts b/apps/sim/blocks/blocks/ashby.test.ts index 748efee159f..4b134b69aba 100644 --- a/apps/sim/blocks/blocks/ashby.test.ts +++ b/apps/sim/blocks/blocks/ashby.test.ts @@ -78,6 +78,252 @@ describe('AshbyBlock', () => { expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object') expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object') }) + + it('does not force braces or brackets on the polymorphic fieldValue', () => { + // fieldValue legitimately takes a bare boolean, number, string, or null, + // so neither the 'json-object' nor the 'json-array' reinforcement applies - + // both would make the wand emit a wrapper the field must not receive. + const fieldValue = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValue') + expect(fieldValue?.wandConfig?.enabled).toBe(true) + expect(fieldValue?.wandConfig?.generationType).toBeUndefined() + }) + + it('requests array output for fieldValues, whose contract is a JSON array', () => { + const fieldValues = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValues') + expect(fieldValues?.wandConfig?.generationType).toBe('json-array') + }) + }) + + describe('fieldValue parsing (set_custom_field_value)', () => { + const parse = (fieldValue: unknown) => + AshbyBlock.tools.config.params!(buildParams('set_custom_field_value', { fieldValue })) + .fieldValue + + it('decodes null so the annotation can be cleared', () => { + // Ashby clears a custom field when it receives an explicit null, which is + // what makes a written annotation reversible. + expect(parse('null')).toBeNull() + }) + + it('decodes booleans and numbers for Boolean and Number fields', () => { + expect(parse('true')).toBe(true) + expect(parse('42')).toBe(42) + }) + + it('decodes a JSON array for MultiValueSelect fields', () => { + expect(parse('["Remote","Hybrid"]')).toEqual(['Remote', 'Hybrid']) + }) + + it('decodes a JSON object for Currency and range fields', () => { + expect(parse('{"value":150000,"currencyCode":"USD"}')).toEqual({ + value: 150000, + currencyCode: 'USD', + }) + }) + + it('passes unparseable text through as a plain string', () => { + // A bare option name is the most common input for String, LongText, and + // ValueSelect fields, so it must not be rejected as invalid JSON. + expect(parse('Senior Engineer')).toBe('Senior Engineer') + }) + + it('decodes a quoted numeric string back to a string', () => { + // The escape hatch for a String field whose value looks like a number. + expect(parse('"123"')).toBe('123') + }) + + it('does not let an overflowing number become a field clear', () => { + // 1e999 parses to Infinity, which JSON.stringify emits as null - and null + // clears the field. The user typed a number, not a clear. + expect(parse('1e999')).toBe('1e999') + }) + + it('does not silently lose precision on long numeric ids', () => { + expect(parse('12345678901234567890')).toBe('12345678901234567890') + expect(parse('0123')).toBe('0123') + }) + + it('leaves prose that merely starts like JSON alone when it does not parse', () => { + expect(parse('{not really json')).toBe('{not really json') + }) + + it('passes an already-parsed value through untouched', () => { + // An upstream block reference resolves to a real value, not to text. + expect(parse({ value: 1 })).toEqual({ value: 1 }) + expect(parse(false)).toBe(false) + }) + + it('leaves fieldValue alone for other operations', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('list_jobs', { fieldValue: 'Senior Engineer' }) + ) + expect(result.fieldValue).toBeUndefined() + }) + }) + + describe('fieldValues parsing (set_custom_field_values)', () => { + it('maps the fieldValues subBlock onto the tool’s values param', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('set_custom_field_values', { + fieldValues: '[{"fieldId":"abc","fieldValue":"High"}]', + }) + ) + expect(result.values).toEqual([{ fieldId: 'abc', fieldValue: 'High' }]) + expect(result.fieldValues).toBeUndefined() + }) + + it('throws instead of silently dropping the writes when the JSON is malformed', () => { + expect(() => + AshbyBlock.tools.config.params!( + buildParams('set_custom_field_values', { fieldValues: 'not json' }) + ) + ).toThrow(/Invalid JSON in Ashby custom field values/) + }) + + it('throws when the parsed JSON is not an array', () => { + expect(() => + AshbyBlock.tools.config.params!( + buildParams('set_custom_field_values', { fieldValues: '{"fieldId":"abc"}' }) + ) + ).toThrow(/expected a JSON array/) + }) + }) + + describe('change_application_source', () => { + it('emits sourceId as undefined when the field is left blank', () => { + // The key must be PRESENT and undefined, not absent. The executor merges + // `{ ...inputs, ...transformedParams }`, so an absent key inherits whatever + // inputs held - which is exactly how a stale create-path sourceId used to + // leak in. Presence is what overrides it. + const result = AshbyBlock.tools.config.params!( + buildParams('change_application_source', { applicationId: 'app-1', changeSourceId: '' }) + ) + expect(result).toHaveProperty('sourceId') + expect(result.sourceId).toBeUndefined() + expect(result).not.toHaveProperty('unsetSource') + }) + + it('passes unsetSource through only when the switch is on', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('change_application_source', { changeSourceId: '', unsetSource: 'true' }) + ) + expect(result.unsetSource).toBe(true) + }) + + it('never sends a stale source id alongside a clear request', () => { + // The Source ID field is hidden once the clear switch is on, but a value + // typed beforehand is still stored. Sending both would trip the tool's + // exclusivity guard and surface as an error the user cannot see the cause of. + const result = AshbyBlock.tools.config.params!( + buildParams('change_application_source', { + changeSourceId: 'src-left-over', + unsetSource: 'true', + }) + ) + expect(result.unsetSource).toBe(true) + expect(result).toHaveProperty('sourceId') + expect(result.sourceId).toBeUndefined() + }) + + it('never inherits a stale create-path source id through the executor merge', () => { + // The executor runs `{ ...inputs, ...transformedParams }`, so any key this + // mapping leaves unset inherits whatever inputs held. The shared + // create-path `sourceId` subblock reaches inputs even on this operation: + // it is mode 'advanced', and the serializer includes an advanced subblock + // on a non-empty value without evaluating its condition. Assert the merged + // result, not just the mapping, since that gap is where the bug lived. + const merge = (inputs: Record) => ({ + ...inputs, + ...AshbyBlock.tools.config.params!(inputs), + }) + + const cleared = merge( + buildParams('change_application_source', { + applicationId: 'app-1', + sourceId: 'stale-from-create-application', + changeSourceId: '', + unsetSource: 'true', + }) + ) + expect(cleared.sourceId).toBeUndefined() + expect(cleared.unsetSource).toBe(true) + + const untouched = merge( + buildParams('change_application_source', { + applicationId: 'app-1', + sourceId: 'stale-from-create-application', + changeSourceId: '', + }) + ) + expect(untouched.sourceId).toBeUndefined() + + const explicit = merge( + buildParams('change_application_source', { + applicationId: 'app-1', + sourceId: 'stale-from-create-application', + changeSourceId: 'src-intended', + }) + ) + expect(explicit.sourceId).toBe('src-intended') + }) + + it('hides the source id field while the clear switch is on', () => { + const sourceField = AshbyBlock.subBlocks.find((s) => s.id === 'changeSourceId') + const condition = sourceField?.condition as { and?: { field: string; not?: boolean } } + expect(condition.and).toEqual({ field: 'unsetSource', value: true, not: true }) + }) + + it('maps a provided source id onto sourceId', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('change_application_source', { changeSourceId: 'src-1' }) + ) + expect(result.sourceId).toBe('src-1') + }) + + it('does not emit a null sourceId for other operations', () => { + // create_candidate treats an absent source as "no source", so a null here + // would turn an omitted optional field into an explicit write. + const result = AshbyBlock.tools.config.params!(buildParams('create_candidate', {})) + expect(result).not.toHaveProperty('sourceId') + }) + }) + + describe('operation and tool registration stay in sync', () => { + it('has a matching ashby_ tool in access for every dropdown option', () => { + // tools.config.tool is a bare `ashby_${operation}` concat, so a dropdown + // option without a matching tool id resolves to a tool that does not exist. + const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation') + const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id) + const access = new Set(AshbyBlock.tools.access) + const missing = optionIds.filter((id) => !access.has(`ashby_${id}`)) + expect(missing).toEqual([]) + }) + + it('has a dropdown option for every tool listed in access', () => { + const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation') + const optionIds = new Set( + (operation?.options as Array<{ id: string }>).map((o) => `ashby_${o.id}`) + ) + const unreachable = AshbyBlock.tools.access!.filter((id) => !optionIds.has(id)) + expect(unreachable).toEqual([]) + }) + + it('has a canvas sentence for every dropdown option', () => { + const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation') + const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id) + const sentences = AshbyBlock.canvasPresentation?.sentences?.byOperation ?? {} + const missing = optionIds.filter((id) => !(id in sentences)) + expect(missing).toEqual([]) + }) + }) + + describe('list_jobs incremental sync', () => { + it('offers the syncToken field on list_jobs', () => { + // Without a sync token every scheduled run rescans the full req set. + const syncToken = AshbyBlock.subBlocks.find((s) => s.id === 'syncToken') + const condition = syncToken?.condition as { value: string[] } + expect(condition.value).toContain('list_jobs') + }) }) describe('list_applications candidateId filter', () => { diff --git a/apps/sim/blocks/blocks/ashby.ts b/apps/sim/blocks/blocks/ashby.ts index 8b7f6b3ab7a..ffb5370c42a 100644 --- a/apps/sim/blocks/blocks/ashby.ts +++ b/apps/sim/blocks/blocks/ashby.ts @@ -39,12 +39,78 @@ function parseSocialLinksInput(value: unknown): Array<{ type: string; url: strin return parsed } +/** + * Parses an Ashby custom field value from the block input. Ashby custom fields + * are polymorphic, so structured input is decoded to give Currency, NumberRange, + * MultiValueSelect, Boolean, Number, and cleared fields the right wire type, + * while everything else passes through as a plain string, which String, + * LongText, Date, Url, and ValueSelect fields accept. + * + * Decoding is deliberately narrow rather than a blanket `JSON.parse`. Parsing + * every string that happens to be valid JSON corrupts real text: `1e999` becomes + * Infinity and serializes back out as `null`, which CLEARS the field; a long + * numeric id loses precision past 2^53; and pasted prose that starts with `{` + * turns into an object. So only these forms decode: + * + * - `null`, `true`, `false` - the literal keywords + * - text starting with `{`, `[`, or `"` - objects, arrays, and quoted strings + * - numbers that survive a round trip exactly, which excludes Infinity, + * precision loss, and leading zeros + * + * The remaining trade-off is that the text `123` becomes the number 123. A field + * that needs the literal string can be quoted (`"123"`), which decodes back to it. + */ +function parseCustomFieldValueInput(value: unknown): unknown { + if (typeof value !== 'string') return value + const trimmed = value.trim() + if (!trimmed) return value + + if (trimmed === 'null') return null + if (trimmed === 'true') return true + if (trimmed === 'false') return false + + const first = trimmed[0] + if (first === '{' || first === '[' || first === '"') { + try { + return JSON.parse(trimmed) + } catch { + return value + } + } + + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + const asNumber = Number(trimmed) + if (Number.isFinite(asNumber) && String(asNumber) === trimmed) return asNumber + } + + return value +} + +function parseCustomFieldValuesInput(value: unknown): unknown[] { + if (Array.isArray(value)) return value + if (typeof value !== 'string' || !value.trim()) return [] + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch (error) { + throw new Error( + `Invalid JSON in Ashby custom field values: ${getErrorMessage(error)}. Expected a JSON array like [{"fieldId":"","fieldValue":"High"}].` + ) + } + if (!Array.isArray(parsed)) { + throw new Error( + 'Invalid Ashby custom field values: expected a JSON array like [{"fieldId":"","fieldValue":"High"}].' + ) + } + return parsed +} + export const AshbyBlock: BlockConfig = { type: 'ashby', name: 'Ashby', description: 'Manage candidates, jobs, and applications in Ashby', longDescription: - 'Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).', + 'Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag, anonymize), applications (list, get, create, delete, change stage, change source), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), custom field values (set one or many), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).', docsLink: 'https://docs.sim.ai/integrations/ashby', category: 'tools', integrationType: IntegrationType.HR, @@ -101,11 +167,17 @@ export const AshbyBlock: BlockConfig = { { text: ', for application', field: 'offerApplicationId' }, { text: ', created after', field: 'createdAfter' }, ], + delete_application: [{ text: 'Delete application', field: 'applicationId', core: true }], change_application_stage: [ { text: 'Move application', field: 'applicationId', core: true }, { text: 'to stage', field: 'interviewStageId' }, { text: ', with archive reason', field: 'archiveReasonId' }, ], + change_application_source: [ + { text: 'Attribute application', field: 'applicationId', core: true }, + { text: 'to source', field: 'changeSourceId' }, + ], + anonymize_candidate: [{ text: 'Anonymize candidate', field: 'candidateId', core: true }], add_candidate_tag: [ { text: 'Add tag', field: 'tagId', core: true }, { text: 'to candidate', field: 'candidateId', core: true }, @@ -119,6 +191,15 @@ export const AshbyBlock: BlockConfig = { list_candidate_tags: ['List candidate tags'], list_archive_reasons: ['List archive reasons'], list_custom_fields: ['List custom field definitions'], + set_custom_field_value: [ + { text: 'Set custom field', field: 'fieldId', core: true }, + { text: 'on', field: 'objectType' }, + { text: 'to', field: 'fieldValue' }, + ], + set_custom_field_values: [ + { text: 'Set custom fields on', field: 'objectType', core: true }, + { text: 'record', field: 'objectId', core: true }, + ], list_departments: ['List departments'], list_locations: ['List locations'], list_job_postings: [ @@ -169,8 +250,11 @@ export const AshbyBlock: BlockConfig = { { label: 'List Applications', id: 'list_applications' }, { label: 'Get Application', id: 'get_application' }, { label: 'Create Application', id: 'create_application' }, + { label: 'Delete Application', id: 'delete_application' }, { label: 'List Offers', id: 'list_offers' }, { label: 'Change Application Stage', id: 'change_application_stage' }, + { label: 'Change Application Source', id: 'change_application_source' }, + { label: 'Anonymize Candidate', id: 'anonymize_candidate' }, { label: 'Add Candidate Tag', id: 'add_candidate_tag' }, { label: 'Remove Candidate Tag', id: 'remove_candidate_tag' }, { label: 'Get Offer', id: 'get_offer' }, @@ -178,6 +262,8 @@ export const AshbyBlock: BlockConfig = { { label: 'List Candidate Tags', id: 'list_candidate_tags' }, { label: 'List Archive Reasons', id: 'list_archive_reasons' }, { label: 'List Custom Fields', id: 'list_custom_fields' }, + { label: 'Set Custom Field Value', id: 'set_custom_field_value' }, + { label: 'Set Custom Field Values', id: 'set_custom_field_values' }, { label: 'List Departments', id: 'list_departments' }, { label: 'List Locations', id: 'list_locations' }, { label: 'List Job Postings', id: 'list_job_postings' }, @@ -209,6 +295,7 @@ export const AshbyBlock: BlockConfig = { 'update_candidate', 'add_candidate_tag', 'remove_candidate_tag', + 'anonymize_candidate', ], }, placeholder: 'Enter candidate UUID', @@ -221,6 +308,7 @@ export const AshbyBlock: BlockConfig = { 'update_candidate', 'add_candidate_tag', 'remove_candidate_tag', + 'anonymize_candidate', ], }, }, @@ -352,12 +440,23 @@ Output only the ISO 8601 timestamp string, nothing else.`, type: 'short-input', required: { field: 'operation', - value: ['get_application', 'change_application_stage'], + value: [ + 'get_application', + 'change_application_stage', + 'change_application_source', + 'delete_application', + ], }, placeholder: 'Enter application UUID', condition: { field: 'operation', - value: ['get_application', 'change_application_stage', 'list_interviews'], + value: [ + 'get_application', + 'change_application_stage', + 'change_application_source', + 'delete_application', + 'list_interviews', + ], }, }, { @@ -652,6 +751,7 @@ Output only the ISO 8601 timestamp string, nothing else.`, 'list_departments', 'list_custom_fields', 'list_offers', + 'list_jobs', ], }, mode: 'advanced', @@ -756,6 +856,131 @@ Output only the JSON array, nothing else.`, condition: { field: 'operation', value: 'list_job_postings' }, mode: 'advanced', }, + { + id: 'includeUnpublishedJobPostings', + title: 'Include Draft Postings', + type: 'switch', + condition: { field: 'operation', value: 'list_job_postings' }, + mode: 'advanced', + }, + { + id: 'objectType', + title: 'Object Type', + type: 'dropdown', + options: [ + { label: 'Job', id: 'Job' }, + { label: 'Application', id: 'Application' }, + { label: 'Candidate', id: 'Candidate' }, + { label: 'Opening', id: 'Opening' }, + ], + value: () => 'Job', + required: { + field: 'operation', + value: ['set_custom_field_value', 'set_custom_field_values'], + }, + condition: { + field: 'operation', + value: ['set_custom_field_value', 'set_custom_field_values'], + }, + }, + { + id: 'objectId', + title: 'Object ID', + type: 'short-input', + required: { + field: 'operation', + value: ['set_custom_field_value', 'set_custom_field_values'], + }, + placeholder: 'Enter the UUID of the job, application, candidate, or opening', + condition: { + field: 'operation', + value: ['set_custom_field_value', 'set_custom_field_values'], + }, + }, + { + id: 'fieldId', + title: 'Custom Field ID', + type: 'short-input', + required: { field: 'operation', value: 'set_custom_field_value' }, + placeholder: 'Custom field definition UUID from List Custom Fields', + condition: { field: 'operation', value: 'set_custom_field_value' }, + }, + { + id: 'fieldValue', + title: 'Custom Field Value', + type: 'long-input', + required: { field: 'operation', value: 'set_custom_field_value' }, + placeholder: 'Plain value, or JSON for structured field types. Use null to clear.', + condition: { field: 'operation', value: 'set_custom_field_value' }, + wandConfig: { + enabled: true, + prompt: `Generate an Ashby custom field value matching the field's type. + +Rules: +- Boolean: true or false +- Number: a bare number, e.g. 42 +- String, LongText, Date, Url, ValueSelect: the plain text, e.g. Senior Engineer or 2026-03-01 +- MultiValueSelect: a JSON array of option values, e.g. ["Remote","Hybrid"] +- Currency: {"value":150000,"currencyCode":"USD"} +- NumberRange: {"type":"number-range","minValue":1,"maxValue":5} +- CompensationRange: {"type":"compensation-range","minValue":120000,"maxValue":160000,"currencyCode":"USD","interval":"YEAR"} +- Location: {"country":"United States","region":"California","city":"San Francisco"} +- To clear the field, output exactly: null + +Output only the value. Do not wrap it in an object or add commentary.`, + placeholder: 'Describe the value to write...', + }, + }, + { + id: 'fieldValues', + title: 'Custom Field Values', + type: 'code', + required: { field: 'operation', value: 'set_custom_field_values' }, + placeholder: '[{ "fieldId": "", "fieldValue": "High" }]', + condition: { field: 'operation', value: 'set_custom_field_values' }, + wandConfig: { + enabled: true, + generationType: 'json-array', + prompt: `Generate a JSON array of Ashby custom field writes for one object. + +Each element is {"fieldId": "", "fieldValue": }. +fieldValue follows the field's type: boolean, number, plain string, a string array for +MultiValueSelect, an object for Currency/NumberRange/CompensationRange/Location, or null to clear. + +Example: +[{"fieldId":"11111111-1111-1111-1111-111111111111","fieldValue":"High"},{"fieldId":"22222222-2222-2222-2222-222222222222","fieldValue":true}] + +Output only the JSON array.`, + placeholder: 'Describe the fields to write...', + }, + }, + { + id: 'unsetSource', + title: 'Clear the source instead', + type: 'switch', + condition: { field: 'operation', value: 'change_application_source' }, + }, + { + /** + * Hidden while the clear switch is on, so the editor cannot hold a source + * ID and a clear request at the same time. The two are mutually exclusive + * intents and the tool rejects the pair rather than picking a winner. + */ + id: 'changeSourceId', + title: 'Source ID', + type: 'short-input', + required: { + field: 'operation', + value: 'change_application_source', + and: { field: 'unsetSource', value: true, not: true }, + }, + placeholder: 'Source UUID from List Sources', + condition: { + field: 'operation', + value: 'change_application_source', + and: { field: 'unsetSource', value: true, not: true }, + }, + }, { id: 'expandJob', title: 'Include Job', @@ -812,10 +1037,13 @@ Output only the JSON array, nothing else.`, tools: { access: [ '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', @@ -838,6 +1066,8 @@ Output only the JSON array, nothing else.`, 'ashby_list_users', 'ashby_remove_candidate_tag', 'ashby_search_candidates', + 'ashby_set_custom_field_value', + 'ashby_set_custom_field_values', 'ashby_update_candidate', ], config: { @@ -865,6 +1095,12 @@ Output only the JSON array, nothing else.`, if (params.listedOnly === 'true' || params.listedOnly === true) { result.listedOnly = true } + if ( + params.includeUnpublishedJobPostings === 'true' || + params.includeUnpublishedJobPostings === true + ) { + result.includeUnpublishedJobPostings = true + } if (params.expandJob === 'true' || params.expandJob === true) { result.expandJob = true } @@ -906,6 +1142,28 @@ Output only the JSON array, nothing else.`, const socialLinks = parseSocialLinksInput(params.socialLinks) if (socialLinks.length > 0) result.socialLinks = socialLinks } + if (params.operation === 'set_custom_field_value') { + result.fieldValue = parseCustomFieldValueInput(params.fieldValue) + } + if (params.operation === 'set_custom_field_values') { + result.values = parseCustomFieldValuesInput(params.fieldValues) + } + if (params.operation === 'change_application_source') { + // sourceId is always assigned, never conditionally, because the executor + // merges `{ ...inputs, ...transformedParams }` and a key this mapping + // leaves unset simply inherits whatever was in inputs. The create-path + // `sourceId` subblock leaks into exactly that gap: it is mode + // 'advanced', and the serializer includes an advanced subblock whenever + // its value is non-empty without ever evaluating its condition, so a + // value typed while on Create Application survives into this operation. + // Inheriting it would attribute a source nobody asked for, or collide + // with a clear request and fail with no visible cause. + const unsetSource = params.unsetSource === 'true' || params.unsetSource === true + const changeSourceId = + typeof params.changeSourceId === 'string' ? params.changeSourceId.trim() : '' + result.sourceId = unsetSource || !changeSourceId ? undefined : changeSourceId + if (unsetSource) result.unsetSource = true + } return result }, }, @@ -981,6 +1239,32 @@ Output only the JSON array, nothing else.`, type: 'string', description: 'Social links as JSON array', }, + includeUnpublishedJobPostings: { + type: 'boolean', + description: 'Also return unpublished (draft) job postings', + }, + objectType: { + type: 'string', + description: 'Custom field target object type (Application, Candidate, Job, or Opening)', + }, + objectId: { type: 'string', description: 'UUID of the object to set custom fields on' }, + fieldId: { type: 'string', description: 'Custom field definition UUID' }, + fieldValue: { + type: 'string', + description: 'Custom field value (plain value, or JSON for structured types; null clears it)', + }, + fieldValues: { + type: 'string', + description: 'Custom field writes as a JSON array of { fieldId, fieldValue }', + }, + changeSourceId: { + type: 'string', + description: 'Source UUID to attribute an application to', + }, + unsetSource: { + type: 'boolean', + description: 'Deliberately clear an application source instead of setting one', + }, }, outputs: { @@ -1020,7 +1304,12 @@ Output only the JSON array, nothing else.`, customFields: { type: 'json', description: - 'List of custom field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived})', + 'For List Custom Fields, the field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived}). For Set Custom Field Values, the field values written to the object (id, title, isPrivate, valueLabel, value)', + }, + customField: { + type: 'json', + description: + 'A single custom field value after a write (id, title, isPrivate, valueLabel, value)', }, departments: { type: 'json', @@ -1092,9 +1381,15 @@ Output only the JSON array, nothing else.`, }, isPrivate: { type: 'boolean', description: 'Whether the note is private' }, createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, + applicationId: { type: 'string', description: 'UUID of the deleted application' }, moreDataAvailable: { type: 'boolean', description: 'Whether more pages exist' }, nextCursor: { type: 'string', description: 'Pagination cursor for next page' }, syncToken: { type: 'string', description: 'Sync token for incremental updates' }, + nextSyncCursor: { + type: 'string', + description: + "Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output", + }, }, } diff --git a/apps/sim/blocks/blocks/azure_data_explorer.ts b/apps/sim/blocks/blocks/azure_data_explorer.ts new file mode 100644 index 00000000000..1375103927b --- /dev/null +++ b/apps/sim/blocks/blocks/azure_data_explorer.ts @@ -0,0 +1,657 @@ +import { AzureDataExplorerIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { AzureDataExplorerTableResponse } from '@/tools/azure_data_explorer/types' + +/** Every operation except the cluster-level database listing runs in a database. */ +const DATABASE_SCOPED_OPERATIONS = [ + 'azure_data_explorer_query', + 'azure_data_explorer_management', + 'azure_data_explorer_list_tables', + 'azure_data_explorer_show_table_schema', + 'azure_data_explorer_show_table_details', + 'azure_data_explorer_show_database_schema', + 'azure_data_explorer_list_functions', + 'azure_data_explorer_create_table', + 'azure_data_explorer_drop_table', + 'azure_data_explorer_ingest_inline', + 'azure_data_explorer_ingest_from_query', + 'azure_data_explorer_show_ingestion_failures', + 'azure_data_explorer_show_operations', +] as const + +/** Operations the cluster rejects without a database in context. */ +const DATABASE_REQUIRED_OPERATIONS = [ + 'azure_data_explorer_query', + 'azure_data_explorer_list_tables', + 'azure_data_explorer_show_table_schema', + 'azure_data_explorer_show_table_details', + 'azure_data_explorer_show_database_schema', + 'azure_data_explorer_list_functions', + 'azure_data_explorer_create_table', + 'azure_data_explorer_drop_table', + 'azure_data_explorer_ingest_inline', + 'azure_data_explorer_ingest_from_query', + 'azure_data_explorer_show_ingestion_failures', +] as const + +/** Operations that name a single table. */ +const TABLE_OPERATIONS = [ + 'azure_data_explorer_show_table_schema', + 'azure_data_explorer_show_table_details', + 'azure_data_explorer_create_table', + 'azure_data_explorer_drop_table', + 'azure_data_explorer_ingest_inline', + 'azure_data_explorer_ingest_from_query', +] as const + +/** Table operations where the table name is mandatory. */ +const TABLE_REQUIRED_OPERATIONS = [ + 'azure_data_explorer_show_table_schema', + 'azure_data_explorer_create_table', + 'azure_data_explorer_drop_table', + 'azure_data_explorer_ingest_inline', + 'azure_data_explorer_ingest_from_query', +] as const + +/** Operations that accept an operation ID filter. */ +const OPERATION_ID_OPERATIONS = [ + 'azure_data_explorer_show_ingestion_failures', + 'azure_data_explorer_show_operations', +] as const + +const KQL_WAND_PROMPT = `Write a Kusto Query Language (KQL) query for Azure Data Explorer based on the user's request. + +Rules: +- Start with the table name, then pipe into operators with | +- Filter with where, project columns with project, aggregate with summarize ... by +- Limit results with take or top N by +- String comparison is case-sensitive with ==, case-insensitive with =~; use contains / has for substring and term matching +- Time filters use ago(), e.g. where Timestamp > ago(7d) +- String literals use double quotes + +Examples: +- StormEvents | where State == "FLORIDA" | summarize count() by EventType | top 10 by count_ +- Logs | where Timestamp > ago(1h) and Level == "Error" | project Timestamp, Message | take 100 + +Return ONLY the KQL query - no explanations, no markdown fences.` + +const COLUMN_SCHEMA_WAND_PROMPT = `Write a Kusto CSL column schema for Azure Data Explorer based on the user's request. + +Rules: +- Format is a comma-separated list of Name:type pairs +- Valid types are bool, datetime, decimal, dynamic, guid, int, long, real, string, and timespan +- Use datetime for timestamps, long for counts, real for measurements, dynamic for nested JSON +- Column names use letters, digits, and underscores + +Examples: +- Timestamp:datetime, Level:string, Message:string, ServiceName:string +- EventTime:datetime, UserId:string, Properties:dynamic, DurationMs:long + +Return ONLY the schema - no explanations, no parentheses, no markdown fences.` + +/** + * Kusto matches an ingested query result to the target table positionally, by + * column type — never by column name — so the prompt has to make column order + * explicit or the generated query quietly fills the wrong columns. + */ +const INGEST_QUERY_WAND_PROMPT = `${KQL_WAND_PROMPT.replace('Return ONLY the KQL query - no explanations, no markdown fences.', '')} +This query's result is ingested into an existing table. Kusto matches columns by +position and type, NOT by name, so the projected columns must come out in the +target table's column order. Use an explicit \`project\` listing the columns in +that order as the final operator. + +Return ONLY the KQL query - no explanations, no markdown fences.` + +/** Switch and dropdown values arrive as booleans or their string form. */ +function toBoolean(value: unknown): true | undefined { + return value === true || value === 'true' ? true : undefined +} + +export const AzureDataExplorerBlock: BlockConfig = { + type: 'azure_data_explorer', + name: 'Azure Data Explorer', + description: 'Query and manage Azure Data Explorer (Kusto) clusters with KQL', + longDescription: + 'Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed.', + docsLink: 'https://docs.sim.ai/integrations/azure_data_explorer', + category: 'tools', + integrationType: IntegrationType.Databases, + bgColor: '#FFFFFF', + icon: AzureDataExplorerIcon, + authMode: AuthMode.ApiKey, + canvasPresentation: { + typeLabel: 'Azure Data Explorer', + defaultTitle: 'Azure Data Explorer', + operationSubBlockId: 'operation', + sentences: { + byOperation: { + azure_data_explorer_query: [ + { text: 'Run KQL', field: 'query', core: true }, + { text: 'against', field: 'database' }, + ], + azure_data_explorer_management: [ + { text: 'Run', field: 'command', core: true }, + { text: 'against', field: 'database' }, + ], + azure_data_explorer_list_databases: ['List the cluster databases'], + azure_data_explorer_list_tables: [ + { text: 'List tables in', field: 'database', core: true }, + ], + azure_data_explorer_show_table_schema: [ + { text: 'Read the schema of', field: 'table', core: true }, + { text: 'in', field: 'database' }, + ], + azure_data_explorer_show_database_schema: [ + { text: 'Read the schema of', field: 'database', core: true }, + ], + azure_data_explorer_ingest_inline: [ + { text: 'Ingest rows into', field: 'table', core: true }, + { text: 'in', field: 'database' }, + ], + azure_data_explorer_ingest_from_query: [ + { text: 'Ingest a query result into', field: 'table', core: true }, + { text: 'from', field: 'sourceQuery' }, + ], + azure_data_explorer_create_table: [ + { text: 'Create table', field: 'table', core: true }, + { text: 'with columns', field: 'columnSchema' }, + ], + azure_data_explorer_drop_table: [{ text: 'Drop table', field: 'table', core: true }], + azure_data_explorer_show_table_details: [ + 'Read table details', + { text: 'for', field: 'table' }, + { text: 'in', field: 'database' }, + ], + azure_data_explorer_list_functions: [ + { text: 'List stored functions in', field: 'database', core: true }, + ], + azure_data_explorer_show_ingestion_failures: [ + { text: 'List ingestion failures in', field: 'database', core: true }, + ], + azure_data_explorer_show_operations: [ + 'Check cluster operations', + { text: 'for', field: 'operationId' }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Run Query', id: 'azure_data_explorer_query' }, + { label: 'List Databases', id: 'azure_data_explorer_list_databases' }, + { label: 'List Tables', id: 'azure_data_explorer_list_tables' }, + { label: 'Show Table Schema', id: 'azure_data_explorer_show_table_schema' }, + { label: 'Show Database Schema', id: 'azure_data_explorer_show_database_schema' }, + { label: 'Show Table Details', id: 'azure_data_explorer_show_table_details' }, + { label: 'List Functions', id: 'azure_data_explorer_list_functions' }, + { label: 'Ingest Rows Inline', id: 'azure_data_explorer_ingest_inline' }, + { label: 'Ingest From Query', id: 'azure_data_explorer_ingest_from_query' }, + { label: 'Create Table', id: 'azure_data_explorer_create_table' }, + { label: 'Drop Table', id: 'azure_data_explorer_drop_table' }, + { label: 'Show Ingestion Failures', id: 'azure_data_explorer_show_ingestion_failures' }, + { label: 'Show Operations', id: 'azure_data_explorer_show_operations' }, + { label: 'Management Command (advanced)', id: 'azure_data_explorer_management' }, + ], + value: () => 'azure_data_explorer_query', + required: true, + }, + + { + id: 'database', + title: 'Database', + type: 'short-input', + placeholder: 'Samples', + condition: { field: 'operation', value: [...DATABASE_SCOPED_OPERATIONS] }, + required: { field: 'operation', value: [...DATABASE_REQUIRED_OPERATIONS] }, + }, + { + id: 'query', + title: 'KQL Query', + type: 'long-input', + placeholder: 'StormEvents | where State == "FLORIDA" | summarize count() by EventType', + condition: { field: 'operation', value: 'azure_data_explorer_query' }, + required: { field: 'operation', value: 'azure_data_explorer_query' }, + wandConfig: { + enabled: true, + prompt: KQL_WAND_PROMPT, + placeholder: + 'Describe the query you want (e.g., "error count per service in the last day")', + }, + }, + { + id: 'readOnly', + title: 'Read-only', + type: 'switch', + description: 'Reject any request that would change data (sends x-ms-readonly)', + condition: { field: 'operation', value: 'azure_data_explorer_query' }, + mode: 'advanced', + }, + { + id: 'properties', + title: 'Request Properties', + type: 'long-input', + placeholder: '{"Options":{"servertimeout":"00:04:00"}}', + condition: { field: 'operation', value: 'azure_data_explorer_query' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a Kusto request properties JSON object. The shape is {"Options":{...},"Parameters":{...}}. Common options are servertimeout (a timespan like "00:04:00"), queryconsistency ("strongconsistency" or "weakconsistency"), and truncationmaxrecords. Return ONLY the JSON object.', + generationType: 'json-object', + placeholder: 'Describe the request options you need', + }, + }, + + { + id: 'table', + title: 'Table', + type: 'short-input', + placeholder: 'StormEvents', + condition: { field: 'operation', value: [...TABLE_OPERATIONS] }, + required: { field: 'operation', value: [...TABLE_REQUIRED_OPERATIONS] }, + }, + { + id: 'data', + title: 'Rows', + type: 'long-input', + placeholder: 'Shoes,1000\nWide Shoes,50', + condition: { field: 'operation', value: 'azure_data_explorer_ingest_inline' }, + required: { field: 'operation', value: 'azure_data_explorer_ingest_inline' }, + }, + { + id: 'columnSchema', + title: 'Column Schema', + type: 'long-input', + placeholder: 'Timestamp:datetime, Level:string, Message:string, Count:long', + condition: { field: 'operation', value: 'azure_data_explorer_create_table' }, + required: { field: 'operation', value: 'azure_data_explorer_create_table' }, + wandConfig: { + enabled: true, + prompt: COLUMN_SCHEMA_WAND_PROMPT, + placeholder: + 'Describe the columns you want (e.g., "error logs with a timestamp and service")', + }, + }, + { + id: 'tableProperties', + title: 'Table Properties', + type: 'short-input', + placeholder: 'docstring="Raw application logs", folder="Ingest"', + condition: { field: 'operation', value: 'azure_data_explorer_create_table' }, + mode: 'advanced', + }, + { + id: 'ifExists', + title: 'Ignore missing table', + type: 'switch', + description: 'Succeed instead of failing when the table does not exist', + condition: { field: 'operation', value: 'azure_data_explorer_drop_table' }, + mode: 'advanced', + }, + { + id: 'ingestMode', + title: 'Mode', + type: 'dropdown', + options: [ + { label: 'Set or append — create, or add to an existing table', id: 'set-or-append' }, + { label: 'Set — create, fail if the table exists', id: 'set' }, + { label: 'Append — add to an existing table', id: 'append' }, + { label: 'Set or replace — replace all data in the table', id: 'set-or-replace' }, + ], + value: () => 'set-or-append', + condition: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' }, + required: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' }, + }, + { + id: 'sourceQuery', + title: 'Source Query', + type: 'long-input', + placeholder: 'LogsTable | where Level == "Error" and Timestamp > ago(1h)', + condition: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' }, + required: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' }, + wandConfig: { + enabled: true, + prompt: INGEST_QUERY_WAND_PROMPT, + placeholder: 'Describe the rows you want to materialize into the table', + }, + }, + { + id: 'ingestAsync', + title: 'Run in background', + type: 'switch', + description: + 'Return an operation ID immediately and keep ingesting — check it with Show Operations', + condition: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' }, + mode: 'advanced', + }, + { + id: 'operationId', + title: 'Operation ID', + type: 'short-input', + placeholder: 'Operation ID returned by an async ingestion', + condition: { field: 'operation', value: [...OPERATION_ID_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'ingestionProperties', + title: 'Ingestion Properties', + type: 'short-input', + placeholder: 'format="json", ingestionMappingReference="mymapping"', + condition: { + field: 'operation', + value: ['azure_data_explorer_ingest_inline', 'azure_data_explorer_ingest_from_query'], + }, + mode: 'advanced', + }, + + { + id: 'command', + title: 'Management Command', + type: 'long-input', + placeholder: '.show table StormEvents details', + condition: { field: 'operation', value: 'azure_data_explorer_management' }, + required: { field: 'operation', value: 'azure_data_explorer_management' }, + wandConfig: { + enabled: true, + prompt: + 'Write a single Azure Data Explorer management command for the user\'s request. Management commands start with a dot, e.g. ".show tables details", ".create table Events (Timestamp:datetime, Message:string)", ".alter-merge table Events policy retention softdelete = 30d". Return ONLY the command text.', + placeholder: 'Describe the management command you want', + }, + }, + + { + id: 'clusterUri', + title: 'Cluster URI', + type: 'short-input', + placeholder: 'https://mycluster.eastus.kusto.windows.net', + required: true, + }, + { + id: 'tenantId', + title: 'Tenant ID', + type: 'short-input', + placeholder: 'Microsoft Entra directory (tenant) ID', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Application (client) ID of the service principal', + password: true, + required: true, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Client secret of the service principal', + password: true, + required: true, + }, + { + id: 'resource', + title: 'Token Audience', + type: 'short-input', + placeholder: 'Defaults to the cluster URI', + mode: 'advanced', + }, + ], + tools: { + access: [ + 'azure_data_explorer_query', + 'azure_data_explorer_management', + 'azure_data_explorer_list_databases', + 'azure_data_explorer_list_tables', + 'azure_data_explorer_show_table_schema', + 'azure_data_explorer_show_database_schema', + 'azure_data_explorer_show_table_details', + 'azure_data_explorer_list_functions', + 'azure_data_explorer_ingest_inline', + 'azure_data_explorer_ingest_from_query', + 'azure_data_explorer_create_table', + 'azure_data_explorer_drop_table', + 'azure_data_explorer_show_ingestion_failures', + 'azure_data_explorer_show_operations', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const auth = { + clusterUri: params.clusterUri, + tenantId: params.tenantId, + clientId: params.clientId, + clientSecret: params.clientSecret, + resource: params.resource || undefined, + } + + switch (params.operation) { + case 'azure_data_explorer_query': + return { + ...auth, + database: params.database, + query: params.query, + properties: params.properties || undefined, + readOnly: toBoolean(params.readOnly), + } + case 'azure_data_explorer_management': + return { + ...auth, + database: params.database || undefined, + command: params.command, + } + case 'azure_data_explorer_list_databases': + return auth + case 'azure_data_explorer_list_tables': + case 'azure_data_explorer_show_database_schema': + return { ...auth, database: params.database } + case 'azure_data_explorer_show_table_schema': + return { ...auth, database: params.database, table: params.table } + case 'azure_data_explorer_ingest_inline': + return { + ...auth, + database: params.database, + table: params.table, + data: params.data, + ingestionProperties: params.ingestionProperties || undefined, + } + case 'azure_data_explorer_ingest_from_query': + return { + ...auth, + database: params.database, + table: params.table, + mode: params.ingestMode || undefined, + sourceQuery: params.sourceQuery, + async: toBoolean(params.ingestAsync), + ingestionProperties: params.ingestionProperties || undefined, + } + case 'azure_data_explorer_create_table': + return { + ...auth, + database: params.database, + table: params.table, + columnSchema: params.columnSchema, + tableProperties: params.tableProperties || undefined, + } + case 'azure_data_explorer_drop_table': + return { + ...auth, + database: params.database, + table: params.table, + ifExists: toBoolean(params.ifExists), + } + case 'azure_data_explorer_show_table_details': + return { ...auth, database: params.database, table: params.table || undefined } + case 'azure_data_explorer_list_functions': + return { ...auth, database: params.database } + case 'azure_data_explorer_show_ingestion_failures': + return { + ...auth, + database: params.database, + operationId: params.operationId || undefined, + } + case 'azure_data_explorer_show_operations': + return { + ...auth, + database: params.database || undefined, + operationId: params.operationId || undefined, + } + default: + return auth + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to run' }, + clusterUri: { type: 'string', description: 'Azure Data Explorer cluster URI' }, + tenantId: { type: 'string', description: 'Microsoft Entra tenant ID' }, + clientId: { type: 'string', description: 'Microsoft Entra application (client) ID' }, + clientSecret: { type: 'string', description: 'Microsoft Entra client secret' }, + resource: { type: 'string', description: 'Token audience override' }, + database: { type: 'string', description: 'Database context for the request' }, + query: { type: 'string', description: 'KQL query text' }, + properties: { type: 'json', description: 'Kusto request properties object' }, + readOnly: { type: 'boolean', description: 'Reject any request that would change data' }, + command: { type: 'string', description: 'Management command text' }, + table: { type: 'string', description: 'Target table name' }, + data: { type: 'string', description: 'Rows to ingest, one record per line' }, + ingestionProperties: { type: 'string', description: 'Ingestion properties clause contents' }, + columnSchema: { type: 'string', description: 'CSL column schema for a new table' }, + tableProperties: { type: 'string', description: 'Table properties clause contents' }, + ifExists: { type: 'boolean', description: 'Ignore a missing table when dropping' }, + ingestMode: { type: 'string', description: 'set | append | set-or-append | set-or-replace' }, + sourceQuery: { type: 'string', description: 'KQL query whose result is ingested' }, + ingestAsync: { type: 'boolean', description: 'Ingest in the background' }, + operationId: { type: 'string', description: 'Operation ID to look up' }, + }, + outputs: { + tableName: { type: 'string', description: 'Name Kusto assigned to the result table' }, + columns: { type: 'json', description: 'Column metadata for the result table' }, + rows: { type: 'json', description: 'Result rows as positional arrays' }, + records: { type: 'json', description: 'Result rows keyed by column name' }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { type: 'number', description: 'Rows Kusto returned, before the row cap' }, + truncated: { type: 'boolean', description: 'Whether rows were dropped to stay within the cap' }, + databases: { type: 'json', description: 'Database names (List Databases)' }, + tables: { type: 'json', description: 'Table names (List Tables)' }, + extentIds: { type: 'json', description: 'Extent IDs created (Ingest Rows Inline)' }, + schema: { type: 'string', description: 'CSL column schema (Show Table Schema)' }, + databaseName: { type: 'string', description: "Table's database (Show Table Schema)" }, + folder: { type: 'string', description: "Table's folder (Show Table Schema)" }, + docString: { type: 'string', description: "Table's docstring (Show Table Schema)" }, + functions: { type: 'json', description: 'Stored function names (List Functions)' }, + }, +} + +export const AzureDataExplorerBlockMeta = { + tags: ['data-analytics', 'data-warehouse', 'cloud', 'monitoring'], + url: 'https://azure.microsoft.com/products/data-explorer', + templates: [ + { + icon: AzureDataExplorerIcon, + title: 'Ask questions of Kusto data', + prompt: + 'Build an agent that turns plain-English questions about our telemetry into KQL, reads the Azure Data Explorer table schema first so the columns are real, runs the query, and answers with the numbers plus the query it used.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['analytics', 'automation'], + }, + { + icon: AzureDataExplorerIcon, + title: 'Daily telemetry digest', + prompt: + 'Create a scheduled workflow that queries Azure Data Explorer every morning for yesterday error rates, latency percentiles, and traffic by service, summarizes what moved against the prior week, and posts the digest to our engineering Slack channel.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['analytics', 'monitoring', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: AzureDataExplorerIcon, + title: 'Kusto anomaly alerts', + prompt: + 'Build a workflow that runs every 15 minutes, queries Azure Data Explorer for request failures grouped by endpoint, compares each endpoint against its own trailing baseline, and pages the on-call engineer through PagerDuty only when a real spike appears.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['monitoring', 'incident-response', 'automation'], + alsoIntegrations: ['pagerduty'], + }, + { + icon: AzureDataExplorerIcon, + title: 'Incident timeline from telemetry', + prompt: + 'Create a workflow that takes an incident window and service name, pulls the matching logs, traces, and deploy events out of Azure Data Explorer, and writes an ordered timeline file that an engineer can drop straight into a postmortem.', + modules: ['agent', 'files', 'workflows'], + category: 'operations', + tags: ['monitoring', 'incident-response', 'reporting'], + }, + { + icon: AzureDataExplorerIcon, + title: 'Event pipeline into Kusto', + prompt: + 'Build a workflow triggered by a webhook that validates each incoming event payload, maps it onto our Azure Data Explorer table schema, ingests the rows inline, and records any rejected payloads in a table so nothing is lost silently.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['automation', 'data-pipeline'], + }, + { + icon: AzureDataExplorerIcon, + title: 'Kusto cluster inventory', + prompt: + 'Create a scheduled workflow that lists every Azure Data Explorer database and table, reads their schemas, and keeps a Sim table up to date as a searchable data-model catalog with a note on what changed since the last run.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['analytics', 'documentation', 'automation'], + }, + { + icon: AzureDataExplorerIcon, + title: 'Usage report to stakeholders', + prompt: + 'Build a scheduled weekly workflow that queries Azure Data Explorer for product usage by account and feature, ranks the biggest movers, generates a report file with the supporting numbers, and emails it to the account team.', + modules: ['scheduled', 'agent', 'files', 'workflows'], + category: 'operations', + tags: ['analytics', 'reporting', 'automation'], + }, + ], + skills: [ + { + name: 'explore-kusto-schema', + description: + 'Discover what data an Azure Data Explorer cluster holds before writing a query against it.', + content: + '# Explore Kusto Schema\n\nMap out a cluster before querying it, so queries reference columns that actually exist.\n\n## Steps\n1. Run List Databases to see what the service principal can reach.\n2. Run List Tables on the database that looks relevant.\n3. Run Show Table Schema on each candidate table to read its columns and types, or Show Database Schema to pull every table and column in one call.\n\n## Output\nReturn the database name, the tables that matter for the question at hand, and each relevant column with its type.', + }, + { + name: 'answer-with-kql', + description: 'Answer a data question by writing and running a KQL query, then explaining it.', + content: + '# Answer With KQL\n\nTurn a question about the data into a query and a trustworthy answer.\n\n## Steps\n1. Run Show Table Schema on the table involved so column names and types are known rather than guessed.\n2. Write the KQL: filter with `where`, aggregate with `summarize ... by`, bound the result with `take` or `top N by`.\n3. Run Query with the read-only option enabled so the request cannot change data.\n4. If the result looks wrong or empty, widen the time filter or relax the string comparison and try again.\n\n## Output\nReport the answer, the row count, and the exact KQL that produced it so the result can be re-run and checked.', + }, + { + name: 'materialize-a-rollup-table', + description: + 'Build or refresh a summary table in Azure Data Explorer from a query over raw data.', + content: + '# Materialize A Rollup Table\n\nTurn an expensive query over raw telemetry into a small table that is cheap to read.\n\n## Steps\n1. Run Show Table Details on the source table to see how much data the query will scan.\n2. Run Show Table Schema on the **target** table. Kusto matches an ingested query result to the table by column position and type, never by name, so you need its exact column order before writing the query.\n3. Write the aggregating KQL — `summarize` the raw rows into the shape you want, bucketing time with `bin()` where relevant — and end it with an explicit `project` listing the columns in the target table order.\n4. Run Ingest From Query against the target table. Use `set-or-append` to add the new window to an existing rollup, `set-or-replace` to rebuild it from scratch, and `set` only for the first run.\n5. For a large backfill, enable the background option and poll Show Operations with the returned operation ID until the state is Completed.\n6. Run Query against the rollup and spot-check a few rows to confirm each column holds what it should, not just that the row count is plausible.\n\n## Output\nReport the target table, the mode used, the rows or extents produced, and the verification query result. Call out that `set-or-replace` discards the existing data in the table, and that a mismatched column order corrupts data silently rather than failing.', + }, + { + name: 'debug-ingestion-failures', + description: + 'Diagnose why data is missing from an Azure Data Explorer table and say whether it will retry.', + content: + '# Debug Ingestion Failures\n\nFind out why expected rows never arrived.\n\n## Steps\n1. Run Show Ingestion Failures for the database. Failures are kept for 14 days, so anything older will not appear.\n2. Group what comes back by table, error code, and failure kind. `Permanent` failures will not retry on their own; `Transient` ones may already have succeeded on a later attempt.\n3. Read the failure detail for the root cause — schema mismatch, a malformed source file, or a missing ingestion mapping are the usual ones.\n4. Run Show Table Schema on the target table and compare it against the source data shape named in the failure.\n5. Take the operation ID from a failure and run Show Operations on it for the fuller history of that operation.\n\n## Output\nReport the affected tables, the distinct error codes with counts, the likely root cause, and whether each failure is permanent (needs a fix) or transient (may self-resolve). Confirm with a Query against the table whether the rows are genuinely absent.', + }, + { + name: 'ingest-rows-into-kusto', + description: 'Push a small batch of records into an Azure Data Explorer table.', + content: + '# Ingest Rows Into Kusto\n\nLoad a handful of records into a table without setting up a pipeline.\n\n## Steps\n1. Run Show Table Schema on the target table to learn the column order and types.\n2. Format the records as CSV in that exact column order, one record per line, quoting any value containing a comma.\n3. Run Ingest Rows Inline against the table.\n4. Run Query to read the newly ingested rows back and confirm they landed as intended.\n\n## Output\nReport how many rows were sent, the extent IDs returned, and the verification query result. Note that inline ingestion is for small batches — recommend queued or streaming ingestion for ongoing volume.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts new file mode 100644 index 00000000000..5b1fbd3e7cc --- /dev/null +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -0,0 +1,365 @@ +import { GridOffset } from '@sim/emcn/icons' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { + type CanonicalGroup, + resolveActiveCanonicalValue, +} from '@/lib/workflows/subblocks/visibility' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { BlockConfig } from '@/blocks/types' +import { + CREDENTIAL_GROUP_LIST_STALE_TIME, + credentialGroupKeys, + fetchCredentialGroupList, +} from '@/hooks/queries/utils/credential-group-queries' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const CREDENTIAL_GROUP_CANONICAL_GROUP = { + canonicalId: 'credentialGroupId', + basicId: 'credentialGroup', + advancedIds: ['manualCredentialGroup'], +} as const satisfies CanonicalGroup + +async function fetchCachedCredentialGroups() { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + return getQueryClient().fetchQuery({ + queryKey: credentialGroupKeys.list(workspaceId), + queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + }) +} + +function resolveCredentialGroupIdForBlock(blockId: string): string | null { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) return null + const values = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {} + const canonicalModes = useWorkflowStore.getState().blocks[blockId]?.data?.canonicalModes + const value = resolveActiveCanonicalValue( + CREDENTIAL_GROUP_CANONICAL_GROUP, + values, + canonicalModes + ) + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +interface CredentialGroupBlockOutput { + success: boolean + output: { + credentials: Array<{ + credentialId: string + email: string + displayName: string + providerId: string + providerSubjectId: string + providerTenantId: string | null + }> + credentialGroups: Array<{ + id: string + name: string + description: string | null + status: 'active' | 'disabled' + providerIds: string[] + createdAt: string + updatedAt: string + }> + people: Array<{ + id: string + email: string + status: string + expired: boolean + invitedAt: string + connections: Array<{ provider: string; status: string; count: number }> + }> + enrollmentId: string + email: string + status: string + invitedAt: string + expiresAt: string + count: number + hasMore: boolean + nextCursor: string | null + } +} + +const GROUP_OPERATIONS = ['list_credentials', 'send_invite', 'list_people'] as const +const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const + +export const CredentialGroupBlock: BlockConfig = { + type: 'credential_group', + name: 'Credential Groups', + description: 'Invite people and use credentials collected by Credential Groups', + longDescription: + 'List usable managed credentials, inspect invited people, send an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', + bestPractices: ` + - Use "List Credentials" with a ForEach loop to run a provider block once for every connected account. + - Filter by email to select credentials belonging to one invited person, by provider to select one account type, or by both for an exact match. + - Continue with nextCursor until hasMore is false when a list operation returns multiple pages. + - "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded. + - Use "List People" to inspect invitation and connection progress without exposing credential secrets. + - "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', + icon: GridOffset, + canvasPresentation: { + defaultTitle: 'Credential Groups', + sentences: { + byOperation: { + list_credentials: [ + { + text: 'List credentials from', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', for', field: 'email' }, + { text: ', from', field: ['providerFilter', 'manualProviderIds'] }, + { text: ', up to', field: 'limit', after: 'credentials' }, + ], + send_invite: [ + { text: 'Invite', field: 'email', core: true }, + { + text: 'to', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + ], + list_people: [ + { + text: 'List people in', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', matching', field: 'email' }, + { text: ', with status', field: 'peopleStatuses' }, + ], + list_groups: ['List Credential Groups', { text: ', up to', field: 'limit' }], + }, + }, + }, + category: 'blocks', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Credentials', id: 'list_credentials' }, + { label: 'Send Invite', id: 'send_invite' }, + { label: 'List People', id: 'list_people' }, + { label: 'List Credential Groups', id: 'list_groups' }, + ], + value: () => 'list_credentials', + }, + { + id: 'credentialGroup', + title: 'Credential Group', + type: 'dropdown', + options: [], + required: { field: 'operation', value: [...GROUP_OPERATIONS] }, + mode: 'basic', + canonicalParamId: 'credentialGroupId', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + fetchOptions: async () => { + const groups = await fetchCachedCredentialGroups() + return groups + .filter((group) => group.status === 'active') + .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() + const group = groups.find((candidate) => candidate.id === optionId) + return group ? { label: group.name, id: group.id } : null + }, + }, + { + id: 'manualCredentialGroup', + title: 'Credential Group ID', + type: 'short-input', + required: { field: 'operation', value: [...GROUP_OPERATIONS] }, + mode: 'advanced', + placeholder: 'Enter credential group ID', + canonicalParamId: 'credentialGroupId', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + }, + { + id: 'email', + title: 'Email', + type: 'short-input', + required: { field: 'operation', value: 'send_invite' }, + placeholder: 'person@example.com', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + }, + { + id: 'providerFilter', + title: 'Provider', + type: 'dropdown', + multiSelect: true, + emptyIsValid: true, + options: [], + required: false, + mode: 'basic', + canonicalParamId: 'credentialProviderIds', + dependsOn: ['credentialGroupId'], + condition: { field: 'operation', value: 'list_credentials' }, + fetchOptions: async (blockId: string) => { + const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) + if (!credentialGroupId) return [] + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === credentialGroupId) + if (!group) return [] + return group.options + .filter((option) => option.status === 'active') + .map((option) => { + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } + }) + .sort((a, b) => a.label.localeCompare(b.label)) + }, + fetchOptionById: async (blockId: string, optionId: string) => { + const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) + if (!credentialGroupId) return null + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === credentialGroupId) + const option = group?.options.find( + (candidate) => + candidate.status === 'active' && + getCredentialGroupProviderService(candidate.provider).providerId === optionId + ) + if (!option) return null + return { + id: optionId, + label: getCredentialGroupProviderService(option.provider).name, + } + }, + }, + { + id: 'manualProviderIds', + title: 'Provider IDs', + type: 'short-input', + required: false, + mode: 'advanced', + canonicalParamId: 'credentialProviderIds', + dependsOn: ['credentialGroupId'], + placeholder: '["google-email", "slack"] — leave empty for all providers', + condition: { field: 'operation', value: 'list_credentials' }, + }, + { + id: 'peopleStatuses', + title: 'Status', + type: 'dropdown', + multiSelect: true, + emptyIsValid: true, + options: [ + { label: 'Invited', id: 'invited' }, + { label: 'Delivery failed', id: 'delivery_failed' }, + { label: 'In progress', id: 'in_progress' }, + { label: 'Connected', id: 'completed' }, + { label: 'Revoked', id: 'revoked' }, + ], + condition: { field: 'operation', value: 'list_people' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + value: () => '100', + mode: 'advanced', + placeholder: '1-100', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + mode: 'advanced', + placeholder: 'nextCursor from a previous page', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + ], + tools: { access: [] }, + inputs: { + operation: { + type: 'string', + description: "'list_credentials', 'send_invite', 'list_people', or 'list_groups'", + }, + credentialGroupId: { type: 'string', description: 'Credential Group ID' }, + email: { + type: 'string', + description: 'Recipient email for invites or exact email filter for list operations', + }, + credentialProviderIds: { + type: 'json', + description: 'Optional OAuth provider IDs to include when listing credentials', + }, + peopleStatuses: { + type: 'json', + description: 'Optional invitation statuses to include when listing people', + }, + limit: { type: 'number', description: 'Maximum results per page (1-100)' }, + cursor: { type: 'string', description: 'nextCursor from a previous page' }, + }, + outputs: { + credentials: { + type: 'json', + description: + 'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)', + condition: { field: 'operation', value: 'list_credentials' }, + }, + credentialGroups: { + type: 'json', + description: + 'Credential Group summaries (id, name, description, status, providerIds, createdAt, updatedAt)', + condition: { field: 'operation', value: 'list_groups' }, + }, + people: { + type: 'json', + description: + 'Invited people and current connection summaries (id, email, status, expired, invitedAt, connections)', + condition: { field: 'operation', value: 'list_people' }, + }, + enrollmentId: { + type: 'string', + description: 'Enrollment ID created or refreshed by the invitation', + condition: { field: 'operation', value: 'send_invite' }, + }, + email: { + type: 'string', + description: 'Normalized invitation recipient email', + condition: { field: 'operation', value: 'send_invite' }, + }, + status: { + type: 'string', + description: 'Invitation status', + condition: { field: 'operation', value: 'send_invite' }, + }, + invitedAt: { + type: 'string', + description: 'Invitation timestamp', + condition: { field: 'operation', value: 'send_invite' }, + }, + expiresAt: { + type: 'string', + description: 'Invitation expiration timestamp', + condition: { field: 'operation', value: 'send_invite' }, + }, + count: { + type: 'number', + description: 'Number of records returned', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + hasMore: { + type: 'boolean', + description: 'Whether another page is available', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + nextCursor: { + type: 'string', + description: 'Cursor for the next page, or null on the last page', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + }, +} diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index a5c9fea68c8..194183149cc 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -4,10 +4,9 @@ import type { BlockConfig, ParamType } from '@/blocks/types' import { getModelOptions, getProviderCredentialSubBlocks, + getSerializedModelProviderId, PROVIDER_CREDENTIAL_INPUTS, } from '@/blocks/utils' -import { getBaseModelProviders } from '@/providers/models' -import type { ProviderId } from '@/providers/types' import type { ToolResponse } from '@/tools/types' const logger = createLogger('EvaluatorBlock') @@ -253,17 +252,7 @@ export const EvaluatorBlock: BlockConfig = { 'deepseek_reasoner', ], config: { - tool: (params: Record) => { - const model = params.model || 'gpt-4o' - if (!model) { - throw new Error('No model selected') - } - const tool = getBaseModelProviders()[model as ProviderId] - if (!tool) { - throw new Error(`Invalid model selected: ${model}`) - } - return tool - }, + tool: (params: Record) => getSerializedModelProviderId(params.model), }, }, inputs: { diff --git a/apps/sim/blocks/blocks/grafana.ts b/apps/sim/blocks/blocks/grafana.ts index 2e36e1d4de8..eb71a701e18 100644 --- a/apps/sim/blocks/blocks/grafana.ts +++ b/apps/sim/blocks/blocks/grafana.ts @@ -52,6 +52,7 @@ export const GrafanaBlock: BlockConfig = { grafana_delete_alert_rule: [ { text: 'Delete alert rule', field: 'alertRuleUid', core: true }, ], + grafana_query_data_source: ['Query a data source', { text: ', over', field: 'queryFrom' }], grafana_list_contact_points: [ 'List contact points', { text: ', named', field: 'contactPointName' }, @@ -60,6 +61,21 @@ export const GrafanaBlock: BlockConfig = { { text: 'Create contact point', field: 'contactPointNameNew', core: true }, { text: ', of type', field: 'contactPointType' }, ], + grafana_update_contact_point: [ + { text: 'Replace contact point', field: 'contactPointUid', core: true }, + { text: ', as type', field: 'contactPointType' }, + ], + grafana_delete_contact_point: [ + { text: 'Delete contact point', field: 'contactPointUid', core: true }, + ], + grafana_move_folder: [ + { text: 'Move folder', field: 'manageFolderUid', core: true }, + { text: ', under', field: 'newParentUid' }, + ], + grafana_get_alert_rule_group: [ + { text: 'Read alert rule group', field: 'ruleGroupName', core: true }, + { text: ', in folder', field: 'manageFolderUid' }, + ], grafana_create_annotation: [ { text: 'Create annotation', field: 'text', core: true }, { text: ', on dashboard', field: 'annotationDashboardUid' }, @@ -112,23 +128,28 @@ export const GrafanaBlock: BlockConfig = { { label: 'Delete Dashboard', id: 'grafana_delete_dashboard' }, { label: 'List Alert Rules', id: 'grafana_list_alert_rules' }, { label: 'Get Alert Rule', id: 'grafana_get_alert_rule' }, + { label: 'Get Alert Rule Group', id: 'grafana_get_alert_rule_group' }, { label: 'Create Alert Rule', id: 'grafana_create_alert_rule' }, { label: 'Update Alert Rule', id: 'grafana_update_alert_rule' }, { label: 'Delete Alert Rule', id: 'grafana_delete_alert_rule' }, { label: 'List Contact Points', id: 'grafana_list_contact_points' }, { label: 'Create Contact Point', id: 'grafana_create_contact_point' }, + { label: 'Update Contact Point', id: 'grafana_update_contact_point' }, + { label: 'Delete Contact Point', id: 'grafana_delete_contact_point' }, { label: 'Create Annotation', id: 'grafana_create_annotation' }, { label: 'List Annotations', id: 'grafana_list_annotations' }, { label: 'Update Annotation', id: 'grafana_update_annotation' }, { label: 'Delete Annotation', id: 'grafana_delete_annotation' }, { label: 'List Data Sources', id: 'grafana_list_data_sources' }, { label: 'Get Data Source', id: 'grafana_get_data_source' }, + { label: 'Query Data Source', id: 'grafana_query_data_source' }, { label: 'Check Data Source Health', id: 'grafana_check_data_source_health' }, { label: 'List Folders', id: 'grafana_list_folders' }, { label: 'Create Folder', id: 'grafana_create_folder' }, { label: 'Get Folder', id: 'grafana_get_folder' }, { label: 'Update Folder', id: 'grafana_update_folder' }, { label: 'Delete Folder', id: 'grafana_delete_folder' }, + { label: 'Move Folder', id: 'grafana_move_folder' }, { label: 'Get Health', id: 'grafana_get_health' }, ], value: () => 'grafana_list_dashboards', @@ -268,8 +289,12 @@ Return ONLY the search query - no explanations, no quotes, no extra text.`, title: 'Dashboard Title', type: 'short-input', placeholder: 'Enter dashboard title', - required: true, - condition: { field: 'operation', value: 'grafana_create_dashboard' }, + /** Update accepts a new title too, but only create demands one. */ + required: { field: 'operation', value: 'grafana_create_dashboard' }, + condition: { + field: 'operation', + value: ['grafana_create_dashboard', 'grafana_update_dashboard'], + }, wandConfig: { enabled: true, prompt: `Generate a professional Grafana dashboard title based on the user's description. @@ -624,6 +649,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, 'grafana_create_alert_rule', 'grafana_update_alert_rule', 'grafana_create_contact_point', + 'grafana_update_contact_point', ], }, }, @@ -871,7 +897,13 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`, required: true, condition: { field: 'operation', - value: ['grafana_get_folder', 'grafana_update_folder', 'grafana_delete_folder'], + value: [ + 'grafana_get_folder', + 'grafana_update_folder', + 'grafana_delete_folder', + 'grafana_move_folder', + 'grafana_get_alert_rule_group', + ], }, }, { @@ -890,6 +922,76 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`, condition: { field: 'operation', value: 'grafana_delete_folder' }, }, + { + id: 'dataSourceQueries', + title: 'Queries (JSON)', + type: 'long-input', + placeholder: '[{"refId":"A","datasource":{"uid":"P123"},"expr":"up","format":"time_series"}]', + required: { field: 'operation', value: 'grafana_query_data_source' }, + condition: { field: 'operation', value: 'grafana_query_data_source' }, + wandConfig: { + enabled: true, + prompt: `Generate a Grafana /api/ds/query queries array based on the user's request. + +Rules: +- Always a JSON array with at least one query object +- Every query needs a refId (e.g. "A") and datasource.uid +- Add the fields that data source expects: expr for Prometheus/Loki, rawSql for SQL sources +- format is "time_series" or "table" + +Examples: +- [{"refId":"A","datasource":{"uid":"PROM_UID"},"expr":"rate(http_requests_total[5m])","format":"time_series"}] +- [{"refId":"A","datasource":{"uid":"PG_UID"},"rawSql":"SELECT now() AS time, count(*) AS c FROM orders","format":"table"}] + +Return ONLY the JSON array - no explanations, no markdown, no extra text.`, + placeholder: 'Describe the metric or query you want...', + generationType: 'json-array', + }, + }, + { + id: 'queryFrom', + title: 'From', + type: 'short-input', + placeholder: 'now-1h or epoch milliseconds', + condition: { field: 'operation', value: 'grafana_query_data_source' }, + }, + { + id: 'queryTo', + title: 'To', + type: 'short-input', + placeholder: 'now or epoch milliseconds', + mode: 'advanced', + condition: { field: 'operation', value: 'grafana_query_data_source' }, + }, + { + id: 'contactPointUid', + title: 'Contact Point UID', + type: 'short-input', + placeholder: 'Enter contact point UID', + required: { + field: 'operation', + value: ['grafana_update_contact_point', 'grafana_delete_contact_point'], + }, + condition: { + field: 'operation', + value: ['grafana_update_contact_point', 'grafana_delete_contact_point'], + }, + }, + { + id: 'newParentUid', + title: 'New Parent Folder UID', + type: 'short-input', + placeholder: 'Leave empty to move to the root', + condition: { field: 'operation', value: 'grafana_move_folder' }, + }, + { + id: 'ruleGroupName', + title: 'Rule Group', + type: 'short-input', + placeholder: 'Enter rule group name', + required: { field: 'operation', value: 'grafana_get_alert_rule_group' }, + condition: { field: 'operation', value: 'grafana_get_alert_rule_group' }, + }, { id: 'contactPointName', title: 'Contact Point Name', @@ -903,8 +1005,14 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`, title: 'Contact Point Name', type: 'short-input', placeholder: 'Enter contact point name', - required: true, - condition: { field: 'operation', value: 'grafana_create_contact_point' }, + required: { + field: 'operation', + value: ['grafana_create_contact_point', 'grafana_update_contact_point'], + }, + condition: { + field: 'operation', + value: ['grafana_create_contact_point', 'grafana_update_contact_point'], + }, }, { id: 'contactPointType', @@ -920,8 +1028,14 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`, { label: 'Discord', id: 'discord' }, ], value: () => 'slack', - required: true, - condition: { field: 'operation', value: 'grafana_create_contact_point' }, + required: { + field: 'operation', + value: ['grafana_create_contact_point', 'grafana_update_contact_point'], + }, + condition: { + field: 'operation', + value: ['grafana_create_contact_point', 'grafana_update_contact_point'], + }, }, { id: 'contactPointSettings', @@ -929,7 +1043,10 @@ Return ONLY the folder title - no explanations, no quotes, no extra text.`, type: 'long-input', placeholder: 'JSON object of receiver settings (e.g., {"url":"https://hooks.slack.com/..."})', required: true, - condition: { field: 'operation', value: 'grafana_create_contact_point' }, + condition: { + field: 'operation', + value: ['grafana_create_contact_point', 'grafana_update_contact_point'], + }, wandConfig: { enabled: true, prompt: `Generate a Grafana contact point settings JSON object based on the user's description and receiver type. @@ -950,7 +1067,10 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, title: 'Disable Resolve Message', type: 'switch', mode: 'advanced', - condition: { field: 'operation', value: 'grafana_create_contact_point' }, + condition: { + field: 'operation', + value: ['grafana_create_contact_point', 'grafana_update_contact_point'], + }, }, ], tools: { @@ -980,6 +1100,11 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, 'grafana_update_folder', 'grafana_delete_folder', 'grafana_get_health', + 'grafana_update_contact_point', + 'grafana_delete_contact_point', + 'grafana_move_folder', + 'grafana_get_alert_rule_group', + 'grafana_query_data_source', ], config: { tool: (params) => params.operation, @@ -1011,6 +1136,24 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, if (params.contactPointType) result.type = params.contactPointType if (params.contactPointSettings) result.settings = params.contactPointSettings break + case 'grafana_update_contact_point': + if (params.contactPointNameNew) result.name = params.contactPointNameNew + if (params.contactPointType) result.type = params.contactPointType + if (params.contactPointSettings) result.settings = params.contactPointSettings + break + case 'grafana_query_data_source': + result.queries = params.dataSourceQueries + if (params.queryFrom) result.from = params.queryFrom + if (params.queryTo) result.to = params.queryTo + break + case 'grafana_move_folder': + result.folderUid = params.manageFolderUid + result.parentUid = params.newParentUid ?? '' + break + case 'grafana_get_alert_rule_group': + result.folderUid = params.manageFolderUid + result.ruleGroup = params.ruleGroupName + break case 'grafana_create_annotation': if (params.annotationTags) result.tags = params.annotationTags if (params.annotationDashboardUid) result.dashboardUid = params.annotationDashboardUid @@ -1062,6 +1205,18 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, }, }, inputs: { + dataSourceQueries: { type: 'string', description: 'JSON array of data source queries' }, + queryFrom: { type: 'string', description: 'Query range start, relative or epoch ms' }, + queryTo: { type: 'string', description: 'Query range end, relative or epoch ms' }, + contactPointUid: { + type: 'string', + description: 'UID of the contact point to update or delete', + }, + newParentUid: { + type: 'string', + description: 'UID of the new parent folder, empty for the root', + }, + ruleGroupName: { type: 'string', description: 'Name of the alert rule group' }, operation: { type: 'string', description: 'Operation to perform' }, baseUrl: { type: 'string', description: 'Grafana instance URL' }, apiKey: { type: 'string', description: 'Service Account Token' }, @@ -1071,7 +1226,11 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, folderUid: { type: 'string', description: 'Folder UID' }, tags: { type: 'string', description: 'Comma-separated tags' }, panels: { type: 'string', description: 'JSON array of panels' }, - message: { type: 'string', description: 'Commit message' }, + message: { + type: 'string', + description: + 'Message returned by Grafana — a confirmation for writes, or the diagnostic detail on a health check', + }, query: { type: 'string', description: 'Search query' }, tag: { type: 'string', description: 'Filter by tag' }, folderUIDs: { @@ -1104,12 +1263,19 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, type: 'string', description: 'JSON of per-rule notification settings', }, - record: { type: 'string', description: 'JSON of recording rule configuration' }, + record: { + type: 'string', + description: 'Recording rule configuration (metric, from, target_datasource_uid)', + }, disableProvenance: { type: 'boolean', description: 'Disable provenance tracking so the rule remains UI-editable', }, - annotations: { type: 'string', description: 'JSON of alert annotations' }, + annotations: { + type: 'string', + description: + 'For annotation operations, the matched annotations (id, dashboardUID, panelId, time, timeEnd, text, tags, newState, prevState, ...). For alert rules, the rule annotation map (summary, description, runbook_url)', + }, labels: { type: 'string', description: 'JSON of alert labels' }, overwrite: { type: 'boolean', description: 'Overwrite existing dashboard on version conflict' }, text: { type: 'string', description: 'Annotation text' }, @@ -1155,20 +1321,134 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, dataSourceUid: { type: 'string', description: 'Data source UID for health checks' }, }, outputs: { - version: { type: 'string', description: 'Grafana version' }, - database: { type: 'string', description: 'Database health status' }, + annotationId: { + type: 'number', + description: + 'The annotation that was updated, echoed from the request — Grafana answers a patch with only a message', + }, + details: { + type: 'json', + description: + 'Extra structured detail from a data source health check, when the plugin supplies any', + }, + results: { + type: 'json', + description: 'Raw data source query response, keyed by query refId', + }, + series: { + type: 'array', + description: + 'Query frames flattened into rows (refId, fields, rowCount, rows) so values can be read directly', + }, + interval: { + type: 'number', + description: 'Evaluation interval of an alert rule group, in seconds', + }, + folderUid: { type: 'string', description: 'UID of the folder holding the alert rule group' }, + title: { + type: 'string', + description: 'Title of the affected dashboard, folder, or alert rule', + }, + slug: { type: 'string', description: 'URL slug of the dashboard' }, + data: { + type: 'json', + description: 'Alert rule query and expression stages (refId, model, ...)', + }, + labels: { type: 'json', description: 'Alert rule labels used for routing and grouping' }, + parentUid: { type: 'string', description: 'UID of the parent folder, when nested' }, + parents: { + type: 'array', + description: 'Folder ancestry from the root down to the parent (uid, title, url)', + }, + created: { type: 'string', description: 'Creation timestamp of the folder' }, + createdBy: { type: 'string', description: 'Login that created the folder' }, + updatedBy: { type: 'string', description: 'Login that last updated the folder' }, + hasAcl: { + type: 'boolean', + description: 'Whether the folder carries an explicit permission list', + }, + canSave: { type: 'boolean', description: 'Whether the caller may save the folder' }, + canEdit: { type: 'boolean', description: 'Whether the caller may edit the folder' }, + canAdmin: { type: 'boolean', description: 'Whether the caller may administer the folder' }, + orgId: { type: 'number', description: 'Organization the data source belongs to' }, + access: { type: 'string', description: 'Data source access mode (proxy or direct)' }, + user: { type: 'string', description: 'Data source basic-auth-adjacent user field' }, + typeLogoUrl: { type: 'string', description: 'Logo URL for the data source type' }, + basicAuth: { type: 'boolean', description: 'Whether the data source uses basic auth' }, + basicAuthUser: { type: 'string', description: 'Basic-auth user for the data source' }, + withCredentials: { + type: 'boolean', + description: 'Whether the data source sends credentials cross-origin', + }, + isDefault: { type: 'boolean', description: 'Whether this is the default data source' }, + jsonData: { type: 'json', description: 'Non-secret data source configuration' }, + secureJsonFields: { + type: 'json', + description: 'Which secret data source fields are set (names only, never values)', + }, + readOnly: { type: 'boolean', description: 'Whether the data source is provisioned read-only' }, + disableResolveMessage: { + type: 'boolean', + description: 'Whether the contact point suppresses resolve notifications', + }, + version: { + type: 'number', + description: + 'Revision number of the dashboard, folder, or data source. Get Health instead returns the Grafana version as a string', + }, + database: { + type: 'string', + description: + 'Database name of the data source; for Get Health, the Grafana database status (e.g. ok)', + }, commit: { type: 'string', description: 'Git commit hash of the Grafana build' }, - status: { type: 'string', description: 'Health status (e.g., data source health)' }, - dashboard: { type: 'json', description: 'Dashboard JSON' }, - meta: { type: 'json', description: 'Dashboard metadata' }, - dashboards: { type: 'json', description: 'List of dashboards' }, - uid: { type: 'string', description: 'Created/updated UID' }, - url: { type: 'string', description: 'Dashboard URL' }, - rules: { type: 'json', description: 'Alert rules list' }, - contactPoints: { type: 'json', description: 'Contact points list' }, - name: { type: 'string', description: 'Name of the created contact point' }, - type: { type: 'string', description: 'Type of the created contact point' }, - settings: { type: 'json', description: 'Contact point receiver settings' }, + status: { + type: 'string', + description: + 'Outcome reported by Grafana — a data source health verdict, or the save status of a dashboard write', + }, + dashboard: { + type: 'json', + description: 'Full dashboard JSON as stored by Grafana (panels, templating, time, ...)', + }, + meta: { + type: 'json', + description: 'Dashboard metadata (isStarred, url, folderId, folderUid, slug)', + }, + dashboards: { + type: 'array', + description: + 'Matched dashboards (id, uid, title, uri, url, type, tags, isStarred, folderId, folderUid, folderTitle, folderUrl)', + }, + uid: { + type: 'string', + description: + 'UID of the affected resource — dashboard, folder, alert rule, data source, or contact point, depending on the operation', + }, + url: { + type: 'string', + description: 'URL of the affected dashboard or folder; the connection URL for a data source', + }, + rules: { + type: 'array', + description: + 'Provisioned alert rules (uid, title, folderUID, ruleGroup, condition, data, for, labels, annotations, isPaused, noDataState, execErrState, provenance, ...)', + }, + contactPoints: { + type: 'array', + description: 'Contact points (uid, name, type, settings, disableResolveMessage, provenance)', + }, + name: { type: 'string', description: 'Name of the affected contact point or data source' }, + type: { + type: 'string', + description: + 'Type of the affected contact point (e.g. slack) or data source (e.g. prometheus)', + }, + settings: { + type: 'json', + description: + 'Contact point receiver settings — the shape depends on the receiver type, e.g. url and recipient for a Slack receiver', + }, condition: { type: 'string', description: 'Alert condition refId' }, for: { type: 'string', description: 'Duration the condition must hold before firing' }, keepFiringFor: { @@ -1189,10 +1469,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, notification_settings: { type: 'json', description: 'Per-rule notification settings' }, record: { type: 'json', description: 'Recording rule configuration' }, updated: { type: 'string', description: 'Last update timestamp' }, - annotations: { type: 'json', description: 'Annotations list' }, - id: { type: 'number', description: 'Annotation ID' }, - dataSources: { type: 'json', description: 'Data sources list' }, - folders: { type: 'json', description: 'Folders list' }, + annotations: { type: 'array', description: 'Annotations list' }, + id: { + type: 'number', + description: + 'Numeric id of the affected resource — annotation, alert rule, dashboard, folder, or data source, depending on the operation', + }, + dataSources: { + type: 'array', + description: + 'Data sources (id, uid, orgId, name, type, typeLogoUrl, access, url, database, isDefault, jsonData, readOnly, ...)', + }, + folders: { + type: 'array', + description: 'Folders (id, uid, title, and parentUid when nested folders are enabled)', + }, message: { type: 'string', description: 'Status message' }, }, } @@ -1205,7 +1496,7 @@ export const GrafanaBlockMeta = { icon: GrafanaIcon, title: 'Grafana alert auto-context', prompt: - 'Build a scheduled workflow that polls Grafana for firing alert rules, pulls related logs and recent deploys, summarizes them with an agent, and posts the enriched alert to PagerDuty and Slack.', + 'Build a scheduled workflow that reads Grafana alert-state annotations to find rules that just started firing, queries the underlying data source for the current metric value, summarizes the two together with an agent, and posts the enriched alert to PagerDuty and Slack.', modules: ['scheduled', 'agent', 'workflows'], category: 'engineering', tags: ['devops', 'monitoring'], @@ -1215,7 +1506,7 @@ export const GrafanaBlockMeta = { icon: GrafanaIcon, title: 'Grafana SLO scorecard', prompt: - 'Create a scheduled weekly workflow that queries Grafana for SLO compliance across services, calculates burn rates, and writes a scorecard to a tables-based SRE review board.', + 'Create a scheduled weekly workflow that runs SLI queries against a Grafana data source for each service, calculates error budget burn rates from the returned series, and writes a scorecard to a tables-based SRE review board.', modules: ['scheduled', 'tables', 'agent', 'workflows'], category: 'engineering', tags: ['devops', 'reporting'], @@ -1233,7 +1524,7 @@ export const GrafanaBlockMeta = { icon: GrafanaIcon, title: 'Grafana metric export', prompt: - 'Create a workflow that exports Grafana metric queries on schedule into a Sim table, so the data can be combined with business metrics for unified reporting.', + 'Create a workflow that runs a set of Grafana data source queries on schedule and writes the returned series into a Sim table, so the metrics can be combined with business data for unified reporting.', modules: ['scheduled', 'tables', 'agent', 'workflows'], category: 'engineering', tags: ['analysis', 'sync'], @@ -1262,7 +1553,7 @@ export const GrafanaBlockMeta = { icon: GrafanaIcon, title: 'Grafana + Linear feature-impact', prompt: - 'Build a scheduled workflow that polls Grafana for metric regressions correlated with recent Linear releases and posts a regression review to the team Slack with the suspected change.', + 'Build a scheduled workflow that queries a Grafana data source for latency and error rates, compares each series against the prior period to spot regressions, correlates them with recent Linear releases, and posts a regression review to the team Slack with the suspected change.', modules: ['scheduled', 'agent', 'workflows'], category: 'engineering', tags: ['engineering', 'analysis'], @@ -1282,7 +1573,7 @@ export const GrafanaBlockMeta = { description: 'List Grafana alert rules and surface those currently firing with their contact points.', content: - '# Review Firing Alerts\n\nProduce a snapshot of alerting health for an on-call handoff or incident triage.\n\n## Steps\n1. List alert rules and capture each rule name, condition, and current state.\n2. Get details on rules that are firing or in a pending state.\n3. List contact points so each firing rule can be mapped to who gets notified.\n4. Group findings by severity or folder.\n\n## Output\nReturn a list of firing and pending alerts with rule name, state, and notification target, plus a count of healthy rules. Suitable for an on-call digest.', + "# Review Firing Alerts\n\nProduce a snapshot of alerting health for an on-call handoff or incident triage.\n\n## Steps\n1. Run List Annotations with `type: 'alert'` over the window you care about. Alert-state transitions are recorded as annotations and carry `newState` and `prevState`, which is how you find what actually fired — the alert rule operations return rule *definitions*, never live instance state.\n2. Run List Alert Rules to join each firing rule id back to its title, folder, condition, and labels.\n3. Optionally run Query Data Source on the rule's own query to see how far the metric is from its threshold right now.\n4. Run List Contact Points, and Get Alert Rule Group for the evaluation interval, so each firing rule maps to who gets notified and how often it is checked.\n5. Group findings by severity label or folder.\n\n## Output\nReturn the rules that transitioned into a firing state in the window, each with its title, the transition, its notification target, and its group evaluation interval. Say explicitly that this is derived from state-change annotations rather than a live instance snapshot, and give the window covered.", }, { name: 'audit-dashboards', diff --git a/apps/sim/blocks/blocks/rabbitmq.ts b/apps/sim/blocks/blocks/rabbitmq.ts new file mode 100644 index 00000000000..bc155c9122c --- /dev/null +++ b/apps/sim/blocks/blocks/rabbitmq.ts @@ -0,0 +1,1012 @@ +import { RabbitmqIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { RabbitmqResponse } from '@/tools/rabbitmq/types' + +/** Operations that act on a single named queue. */ +const QUEUE_OPERATIONS = [ + 'rabbitmq_get_messages', + 'rabbitmq_get_queue', + 'rabbitmq_create_queue', + 'rabbitmq_delete_queue', + 'rabbitmq_purge_queue', + 'rabbitmq_list_bindings', +] + +/** Operations that act on a single named exchange. */ +const EXCHANGE_OPERATIONS = [ + 'rabbitmq_get_exchange', + 'rabbitmq_create_exchange', + 'rabbitmq_delete_exchange', + 'rabbitmq_list_exchange_bindings', +] + +/** Operations that expose page, page size, and name filtering. */ +const PAGINATED_OPERATIONS = [ + 'rabbitmq_list_queues', + 'rabbitmq_list_exchanges', + 'rabbitmq_list_connections', + 'rabbitmq_list_channels', +] + +const POLICY_OPERATIONS = ['rabbitmq_create_policy', 'rabbitmq_delete_policy'] + +export const RabbitmqBlock: BlockConfig = { + type: 'rabbitmq', + name: 'RabbitMQ', + description: 'Publish and read messages and manage queues in RabbitMQ', + authMode: AuthMode.ApiKey, + longDescription: + 'Connect agents to a RabbitMQ broker through its Management HTTP API. Publish messages to exchanges, read messages off queues, declare queues, exchanges, bindings, and policies, and inspect broker health, queue depth, consumers, connections, and cluster nodes. Works with self-hosted brokers and managed offerings such as CloudAMQP as long as the management plugin is reachable.', + docsLink: 'https://docs.sim.ai/integrations/rabbitmq', + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: '#FFFFFF', + icon: RabbitmqIcon, + + canvasPresentation: { + defaultTitle: 'RabbitMQ', + sentences: { + byOperation: { + rabbitmq_publish_message: [ + { text: 'Publish a message to', field: 'exchange', core: true }, + { text: 'with routing key', field: 'routingKey', core: true }, + ], + rabbitmq_get_messages: [{ text: 'Read messages from', field: 'queue', core: true }], + rabbitmq_list_queues: [ + 'List queues', + { text: 'in vhost', field: 'vhost' }, + { text: ', matching', field: 'name' }, + ], + rabbitmq_get_queue: [{ text: 'Read queue', field: 'queue', core: true }], + rabbitmq_create_queue: [{ text: 'Declare queue', field: 'queue', core: true }], + rabbitmq_delete_queue: [ + { + text: 'Delete queue', + field: 'queue', + core: true, + after: 'and every message in it', + }, + ], + rabbitmq_purge_queue: [{ text: 'Discard every message in', field: 'queue', core: true }], + rabbitmq_list_exchanges: [ + 'List exchanges', + { text: 'in vhost', field: 'vhost' }, + { text: ', matching', field: 'name' }, + ], + rabbitmq_get_exchange: [{ text: 'Read exchange', field: 'exchange', core: true }], + rabbitmq_create_exchange: [ + { text: 'Declare exchange', field: 'exchange', core: true }, + { text: 'of type', field: 'exchangeType' }, + ], + rabbitmq_delete_exchange: [ + { + text: 'Delete exchange', + field: 'exchange', + core: true, + after: 'and every binding on it', + }, + ], + rabbitmq_list_bindings: [{ text: 'List bindings into', field: 'queue', core: true }], + rabbitmq_list_exchange_bindings: [ + { text: 'List everything routed out of', field: 'exchange', core: true }, + ], + rabbitmq_create_binding: [ + { text: 'Bind', field: 'queue', core: true }, + { text: 'to exchange', field: 'exchange', core: true }, + { text: 'on routing key', field: 'routingKey' }, + ], + rabbitmq_delete_binding: [ + { text: 'Unbind', field: 'destination', core: true }, + { text: 'from exchange', field: 'exchange', core: true }, + ], + rabbitmq_get_overview: ['Read broker status and totals'], + rabbitmq_health_check: [{ text: 'Run health check', field: 'check', core: true }], + rabbitmq_list_nodes: ['List cluster nodes and their resource alarms'], + rabbitmq_list_vhosts: ['List virtual hosts'], + rabbitmq_list_connections: ['List client connections'], + rabbitmq_list_channels: ['List open channels'], + rabbitmq_list_consumers: ['List consumers', { text: 'in vhost', field: 'vhost' }], + rabbitmq_list_policies: ['List policies', { text: 'in vhost', field: 'vhost' }], + rabbitmq_create_policy: [ + { text: 'Apply policy', field: 'policyName', core: true }, + { text: 'to names matching', field: 'pattern', core: true }, + ], + rabbitmq_delete_policy: [{ text: 'Delete policy', field: 'policyName', core: true }], + }, + }, + }, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Publish Message', id: 'rabbitmq_publish_message' }, + { label: 'Get Messages', id: 'rabbitmq_get_messages' }, + { label: 'List Queues', id: 'rabbitmq_list_queues' }, + { label: 'Get Queue', id: 'rabbitmq_get_queue' }, + { label: 'Create Queue', id: 'rabbitmq_create_queue' }, + { label: 'Delete Queue', id: 'rabbitmq_delete_queue' }, + { label: 'Purge Queue', id: 'rabbitmq_purge_queue' }, + { label: 'List Exchanges', id: 'rabbitmq_list_exchanges' }, + { label: 'Get Exchange', id: 'rabbitmq_get_exchange' }, + { label: 'Create Exchange', id: 'rabbitmq_create_exchange' }, + { label: 'Delete Exchange', id: 'rabbitmq_delete_exchange' }, + { label: 'List Queue Bindings', id: 'rabbitmq_list_bindings' }, + { label: 'List Exchange Bindings', id: 'rabbitmq_list_exchange_bindings' }, + { label: 'Create Binding', id: 'rabbitmq_create_binding' }, + { label: 'Delete Binding', id: 'rabbitmq_delete_binding' }, + { label: 'Get Overview', id: 'rabbitmq_get_overview' }, + { label: 'Health Check', id: 'rabbitmq_health_check' }, + { label: 'List Nodes', id: 'rabbitmq_list_nodes' }, + { label: 'List Virtual Hosts', id: 'rabbitmq_list_vhosts' }, + { label: 'List Connections', id: 'rabbitmq_list_connections' }, + { label: 'List Channels', id: 'rabbitmq_list_channels' }, + { label: 'List Consumers', id: 'rabbitmq_list_consumers' }, + { label: 'List Policies', id: 'rabbitmq_list_policies' }, + { label: 'Create Policy', id: 'rabbitmq_create_policy' }, + { label: 'Delete Policy', id: 'rabbitmq_delete_policy' }, + ], + value: () => 'rabbitmq_publish_message', + }, + + { + id: 'host', + title: 'Management URL', + type: 'short-input', + placeholder: 'https://rabbit.example.com:15672', + description: + 'Base URL of the RabbitMQ management plugin, including scheme and port. Must use https unless the broker is on a loopback host', + required: true, + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Enter username', + required: true, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + placeholder: 'Enter password', + password: true, + required: true, + }, + { + id: 'vhost', + title: 'Virtual Host', + type: 'short-input', + placeholder: '/', + description: 'Virtual host to operate on. Leave empty to use the default vhost', + mode: 'advanced', + }, + + { + id: 'exchange', + title: 'Exchange', + type: 'short-input', + placeholder: 'orders (leave empty for the default exchange)', + description: + 'Publishing to the default exchange routes by queue name, so set the routing key to the target queue', + condition: { + field: 'operation', + value: [ + 'rabbitmq_publish_message', + 'rabbitmq_create_binding', + 'rabbitmq_delete_binding', + ...EXCHANGE_OPERATIONS, + ], + }, + required: { + field: 'operation', + value: [ + 'rabbitmq_create_binding', + 'rabbitmq_delete_binding', + 'rabbitmq_create_exchange', + 'rabbitmq_delete_exchange', + ], + }, + }, + { + id: 'exchangeType', + title: 'Exchange Type', + type: 'dropdown', + options: [ + { label: 'Direct — exact routing key match', id: 'direct' }, + { label: 'Topic — wildcard routing key patterns', id: 'topic' }, + { label: 'Fanout — every bound queue', id: 'fanout' }, + { label: 'Headers — match on binding arguments', id: 'headers' }, + ], + value: () => 'direct', + condition: { field: 'operation', value: 'rabbitmq_create_exchange' }, + }, + { + id: 'internal', + title: 'Internal', + type: 'switch', + description: 'Internal exchanges can only be bound from another exchange, not published to', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_create_exchange' }, + }, + { + id: 'queue', + title: 'Queue', + type: 'short-input', + placeholder: 'orders.processing', + condition: { + field: 'operation', + value: [...QUEUE_OPERATIONS, 'rabbitmq_create_binding'], + }, + required: { + field: 'operation', + value: [...QUEUE_OPERATIONS, 'rabbitmq_create_binding'], + }, + }, + { + id: 'destination', + title: 'Destination', + type: 'short-input', + placeholder: 'orders.processing', + description: 'Queue or exchange the binding routes to', + condition: { field: 'operation', value: 'rabbitmq_delete_binding' }, + required: { field: 'operation', value: 'rabbitmq_delete_binding' }, + }, + { + id: 'destinationType', + title: 'Destination Type', + type: 'dropdown', + options: [ + { label: 'Queue', id: 'queue' }, + { label: 'Exchange', id: 'exchange' }, + ], + value: () => 'queue', + mode: 'advanced', + condition: { + field: 'operation', + value: ['rabbitmq_create_binding', 'rabbitmq_delete_binding'], + }, + }, + { + id: 'propertiesKey', + title: 'Binding Key', + type: 'short-input', + placeholder: 'orders.created', + description: + 'Broker identifier for the binding, from List Bindings. Use ~ for a binding with an empty routing key', + condition: { field: 'operation', value: 'rabbitmq_delete_binding' }, + required: { field: 'operation', value: 'rabbitmq_delete_binding' }, + }, + { + id: 'routingKey', + title: 'Routing Key', + type: 'short-input', + placeholder: 'orders.created', + condition: { + field: 'operation', + value: ['rabbitmq_publish_message', 'rabbitmq_create_binding'], + }, + required: { field: 'operation', value: 'rabbitmq_publish_message' }, + }, + { + id: 'payload', + title: 'Message', + type: 'long-input', + placeholder: '{ "orderId": "1234", "status": "created" }', + condition: { field: 'operation', value: 'rabbitmq_publish_message' }, + required: { field: 'operation', value: 'rabbitmq_publish_message' }, + }, + { + id: 'properties', + title: 'Message Properties', + type: 'code', + placeholder: '{ "delivery_mode": 2, "content_type": "application/json" }', + description: 'AMQP basic properties. delivery_mode 2 makes the message persistent', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_publish_message' }, + wandConfig: { + enabled: true, + prompt: `Generate AMQP basic properties as a JSON object for a RabbitMQ publish based on the user's description. +Common properties: +- "delivery_mode": 1 for transient, 2 for persistent +- "content_type": MIME type of the payload, e.g. "application/json" +- "correlation_id", "reply_to", "message_id": request/reply correlation +- "expiration": per-message TTL in milliseconds, as a string +- "priority": integer priority for priority queues + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the message properties you need...', + generationType: 'json-object', + }, + }, + { + id: 'headers', + title: 'Message Headers', + type: 'code', + placeholder: '{ "source": "sim", "tenant": "acme" }', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_publish_message' }, + wandConfig: { + enabled: true, + prompt: `Generate RabbitMQ message headers as a flat JSON object based on the user's description. +Headers are arbitrary key/value pairs used for routing on headers exchanges and for downstream metadata. + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the headers you want to attach...', + generationType: 'json-object', + }, + }, + { + id: 'payloadEncoding', + title: 'Payload Encoding', + type: 'dropdown', + options: [ + { label: 'String', id: 'string' }, + { label: 'Base64', id: 'base64' }, + ], + value: () => 'string', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_publish_message' }, + }, + + { + id: 'count', + title: 'Message Count', + type: 'short-input', + placeholder: '1', + description: 'Maximum number of messages to retrieve, up to 50', + condition: { field: 'operation', value: 'rabbitmq_get_messages' }, + }, + { + id: 'ackmode', + title: 'Acknowledgement Mode', + type: 'dropdown', + options: [ + { label: 'Ack, requeue (leave messages in the queue)', id: 'ack_requeue_true' }, + { label: 'Ack, discard (remove messages from the queue)', id: 'ack_requeue_false' }, + { label: 'Reject, requeue', id: 'reject_requeue_true' }, + { label: 'Reject, discard', id: 'reject_requeue_false' }, + ], + value: () => 'ack_requeue_true', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_get_messages' }, + }, + { + id: 'encoding', + title: 'Payload Decoding', + type: 'dropdown', + options: [ + { label: 'Auto (text where possible)', id: 'auto' }, + { label: 'Base64', id: 'base64' }, + ], + value: () => 'auto', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_get_messages' }, + }, + { + id: 'truncate', + title: 'Truncate Payloads (bytes)', + type: 'short-input', + placeholder: '50000', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_get_messages' }, + }, + + { + id: 'name', + title: 'Name Filter', + type: 'short-input', + placeholder: 'orders', + description: 'Only return entries whose name matches this value', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + }, + { + id: 'useRegex', + title: 'Treat Filter as Regex', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: '1', + mode: 'advanced', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + }, + { + id: 'pageSize', + title: 'Page Size', + type: 'short-input', + placeholder: '50', + description: 'Results per page, up to 500', + mode: 'advanced', + condition: { field: 'operation', value: PAGINATED_OPERATIONS }, + }, + + { + id: 'durable', + title: 'Durable', + type: 'switch', + defaultValue: true, + description: 'Durable queues and exchanges survive a broker restart', + condition: { + field: 'operation', + value: ['rabbitmq_create_queue', 'rabbitmq_create_exchange'], + }, + }, + { + id: 'autoDelete', + title: 'Auto Delete', + type: 'switch', + description: + 'Delete the queue once its last consumer disconnects, or the exchange once its last binding is removed', + mode: 'advanced', + condition: { + field: 'operation', + value: ['rabbitmq_create_queue', 'rabbitmq_create_exchange'], + }, + }, + { + id: 'arguments', + title: 'Arguments', + type: 'code', + placeholder: '{ "x-queue-type": "quorum" }', + mode: 'advanced', + condition: { + field: 'operation', + value: ['rabbitmq_create_queue', 'rabbitmq_create_exchange', 'rabbitmq_create_binding'], + }, + wandConfig: { + enabled: true, + prompt: `Generate RabbitMQ arguments as a JSON object based on the user's description. +For queue declarations, common arguments are: +- "x-queue-type": "classic", "quorum", or "stream" +- "x-message-ttl": per-queue message TTL in milliseconds +- "x-max-length" / "x-max-length-bytes": queue length limits +- "x-dead-letter-exchange" / "x-dead-letter-routing-key": dead lettering +For exchange declarations, use "alternate-exchange" to capture messages that match no binding. +For binding declarations on a headers exchange, use "x-match" ("all" or "any") plus the header names and values to match. + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the arguments you need...', + generationType: 'json-object', + }, + }, + + { + id: 'ifUnused', + title: 'Only If Unused', + type: 'switch', + description: + 'Fail instead of deleting when the queue still has consumers, or the exchange still has bindings', + mode: 'advanced', + condition: { + field: 'operation', + value: ['rabbitmq_delete_queue', 'rabbitmq_delete_exchange'], + }, + }, + { + id: 'ifEmpty', + title: 'Only If Empty', + type: 'switch', + description: 'Fail instead of deleting when the queue still holds messages', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_delete_queue' }, + }, + + { + id: 'check', + title: 'Check', + type: 'dropdown', + options: [ + { label: 'Resource alarms (cluster-wide)', id: 'alarms' }, + { label: 'Resource alarms (this node)', id: 'local-alarms' }, + { label: 'Virtual hosts are running', id: 'virtual-hosts' }, + { label: 'Quorum queues would survive losing this node', id: 'node-is-quorum-critical' }, + { label: 'A port has a listener', id: 'port-listener' }, + { label: 'A protocol has a listener', id: 'protocol-listener' }, + { label: 'Certificates are not expiring', id: 'certificate-expiration' }, + ], + value: () => 'alarms', + condition: { field: 'operation', value: 'rabbitmq_health_check' }, + }, + { + id: 'port', + title: 'Port', + type: 'short-input', + placeholder: '5672', + condition: { + field: 'operation', + value: 'rabbitmq_health_check', + and: { field: 'check', value: 'port-listener' }, + }, + required: { + field: 'operation', + value: 'rabbitmq_health_check', + and: { field: 'check', value: 'port-listener' }, + }, + }, + { + id: 'protocol', + title: 'Protocol', + type: 'short-input', + placeholder: 'amqp', + condition: { + field: 'operation', + value: 'rabbitmq_health_check', + and: { field: 'check', value: 'protocol-listener' }, + }, + required: { + field: 'operation', + value: 'rabbitmq_health_check', + and: { field: 'check', value: 'protocol-listener' }, + }, + }, + { + id: 'within', + title: 'Expiring Within', + type: 'short-input', + placeholder: '1', + condition: { + field: 'operation', + value: 'rabbitmq_health_check', + and: { field: 'check', value: 'certificate-expiration' }, + }, + required: { + field: 'operation', + value: 'rabbitmq_health_check', + and: { field: 'check', value: 'certificate-expiration' }, + }, + }, + { + id: 'unit', + title: 'Unit', + type: 'dropdown', + options: [ + { label: 'Days', id: 'days' }, + { label: 'Weeks', id: 'weeks' }, + { label: 'Months', id: 'months' }, + { label: 'Years', id: 'years' }, + ], + value: () => 'months', + condition: { + field: 'operation', + value: 'rabbitmq_health_check', + and: { field: 'check', value: 'certificate-expiration' }, + }, + }, + + { + id: 'policyName', + title: 'Policy Name', + type: 'short-input', + placeholder: 'orders-dead-lettering', + condition: { field: 'operation', value: POLICY_OPERATIONS }, + required: { field: 'operation', value: POLICY_OPERATIONS }, + }, + { + id: 'pattern', + title: 'Name Pattern', + type: 'short-input', + placeholder: '^orders\\.', + description: 'Regular expression matched against queue or exchange names', + condition: { field: 'operation', value: 'rabbitmq_create_policy' }, + required: { field: 'operation', value: 'rabbitmq_create_policy' }, + }, + { + id: 'definition', + title: 'Definition', + type: 'code', + placeholder: '{ "dead-letter-exchange": "dlx", "message-ttl": 86400000 }', + condition: { field: 'operation', value: 'rabbitmq_create_policy' }, + required: { field: 'operation', value: 'rabbitmq_create_policy' }, + wandConfig: { + enabled: true, + prompt: `Generate a RabbitMQ policy definition as a JSON object based on the user's description. +Common definition keys: +- "dead-letter-exchange" / "dead-letter-routing-key": where rejected or expired messages go +- "message-ttl": message lifetime in milliseconds +- "expires": how long an unused queue survives, in milliseconds +- "max-length" / "max-length-bytes": queue length limits +- "overflow": "drop-head", "reject-publish", or "reject-publish-dlx" +- "delivery-limit": redeliveries allowed before dead-lettering, for quorum queues +- "queue-mode": "lazy" to keep messages on disk + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the policy settings you need...', + generationType: 'json-object', + }, + }, + { + id: 'applyTo', + title: 'Apply To', + type: 'dropdown', + options: [ + { label: 'Queues', id: 'queues' }, + { label: 'Classic queues', id: 'classic_queues' }, + { label: 'Quorum queues', id: 'quorum_queues' }, + { label: 'Streams', id: 'streams' }, + { label: 'Exchanges', id: 'exchanges' }, + { label: 'All', id: 'all' }, + ], + value: () => 'queues', + condition: { field: 'operation', value: 'rabbitmq_create_policy' }, + }, + { + id: 'priority', + title: 'Priority', + type: 'short-input', + placeholder: '0', + description: + 'When several policies match a resource only the highest-priority one applies — they do not merge', + mode: 'advanced', + condition: { field: 'operation', value: 'rabbitmq_create_policy' }, + }, + ], + + tools: { + access: [ + 'rabbitmq_publish_message', + 'rabbitmq_get_messages', + 'rabbitmq_list_queues', + 'rabbitmq_get_queue', + 'rabbitmq_create_queue', + 'rabbitmq_delete_queue', + 'rabbitmq_purge_queue', + 'rabbitmq_list_exchanges', + 'rabbitmq_get_exchange', + 'rabbitmq_create_exchange', + 'rabbitmq_delete_exchange', + 'rabbitmq_list_bindings', + 'rabbitmq_list_exchange_bindings', + 'rabbitmq_create_binding', + 'rabbitmq_delete_binding', + 'rabbitmq_get_overview', + 'rabbitmq_health_check', + 'rabbitmq_list_nodes', + 'rabbitmq_list_vhosts', + 'rabbitmq_list_connections', + 'rabbitmq_list_channels', + 'rabbitmq_list_consumers', + 'rabbitmq_list_policies', + 'rabbitmq_create_policy', + 'rabbitmq_delete_policy', + ], + config: { + tool: (params) => params.operation || 'rabbitmq_publish_message', + params: (params) => { + const result: Record = {} + if (params.count) result.count = Number(params.count) + if (params.truncate) result.truncate = Number(params.truncate) + if (params.page) result.page = Number(params.page) + if (params.pageSize) result.pageSize = Number(params.pageSize) + if (params.port) result.port = Number(params.port) + if (params.within) result.within = Number(params.within) + if (params.priority) result.priority = Number(params.priority) + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + host: { type: 'string', description: 'RabbitMQ Management API base URL' }, + username: { type: 'string', description: 'RabbitMQ username' }, + password: { type: 'string', description: 'RabbitMQ password' }, + vhost: { type: 'string', description: 'Virtual host to operate on' }, + exchange: { type: 'string', description: 'Exchange name' }, + exchangeType: { type: 'string', description: 'Exchange routing behaviour' }, + internal: { type: 'boolean', description: 'Whether a declared exchange is internal' }, + queue: { type: 'string', description: 'Queue name' }, + destination: { type: 'string', description: 'Binding destination name' }, + destinationType: { type: 'string', description: 'Binding destination kind: queue or exchange' }, + propertiesKey: { type: 'string', description: 'Broker identifier for a binding' }, + routingKey: { type: 'string', description: 'Routing key' }, + payload: { type: 'string', description: 'Message body to publish' }, + payloadEncoding: { type: 'string', description: 'Payload encoding: string or base64' }, + properties: { type: 'string', description: 'AMQP basic properties as JSON' }, + headers: { type: 'string', description: 'Message headers as JSON' }, + count: { type: 'number', description: 'Maximum messages to retrieve' }, + ackmode: { type: 'string', description: 'How retrieved messages are acknowledged' }, + encoding: { type: 'string', description: 'How retrieved payloads are decoded' }, + truncate: { type: 'number', description: 'Truncate payloads longer than this many bytes' }, + page: { type: 'number', description: 'Page of results to return' }, + pageSize: { type: 'number', description: 'Results per page' }, + name: { type: 'string', description: 'Name filter for list operations' }, + useRegex: { type: 'boolean', description: 'Treat the name filter as a regular expression' }, + durable: { type: 'boolean', description: 'Whether a declared queue or exchange is durable' }, + autoDelete: { + type: 'boolean', + description: 'Whether a declared queue or exchange auto-deletes', + }, + arguments: { type: 'string', description: 'Queue, exchange, or binding arguments as JSON' }, + ifUnused: { type: 'boolean', description: 'Only delete when unused' }, + ifEmpty: { type: 'boolean', description: 'Only delete the queue when it holds no messages' }, + check: { type: 'string', description: 'Health check to run' }, + port: { type: 'number', description: 'Port for the port-listener health check' }, + protocol: { type: 'string', description: 'Protocol for the protocol-listener health check' }, + within: { type: 'number', description: 'Certificate expiration window' }, + unit: { type: 'string', description: 'Unit for the certificate expiration window' }, + policyName: { type: 'string', description: 'Policy name' }, + pattern: { type: 'string', description: 'Regular expression the policy matches names with' }, + definition: { type: 'string', description: 'Policy definition as JSON' }, + applyTo: { type: 'string', description: 'What the policy applies to' }, + priority: { type: 'number', description: 'Policy priority' }, + }, + + outputs: { + routed: { + type: 'boolean', + description: 'Whether a published message reached at least one queue', + }, + exchange: { + type: 'any', + description: 'Exchange name, or the exchange record for Get Exchange', + }, + exchangeName: { type: 'string', description: 'Exchange the operation acted on' }, + routingKey: { type: 'string', description: 'Routing key involved in the operation' }, + queueName: { type: 'string', description: 'Queue the operation acted on' }, + queue: { + type: 'json', + description: + 'Queue record from Get Queue (name, vhost, type, state, durable, messages, consumers)', + }, + destination: { type: 'string', description: 'Binding destination the operation acted on' }, + vhost: { type: 'string', description: 'Virtual host the operation ran against' }, + messages: { + type: 'json', + description: + 'Messages retrieved from a queue: [{payload, payloadBytes, truncated, exchange, routingKey, redelivered, messageCount, properties}]', + }, + queues: { + type: 'json', + description: + 'Queues returned by List Queues: [{name, vhost, type, state, durable, messages, messagesReady, consumers}]', + }, + exchanges: { + type: 'json', + description: + 'Exchanges returned by List Exchanges: [{name, vhost, type, durable, autoDelete, internal, arguments}]', + }, + bindings: { + type: 'json', + description: + 'Bindings returned by the binding list operations: [{source, destination, destinationType, routingKey, propertiesKey, arguments}]', + }, + vhosts: { + type: 'json', + description: + 'Virtual hosts returned by List Virtual Hosts: [{name, description, tags, tracing, messages}]', + }, + connections: { + type: 'json', + description: + 'Connections returned by List Connections: [{name, user, vhost, state, protocol, node, channels, peerHost, ssl}]', + }, + channels: { + type: 'json', + description: + 'Channels returned by List Channels: [{name, number, user, vhost, state, consumerCount, prefetchCount, messagesUnacknowledged}]', + }, + consumers: { + type: 'json', + description: + 'Consumers returned by List Consumers: [{consumerTag, queue, vhost, ackRequired, active, activityStatus, prefetchCount}]', + }, + nodes: { + type: 'json', + description: + 'Nodes returned by List Nodes: [{name, type, running, memUsed, memAlarm, diskFree, diskFreeAlarm, fdUsed, partitions}]', + }, + policies: { + type: 'json', + description: + 'Policies returned by List Policies: [{name, vhost, pattern, applyTo, priority, definition}]', + }, + policyName: { type: 'string', description: 'Policy the operation acted on' }, + count: { type: 'number', description: 'Number of records returned' }, + totalCount: { type: 'number', description: 'Total records available before filtering' }, + page: { type: 'number', description: 'Page number returned' }, + pageCount: { type: 'number', description: 'Total number of pages' }, + created: { type: 'boolean', description: 'Whether the resource was created' }, + deleted: { type: 'boolean', description: 'Whether the resource was deleted' }, + purged: { type: 'boolean', description: 'Whether the queue was purged' }, + propertiesKey: { type: 'string', description: 'Broker identifier of a binding' }, + check: { type: 'string', description: 'Health check that was run' }, + healthy: { type: 'boolean', description: 'Whether the health check passed' }, + status: { type: 'string', description: 'Raw health check status reported by the broker' }, + reason: { type: 'string', description: 'Why a health check failed' }, + details: { type: 'json', description: 'Full health check body' }, + rabbitmqVersion: { type: 'string', description: 'Broker version' }, + productName: { type: 'string', description: 'Broker product name' }, + productVersion: { type: 'string', description: 'Broker product version' }, + erlangVersion: { type: 'string', description: 'Erlang runtime version' }, + clusterName: { type: 'string', description: 'Cluster name' }, + node: { type: 'string', description: 'Node that served the request' }, + objectTotals: { + type: 'json', + description: 'Broker object counts (connections, channels, exchanges, queues, consumers)', + }, + queueTotals: { + type: 'json', + description: + 'Aggregate queue depth across the broker (messages, messages_ready, messages_unacknowledged)', + }, + messageStats: { type: 'json', description: 'Broker-wide message counters and rates' }, + }, +} + +export const RabbitmqBlockMeta = { + tags: ['messaging', 'automation'], + url: 'https://www.rabbitmq.com', + templates: [ + { + icon: RabbitmqIcon, + title: 'RabbitMQ dead-letter triage', + prompt: + 'Build a scheduled workflow that reads messages off the RabbitMQ dead-letter queue every 15 minutes, groups them by failure reason, writes the groups to a table, and posts a summary to Slack.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ queue depth alerting', + prompt: + 'Create a scheduled workflow that lists RabbitMQ queues every five minutes, flags any queue whose depth exceeds its threshold or that has zero consumers, and opens a PagerDuty incident.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['pagerduty'], + }, + { + icon: RabbitmqIcon, + title: 'Publish enriched events to RabbitMQ', + prompt: + 'Build a workflow that receives an inbound webhook, enriches the payload with an agent, and publishes the result to a RabbitMQ topic exchange with a routing key derived from the event type.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['automation', 'engineering'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ to Slack incident relay', + prompt: + 'Create a scheduled workflow that drains a RabbitMQ alerts queue, summarizes each message with an agent, and posts the summary to the on-call Slack channel with a severity label.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['automation', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ order pipeline bootstrap', + prompt: + 'Build a workflow that declares the RabbitMQ queues and exchange bindings an order pipeline needs, verifies each binding, and records the resulting topology in a table.', + modules: ['tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['engineering', 'automation'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ broker health digest', + prompt: + 'Create a scheduled daily workflow that reads the RabbitMQ broker overview and queue list, compares depth and consumer counts against yesterday, and emails the platform team a health digest.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['gmail'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ replay after an outage', + prompt: + 'Build a workflow that reads messages from a RabbitMQ parking queue, has an agent decide which are safe to replay, republishes those to the live exchange, and logs every skipped message with its reason.', + modules: ['tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'automation'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ capacity review', + prompt: + 'Create a scheduled weekly workflow that lists every RabbitMQ queue and exchange, identifies unused queues and queues with no bindings, and posts a cleanup proposal to a Linear issue.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'engineering'], + alsoIntegrations: ['linear'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ cluster alarm watch', + prompt: + 'Create a scheduled workflow that runs the RabbitMQ health checks every five minutes, lists cluster nodes for memory and disk alarms and network partitions, and pages on-call when an alarm fires.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['pagerduty'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ stalled consumer detector', + prompt: + 'Build a scheduled workflow that cross-references RabbitMQ queue depth with consumers and channels, flags queues whose consumers are inactive or whose unacknowledged counts are stuck at the prefetch limit, and posts the findings to Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: RabbitmqIcon, + title: 'RabbitMQ dead-letter policy rollout', + prompt: + 'Create a workflow that audits RabbitMQ policies across virtual hosts, finds queue families with no dead-letter exchange configured, and applies a standard dead-lettering policy after posting the plan for approval.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'enterprise'], + alsoIntegrations: ['slack'], + }, + ], + skills: [ + { + name: 'publish-rabbitmq-message', + description: 'Publish a message to a RabbitMQ exchange and confirm it was routed.', + content: + '# Publish RabbitMQ Message\n\nSend a message into a RabbitMQ topology.\n\n## Steps\n1. Confirm the management URL, credentials, and virtual host.\n2. Choose the Publish Message operation. Set the exchange and routing key — to target a queue directly, leave the exchange empty and set the routing key to the queue name.\n3. Put the body in the message field. For JSON, set content_type to application/json in the message properties, and set delivery_mode to 2 so the message survives a broker restart.\n4. Add headers when downstream consumers or a headers exchange route on them.\n\n## Output\nReport whether the message was routed. A routed value of false means no binding matched the routing key and the broker dropped the message — check the exchange bindings before retrying.', + }, + { + name: 'drain-rabbitmq-queue', + description: 'Read messages off a RabbitMQ queue for inspection or reprocessing.', + content: + '# Drain RabbitMQ Queue\n\nRetrieve messages from a queue so they can be inspected or reprocessed.\n\n## Steps\n1. Confirm the management URL, credentials, virtual host, and queue name.\n2. Choose the Get Messages operation and set the count. Leave the acknowledgement mode at "Ack, requeue" to inspect messages without consuming them; switch to "Ack, discard" only when the messages should be removed.\n3. Raise the truncate limit if payloads are being cut short.\n\n## Output\nReturn the retrieved messages with their routing key, headers, and body, plus how many remain in the queue. Note explicitly whether the messages were left in place or removed.', + }, + { + name: 'audit-rabbitmq-queues', + description: 'Review RabbitMQ queue depth, consumers, and broker health.', + content: + '# Audit RabbitMQ Queues\n\nAssess the state of a RabbitMQ broker.\n\n## Steps\n1. Confirm the management URL and credentials.\n2. Run Get Overview for broker version, node, and aggregate queue totals.\n3. Run List Queues for the virtual host. Use the name filter to narrow to a service prefix.\n4. Flag queues where messages are growing, consumers is zero, or state is not running. Queue statistics are null until the broker has collected them, which is normal for a freshly declared queue.\n\n## Output\nReport the broker version and totals, then the problem queues with their depth and consumer count and what each symptom implies — a backlog with no consumers means nothing is processing, a backlog with consumers means processing is too slow.', + }, + { + name: 'triage-rabbitmq-alarms', + description: 'Check RabbitMQ broker health and explain what a fired alarm means.', + content: + '# Triage RabbitMQ Alarms\n\nDetermine whether the broker is healthy and what to do if it is not.\n\n## Steps\n1. Confirm the management URL and credentials.\n2. Run Health Check with the alarms check for a cluster-wide answer. A failing check is a normal result — read the healthy flag and reason rather than treating it as an error.\n3. Run List Nodes and inspect memory and disk alarms, the free-versus-limit figures, file-descriptor usage, and partitions. A non-empty partitions list means the cluster is split-brained and needs manual recovery.\n4. For a cluster, also run the quorum-critical check to confirm quorum queues would survive losing the node, and the virtual-hosts check to confirm every vhost is running.\n\n## Output\nState whether the broker is healthy. When a resource alarm has fired, say plainly that publishers are currently blocked broker-wide until the resource is reclaimed, name the node and the resource, and give the free-versus-limit numbers. Recommend consuming the backlog or raising the watermark rather than restarting the node.', + }, + { + name: 'find-stalled-rabbitmq-consumers', + description: 'Work out why a RabbitMQ queue has a backlog that is not draining.', + content: + '# Find Stalled RabbitMQ Consumers\n\nDistinguish "nothing is consuming" from "consuming too slowly" from "consumers are stuck".\n\n## Steps\n1. Confirm the management URL, credentials, and virtual host.\n2. Run Get Queue on the affected queue and note messages, messagesReady, and messagesUnacknowledged. Null values mean the broker has not collected statistics yet, not that the queue is empty.\n3. Run List Consumers. No consumers on a queue with a backlog means nothing is processing it — the application is down or never subscribed.\n4. If consumers exist, check their active flag and activityStatus, then run List Channels and compare messagesUnacknowledged against prefetchCount. A channel pinned at its prefetch limit is a consumer that received messages and never acknowledged them.\n5. Run List Connections to confirm the client is still connected and its state is running rather than blocked or flow — blocked means a resource alarm is throttling it.\n\n## Output\nName which of the three cases applies and the evidence for it. For no consumers, say the application is not subscribed. For unacknowledged messages stuck at the prefetch limit, say consumers are hung mid-processing and those messages will be redelivered when the connection drops. For flow or blocked connection state, point at the resource alarm instead.', + }, + { + name: 'configure-rabbitmq-dead-lettering', + description: 'Apply dead-lettering to a family of RabbitMQ queues with a policy.', + content: + '# Configure RabbitMQ Dead-Lettering\n\nRoute rejected and expired messages somewhere you can inspect them.\n\n## Steps\n1. Confirm the management URL, credentials, and virtual host.\n2. Create the dead-letter exchange and the queue that will hold the failures, then bind them.\n3. Use Create Policy with a pattern matching the queues to protect and a definition setting dead-letter-exchange, plus message-ttl, max-length, or delivery-limit as needed. A policy is the right tool here because it applies to matching queues that already exist — queue arguments only take effect at declaration time, so setting them on a live queue means deleting and redeclaring it.\n4. Run List Policies and confirm the priority. Only the single highest-priority matching policy applies to a resource; policies do not merge, so a higher-priority policy elsewhere will silently override this one.\n5. Verify with Get Queue that the queue reports the expected policy.\n\n## Output\nReport the policy name, pattern, definition, and priority, and which queues it now governs. Warn about any existing policy at equal or higher priority whose pattern also matches, since that one wins.', + }, + { + name: 'diagnose-unrouted-messages', + description: 'Work out why a published RabbitMQ message was not routed to any queue.', + content: + '# Diagnose Unrouted Messages\n\nFind why a publish reported `routed: false`.\n\n## Steps\n1. Confirm the management URL, credentials, and virtual host — a message published to the wrong vhost cannot match any binding.\n2. Run List Exchanges and confirm the target exchange exists and note its type. The type decides how matching works: direct requires the routing key to equal the binding key exactly, topic matches wildcard patterns, fanout ignores the routing key, and headers ignores it entirely in favour of binding arguments.\n3. Run List Bindings on the queue that should have received the message and compare each binding routing key against the key that was published. Watch for the common causes: a typo, a topic pattern that uses `*` (exactly one word) where `#` (zero or more words) was needed, or a queue bound to a different exchange.\n4. If no binding matches, use Create Binding to add the missing one, then republish and confirm `routed` is true.\n\n## Output\nState the exchange type, the routing key that was published, and every binding that could have matched. Name the specific mismatch, then the exact binding needed to fix it. An unrouted message is gone — the broker drops it unless the exchange has an alternate exchange configured, so re-publishing after the fix is required.', + }, + { + name: 'purge-queue-safely', + description: 'Clear a RabbitMQ queue after confirming what would be lost.', + content: + '# Purge Queue Safely\n\nDiscard a queue backlog without throwing away messages that still matter.\n\n## Steps\n1. Confirm the management URL, credentials, virtual host, and queue name.\n2. Run Get Queue first and report the current depth. If message counts come back null the broker has not collected statistics yet — wait and re-read rather than assuming the queue is empty.\n3. Sample the backlog with Get Messages at the default requeue acknowledgement mode so nothing is consumed, and summarize what the messages are.\n4. Only after the user confirms, run Purge Queue. It discards every ready message and cannot be undone. To remove the queue itself instead, use Delete Queue with the only-if-empty guard so the call fails rather than destroying unread messages.\n\n## Output\nReport the depth before purging, a summary of what the sampled messages contained, and the depth afterwards. If the queue is a dead-letter queue, say so explicitly and recommend replaying or archiving the messages before discarding them.', + }, + { + name: 'set-up-rabbitmq-topology', + description: 'Declare RabbitMQ queues and bind them to an exchange.', + content: + '# Set Up RabbitMQ Topology\n\nCreate the queues and bindings a pipeline needs.\n\n## Steps\n1. Confirm the management URL, credentials, and virtual host.\n2. Use Create Queue for each queue. Keep durable on so queues survive a restart. Set arguments for a quorum queue, a message TTL, a length limit, or dead lettering.\n3. Use Create Binding to attach each queue to its exchange with the routing key it should receive. Topic exchanges accept wildcards such as orders.* for one segment and orders.# for many.\n4. Verify with List Bindings on each queue.\n\n## Output\nList the queues declared and the bindings created with their routing keys. Note that every queue also has an implicit default-exchange binding on its own name, which appears with an empty source.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index fd7bb9af6c4..34ba119712e 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -3,10 +3,9 @@ import { AuthMode, type BlockConfig } from '@/blocks/types' import { getModelOptions, getProviderCredentialSubBlocks, + getSerializedModelProviderId, PROVIDER_CREDENTIAL_INPUTS, } from '@/blocks/utils' -import { getBaseModelProviders } from '@/providers/models' -import type { ProviderId } from '@/providers/types' import type { ToolResponse } from '@/tools/types' interface RouterResponse extends ToolResponse { @@ -215,17 +214,7 @@ export const RouterBlock: BlockConfig = { 'deepseek_reasoner', ], config: { - tool: (params: Record) => { - const model = params.model || 'gpt-4o' - if (!model) { - throw new Error('No model selected') - } - const tool = getBaseModelProviders()[model as ProviderId] - if (!tool) { - throw new Error(`Invalid model selected: ${model}`) - } - return tool - }, + tool: (params: Record) => getSerializedModelProviderId(params.model), }, }, inputs: { @@ -325,17 +314,7 @@ export const RouterV2Block: BlockConfig = { 'deepseek_reasoner', ], config: { - tool: (params: Record) => { - const model = params.model || 'gpt-4o' - if (!model) { - throw new Error('No model selected') - } - const tool = getBaseModelProviders()[model as ProviderId] - if (!tool) { - throw new Error(`Invalid model selected: ${model}`) - } - return tool - }, + tool: (params: Record) => getSerializedModelProviderId(params.model), }, }, inputs: { diff --git a/apps/sim/blocks/blocks/smartlead.ts b/apps/sim/blocks/blocks/smartlead.ts index 6da7223db1c..2eef1dc6269 100644 --- a/apps/sim/blocks/blocks/smartlead.ts +++ b/apps/sim/blocks/blocks/smartlead.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { SmartleadIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' @@ -1206,7 +1207,7 @@ function parseJsonArray(value: unknown, label: string): unknown[] | undefined { } function parseJsonObject(value: unknown, label: string): Record | undefined { - if (value && typeof value === 'object' && !Array.isArray(value)) { + if (isRecordLike(value)) { return value as Record } if (typeof value !== 'string' || value.trim() === '') return undefined diff --git a/apps/sim/blocks/blocks/table.test.ts b/apps/sim/blocks/blocks/table.test.ts new file mode 100644 index 00000000000..ab643825f72 --- /dev/null +++ b/apps/sim/blocks/blocks/table.test.ts @@ -0,0 +1,30 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/triggers', () => ({ + getTrigger: vi.fn(() => ({ subBlocks: [] })), +})) + +import { TableBlock } from '@/blocks/blocks/table' + +function params(input: Record): Record { + return TableBlock.tools.config?.params?.(input as never) as Record +} + +describe('table query_rows transformer', () => { + it('keeps an omitted limit unbounded', () => { + expect(params({ operation: 'query_rows', tableId: 'table-1' }).limit).toBeUndefined() + }) + + it('parses and validates an explicit limit', () => { + expect(params({ operation: 'query_rows', tableId: 'table-1', limit: '25' }).limit).toBe(25) + expect(params({ operation: 'query_rows', tableId: 'table-1', limit: '1000000' }).limit).toBe( + 1000000 + ) + expect(() => params({ operation: 'query_rows', tableId: 'table-1', limit: 'abc' })).toThrow( + /Invalid number for Limit/ + ) + }) +}) diff --git a/apps/sim/blocks/blocks/table.ts b/apps/sim/blocks/blocks/table.ts index e4b90227d41..6f493a322c7 100644 --- a/apps/sim/blocks/blocks/table.ts +++ b/apps/sim/blocks/blocks/table.ts @@ -1,8 +1,9 @@ +import { Table } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' -import { TableIcon } from '@/components/icons' import { TABLE_LIMITS } from '@/lib/table/constants' import { filterRulesToFilter, sortRulesToSort } from '@/lib/table/query-builder/converters' import type { BlockConfig } from '@/blocks/types' +import { parseOptionalNumberInput } from '@/blocks/utils' import type { TableQueryResponse } from '@/tools/table/types' import { getTrigger } from '@/triggers' @@ -113,7 +114,11 @@ const paramTransformers: Record ParsedPara tableId: params.tableId, filter, data: parseJSON(params.data, 'Row Data'), - limit: params.limit ? Number.parseInt(params.limit) : undefined, + limit: parseOptionalNumberInput(params.limit, 'Limit', { + integer: true, + min: 1, + max: TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, + }), } }, @@ -136,7 +141,11 @@ const paramTransformers: Record ParsedPara return { tableId: params.tableId, filter, - limit: params.limit ? Number.parseInt(params.limit) : undefined, + limit: parseOptionalNumberInput(params.limit, 'Limit', { + integer: true, + min: 1, + max: TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, + }), } }, @@ -171,8 +180,11 @@ const paramTransformers: Record ParsedPara tableId: params.tableId, filter, sort, - limit: params.limit ? Number.parseInt(params.limit) : 100, - offset: params.offset ? Number.parseInt(params.offset) : 0, + limit: parseOptionalNumberInput(params.limit, 'Limit', { + integer: true, + min: 1, + }), + offset: parseOptionalNumberInput(params.offset, 'Offset', { integer: true, min: 0 }) ?? 0, } }, } @@ -197,11 +209,11 @@ export const TableBlock: BlockConfig = { name: 'Table', description: 'User-defined data tables', longDescription: - 'Create and manage custom data tables. Store, query, and manipulate structured data within workflows.', + 'Create and manage custom data tables. Store, query, and manipulate structured data within workflows. Query Rows returns every matching row when Limit is omitted and fails if the result exceeds 5MB.', docsLink: 'https://docs.sim.ai/integrations/table', category: 'blocks', bgColor: '#10B981', - icon: TableIcon, + icon: Table, canvasPresentation: { defaultTitle: 'Table', /* @@ -652,7 +664,7 @@ Return ONLY the sort JSON:`, id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', + placeholder: 'Leave empty for all rows (fails over 5MB)', condition: { field: 'operation', value: ['query_rows', 'update_rows_by_filter', 'delete_rows_by_filter'], @@ -726,7 +738,11 @@ Return ONLY the sort JSON:`, description: 'Visual filter builder conditions for bulk operations', }, filter: { type: 'json', description: 'Filter criteria for query/update/delete operations' }, - limit: { type: 'number', description: 'Query or bulk operation limit' }, + limit: { + type: 'number', + description: + 'Optional query row limit; omit to return every matching row (fails over 5MB). Also caps bulk update/delete operations.', + }, builderMode: { type: 'string', description: 'Input mode for filter and sort (builder or json)', diff --git a/apps/sim/blocks/blocks/table_v2.ts b/apps/sim/blocks/blocks/table_v2.ts index ff569fd40f0..3b8ff6d9fb5 100644 --- a/apps/sim/blocks/blocks/table_v2.ts +++ b/apps/sim/blocks/blocks/table_v2.ts @@ -1,5 +1,5 @@ +import { Table } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' -import { TableIcon } from '@/components/icons' import { TABLE_LIMITS } from '@/lib/table/constants' import { filterRulesToPredicate, sortRulesToSortSpec } from '@/lib/table/query-builder/converters' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' @@ -221,7 +221,7 @@ export const TableV2Block: BlockConfig = { // and mark v1 `table` superseded. preview: true, bgColor: '#10B981', - icon: TableIcon, + icon: Table, canvasPresentation: { defaultTitle: 'Table', /* diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 208a2ca885a..d12e0beade3 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -18,6 +18,10 @@ import { AsanaBlock, AsanaBlockMeta } from '@/blocks/blocks/asana' import { AshbyBlock, AshbyBlockMeta } from '@/blocks/blocks/ashby' import { AthenaBlock, AthenaBlockMeta } from '@/blocks/blocks/athena' import { AttioBlock, AttioBlockMeta } from '@/blocks/blocks/attio' +import { + AzureDataExplorerBlock, + AzureDataExplorerBlockMeta, +} from '@/blocks/blocks/azure_data_explorer' import { AzureDevOpsBlock, AzureDevOpsBlockMeta } from '@/blocks/blocks/azure_devops' import { BoxBlock, BoxBlockMeta } from '@/blocks/blocks/box' import { BrandfetchBlock, BrandfetchBlockMeta } from '@/blocks/blocks/brandfetch' @@ -42,6 +46,7 @@ import { ConfluenceBlock, ConfluenceBlockMeta, ConfluenceV2Block } from '@/block import { ContextDevBlock, ContextDevBlockMeta } from '@/blocks/blocks/context_dev' import { ConvexBlock, ConvexBlockMeta } from '@/blocks/blocks/convex' import { CredentialBlock } from '@/blocks/blocks/credential' +import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' import { CrowdStrikeBlock, CrowdStrikeBlockMeta } from '@/blocks/blocks/crowdstrike' import { CursorBlock, CursorBlockMeta, CursorV2Block } from '@/blocks/blocks/cursor' import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' @@ -251,6 +256,7 @@ import { PulseBlock, PulseBlockMeta, PulseV2Block } from '@/blocks/blocks/pulse' import { QdrantBlock, QdrantBlockMeta } from '@/blocks/blocks/qdrant' import { QuartrBlock, QuartrBlockMeta } from '@/blocks/blocks/quartr' import { QuiverBlock, QuiverBlockMeta } from '@/blocks/blocks/quiver' +import { RabbitmqBlock, RabbitmqBlockMeta } from '@/blocks/blocks/rabbitmq' import { RailwayBlock, RailwayBlockMeta } from '@/blocks/blocks/railway' import { RB2BBlock, RB2BBlockMeta } from '@/blocks/blocks/rb2b' import { RDSBlock, RDSBlockMeta } from '@/blocks/blocks/rds' @@ -373,6 +379,7 @@ export const BLOCK_REGISTRY: Record = { ashby: AshbyBlock, athena: AthenaBlock, attio: AttioBlock, + azure_data_explorer: AzureDataExplorerBlock, azure_devops: AzureDevOpsBlock, box: BoxBlock, brandfetch: BrandfetchBlock, @@ -398,6 +405,7 @@ export const BLOCK_REGISTRY: Record = { context_dev: ContextDevBlock, convex: ConvexBlock, credential: CredentialBlock, + credential_group: CredentialGroupBlock, crowdstrike: CrowdStrikeBlock, cursor: CursorBlock, cursor_v2: CursorV2Block, @@ -575,6 +583,7 @@ export const BLOCK_REGISTRY: Record = { qdrant: QdrantBlock, quartr: QuartrBlock, quiver: QuiverBlock, + rabbitmq: RabbitmqBlock, railway: RailwayBlock, rb2b: RB2BBlock, rds: RDSBlock, @@ -709,6 +718,7 @@ export const BLOCK_META_REGISTRY: Record = { ashby: AshbyBlockMeta, athena: AthenaBlockMeta, attio: AttioBlockMeta, + azure_data_explorer: AzureDataExplorerBlockMeta, azure_devops: AzureDevOpsBlockMeta, box: BoxBlockMeta, brandfetch: BrandfetchBlockMeta, @@ -877,6 +887,7 @@ export const BLOCK_META_REGISTRY: Record = { qdrant: QdrantBlockMeta, quartr: QuartrBlockMeta, quiver: QuiverBlockMeta, + rabbitmq: RabbitmqBlockMeta, railway: RailwayBlockMeta, rb2b: RB2BBlockMeta, rds: RDSBlockMeta, diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index eaa91330389..fd71606f4ae 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -72,11 +72,13 @@ import { BUILT_IN_TOOL_TYPES, getApiKeyCondition, getDependsOnFields, + getSerializedModelProviderId, getSubBlocksDependingOnChange, parseOptionalBooleanInput, parseOptionalJsonInput, parseOptionalNumberInput, } from '@/blocks/utils' +import { getProviderFromModel } from '@/providers/utils' describe('BUILT_IN_TOOL_TYPES', () => { it('classifies the current File block instead of the legacy File block', () => { @@ -464,3 +466,46 @@ describe('getSubBlocksDependingOnChange', () => { ).toEqual(['projectId']) }) }) + +describe('getSerializedModelProviderId', () => { + const resolver = vi.mocked(getProviderFromModel) + + beforeEach(() => { + resolver.mockReset() + resolver.mockImplementation(((model: string) => { + if (model.startsWith('openrouter/')) return 'openrouter' + if (model === 'gpt-4o') return 'openai' + if (model === 'claude-sonnet-5') return 'anthropic' + throw new Error(`No provider found for model: ${model}`) + }) as unknown as typeof getProviderFromModel) + }) + + it('resolves a gateway model that the base model map deliberately omits', () => { + expect(getSerializedModelProviderId('openrouter/meta-llama/llama-4-maverick')).toBe( + 'openrouter' + ) + }) + + it('uses the fallback model when the model is still an unresolved reference', () => { + expect(getSerializedModelProviderId('openrouter/')).toBe('openai') + expect(resolver).not.toHaveBeenCalledWith('openrouter/') + }) + + it('honours a caller-supplied fallback model', () => { + expect(getSerializedModelProviderId(undefined, 'claude-sonnet-5')).toBe('anthropic') + }) + + it('never throws when the resolver rejects the model', () => { + expect(() => getSerializedModelProviderId('totally-unknown-model')).not.toThrow() + expect(getSerializedModelProviderId('totally-unknown-model')).toBe('openai') + }) + + it('never throws when the resolver rejects every model, including the fallback', () => { + resolver.mockImplementation((() => { + throw new Error('Provider "openai" is not available') + }) as unknown as typeof getProviderFromModel) + + expect(() => getSerializedModelProviderId('gpt-4o')).not.toThrow() + expect(getSerializedModelProviderId('gpt-4o')).toBe('openai') + }) +}) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index e5ecd711fed..909e42a01f4 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -9,7 +9,7 @@ import { import { getScopesForService } from '@/lib/oauth/utils' import { containsReference } from '@/lib/workflows/sanitization/references' import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' -import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types' +import type { SubBlockConfig } from '@/blocks/types' import { getBaseModelProviders, getHostedModels, @@ -21,6 +21,7 @@ import { SIM_AUTO_MODEL_ID, } from '@/providers/models' import { isPiSupportedModel } from '@/providers/pi-providers' +import type { ProviderId } from '@/providers/types' import { getProviderFromModel } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' @@ -144,24 +145,6 @@ export function getSubBlocksDependingOnChange( ) } -export function resolveOutputType( - outputs: Record -): Record { - const resolvedOutputs: Record = {} - - for (const [key, outputType] of Object.entries(outputs)) { - // Handle new format: { type: 'string', description: '...' } - if (typeof outputType === 'object' && outputType !== null && 'type' in outputType) { - resolvedOutputs[key] = outputType.type as BlockOutput - } else { - // Handle old format: just the type as string, or other object formats - resolvedOutputs[key] = outputType as BlockOutput - } - } - - return resolvedOutputs -} - function getProviderFromStore(model: string): string | null { const { providers } = useProvidersStore.getState() const normalized = model.toLowerCase() @@ -236,6 +219,45 @@ function shouldRequireApiKeyForModel(model: string): boolean { return true } +/** Model whose provider is recorded when a block's own `model` cannot be resolved. */ +const SERIALIZATION_FALLBACK_MODEL = 'gpt-4o' + +/** Last-resort provider for when even {@link SERIALIZATION_FALLBACK_MODEL} cannot be resolved. */ +const SERIALIZATION_FALLBACK_PROVIDER: ProviderId = 'openai' + +/** + * Provider id a model-driven block records for `model` during serialization. + * + * Serialization runs before variable resolution, and every model block's handler + * re-derives the provider from the *resolved* model without ever reading this + * value — so it only has to be shape-correct, and it must never throw. Two cases + * reach here that {@link getBaseModelProviders} cannot answer: `model` may still + * hold a `` reference, and gateway providers (OpenRouter, vLLM, + * LiteLLM, Ollama, …) are deliberately absent from that map even when the model + * id is perfectly valid. A reference resolves to {@link SERIALIZATION_FALLBACK_MODEL}'s + * provider; anything else is left to `getProviderFromModel`, which defaults an + * unrecognised id to `ollama` rather than failing serialization with an error the + * user cannot act on. + * + * The remaining throw is a blacklisted provider or model, which is env-driven and + * can name the fallback itself — so recovery returns + * {@link SERIALIZATION_FALLBACK_PROVIDER} outright rather than resolving a second + * time through the function that just threw. + */ +export function getSerializedModelProviderId( + model: unknown, + fallbackModel: string = SERIALIZATION_FALLBACK_MODEL +): ProviderId { + const candidate = + typeof model === 'string' && model && !containsReference(model) ? model : fallbackModel + + try { + return getProviderFromModel(candidate) + } catch { + return SERIALIZATION_FALLBACK_PROVIDER + } +} + /** * Visibility condition for a model-tuning field that only some models accept, such as * reasoning effort or verbosity. Gates on the capability list, but keeps the field visible diff --git a/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx new file mode 100644 index 00000000000..1e2383a9579 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx @@ -0,0 +1,50 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface CredentialGroupInvitationEmailProps { + recipientEmail: string + inviterName: string + workspaceName: string + credentialGroupName: string + invitationLink: string +} + +export function CredentialGroupInvitationEmail({ + recipientEmail, + inviterName, + workspaceName, + credentialGroupName, + invitationLink, +}: CredentialGroupInvitationEmailProps) { + const brand = getBrandConfig() + + return ( + + Hello, + + {inviterName} invited {recipientEmail} to connect accounts + for {credentialGroupName} in the {workspaceName} workspace + on {brand.name}. + + + + Connect Accounts + + +
+ + + This private link expires in 7 days. {brand.name} will send you to each provider to sign in + and will never ask for your provider password. If you did not expect this invitation, you + can ignore it. + + + ) +} + +export default CredentialGroupInvitationEmail diff --git a/apps/sim/components/emails/credential-groups/index.ts b/apps/sim/components/emails/credential-groups/index.ts new file mode 100644 index 00000000000..fec4e2d1428 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/index.ts @@ -0,0 +1 @@ +export { CredentialGroupInvitationEmail } from './credential-group-invitation-email' diff --git a/apps/sim/components/emails/credential-groups/render.ts b/apps/sim/components/emails/credential-groups/render.ts new file mode 100644 index 00000000000..63779541237 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/render.ts @@ -0,0 +1,12 @@ +import { render } from '@react-email/render' +import { CredentialGroupInvitationEmail } from '@/components/emails/credential-groups/credential-group-invitation-email' + +export async function renderCredentialGroupInvitationEmail(params: { + recipientEmail: string + inviterName: string + workspaceName: string + credentialGroupName: string + invitationLink: string +}): Promise { + return await render(CredentialGroupInvitationEmail(params)) +} diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index b8df1a5f27f..a56b542d606 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -103,3 +103,11 @@ export function getRequestConfirmationSubject(userSubject: string, requestType?: export function getOtpSubject(resourceLabel: string): string { return `Verification code for ${resourceLabel}` } + +/** Names both the inviter and workspace so an external recipient can identify the request. */ +export function getCredentialGroupInvitationSubject( + inviterName: string, + workspaceName: string +): string { + return `${inviterName} invited you to connect accounts for ${workspaceName} on ${getBrandConfig().name}` +} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index ad17849bd0a..812d38ea311 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3898,6 +3898,138 @@ export const ClaudeIcon = (props: SVGProps) => ( ) +export function AzureDataExplorerIcon(props: SVGProps) { + const id = useId() + const wedgeGradient = `adx_wedge_${id}` + const dashLongGradient = `adx_dash_long_${id}` + const dashTopGradient = `adx_dash_top_${id}` + const dashBottomGradient = `adx_dash_bottom_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + export function AzureIcon(props: SVGProps) { const id = useId() const gradient0 = `azure_paint0_${id}` @@ -7406,26 +7538,6 @@ export function BedrockIcon(props: SVGProps) { ) } -export function TableIcon(props: SVGProps) { - return ( - - - - - - - - ) -} export function ReductoIcon(props: SVGProps) { return ( ) { ) } +export function RabbitmqIcon(props: SVGProps) { + return ( + + + + ) +} + export function RailwayIcon(props: SVGProps) { return ( diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index d23e5bab48e..6a66e3ed75a 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -50,6 +50,7 @@ describe('settings navigation boundaries', () => { 'teammates', 'organization', 'secrets', + 'credential-groups', 'custom-tools', 'mcp', 'apikeys', @@ -99,6 +100,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'forks', + 'credential-groups', 'custom-blocks', 'self-host', ]) @@ -114,6 +116,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -139,6 +142,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -158,6 +162,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -450,6 +455,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, customBlocks: true, forks: true, inbox: true, @@ -474,6 +480,7 @@ describe('settings navigation boundaries', () => { }, entitlements: { byok: true, + credentialGroups: true, customBlocks: true, forks: true, inbox: true, @@ -488,6 +495,7 @@ describe('settings navigation boundaries', () => { '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 cb8e23ec1a6..d7707e37bcb 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -5,6 +5,7 @@ import { Credit, Database, Globe, + GridOffset, HexSimple, Key, KeySquare, @@ -64,6 +65,7 @@ export type OrganizationSettingsSection = export type WorkspaceSettingsSection = | 'teammates' | 'secrets' + | 'credential-groups' | 'byok' | 'sandboxes' | 'custom-tools' @@ -99,6 +101,7 @@ export type UnifiedSettingsSection = | 'browser' | 'terminal' | 'secrets' + | 'credential-groups' | 'access-control' | 'custom-blocks' | 'audit-logs' @@ -353,13 +356,6 @@ export const ORGANIZATION_SETTINGS_GROUPS = [ { key: 'enterprise', title: 'Enterprise' }, ] as const -export const WORKSPACE_SETTINGS_GROUPS = [ - { key: 'workspace', title: 'Workspace' }, - { key: 'tools', title: 'Tools' }, - { key: 'system', title: 'System' }, - { key: 'enterprise', title: 'Enterprise' }, -] as const - export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] = [ { label: 'General', @@ -534,6 +530,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] workspace: { id: 'secrets', group: 'workspace', order: 1 }, }, }, + { + label: 'Credential groups', + icon: GridOffset, + unified: { + id: 'credential-groups', + description: 'Collect and manage OAuth credentials for people outside this workspace.', + group: 'workspace', + order: 2, + requiresEnterprise: true, + allowNonOrgAdmin: true, + selfHostedOverride: true, + }, + planes: { + workspace: { id: 'credential-groups', group: 'enterprise', order: 10 }, + }, + }, { label: 'Custom tools', icon: Wrench, @@ -955,6 +967,7 @@ export interface WorkspacePermissionConfig { export interface WorkspaceSettingsEntitlements { byok: boolean + credentialGroups: boolean customBlocks: boolean forks: boolean inbox: boolean @@ -989,6 +1002,7 @@ export interface ResolvedWorkspaceNavigationItem const WORKSPACE_MUTATION_PERMISSION: Record = { teammates: 'admin', secrets: 'write', + 'credential-groups': 'admin', byok: 'admin', sandboxes: 'admin', 'custom-tools': 'write', @@ -1028,6 +1042,12 @@ export function resolveWorkspaceNavigation({ if (item.id === 'mcp' && permissionConfig.disableMcpTools) return [] if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) return [] if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return [] + if ( + item.id === 'credential-groups' && + (permission !== 'admin' || !entitlements.credentialGroups) + ) { + return [] + } if (item.id === 'byok' && !entitlements.byok) return [] if (item.id === 'custom-blocks' && !entitlements.customBlocks) return [] // Absent on Sim Cloud, where the managed service owns these settings. diff --git a/apps/sim/connectors/box/box.ts b/apps/sim/connectors/box/box.ts new file mode 100644 index 00000000000..2d82a9a6f2b --- /dev/null +++ b/apps/sim/connectors/box/box.ts @@ -0,0 +1,632 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { boxConnectorMeta } from '@/connectors/box/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { + CONNECTOR_MAX_FILE_BYTES, + ConnectorFileTooLargeError, + htmlToPlainText, + isSkippedDocument, + markSkipped, + parseTagDate, + readBodyWithLimit, + sizeLimitSkipReason, + stubOrSkipBySize, + takeIndexableWithinCap, +} from '@/connectors/utils' + +const logger = createLogger('BoxConnector') + +const BOX_API_BASE = 'https://api.box.com/2.0' +const BOX_ROOT_FOLDER_ID = '0' + +/** Maximum items per `GET /folders/:id/items` page (Box hard limit is 1000). */ +const FOLDER_ITEMS_PAGE_SIZE = 1000 + +/** + * Fields requested on every listed item. Supplying `fields` drops Box's standard + * field set, so every field the stub and tag mapping need must be named here. + */ +const ITEM_FIELDS = 'type,id,name,etag,size,created_at,modified_at,extension,path_collection' + +const FILE_FIELDS = `${ITEM_FIELDS},item_status,trashed_at` + +/** + * Folder pages drained per `listDocuments` call. Box has no recursive listing, so + * a naive one-folder-per-call walk would spend the sync engine's page budget + * (`MAX_PAGES`) on folder depth rather than on documents. + */ +const FOLDER_PAGES_PER_CALL = 10 + +/** Soft ceiling on stubs accumulated in one call, so a wide tree still yields early. */ +const MAX_FILES_PER_CALL = 2000 + +const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES + +/** + * Extensions read straight from `GET /files/:id/content` as UTF-8. These need no + * Box-side conversion, so they skip the representation round trip entirely. + */ +const PLAIN_TEXT_EXTENSIONS = new Set([ + 'as', + 'as3', + 'asm', + 'bat', + 'c', + 'cc', + 'cmake', + 'cpp', + 'cs', + 'css', + 'csv', + 'cxx', + 'diff', + 'erb', + 'groovy', + 'h', + 'haml', + 'hh', + 'htm', + 'html', + 'java', + 'js', + 'json', + 'less', + 'log', + 'm', + 'make', + 'markdown', + 'md', + 'ml', + 'mm', + 'php', + 'pl', + 'plist', + 'properties', + 'py', + 'rb', + 'rst', + 'sass', + 'scala', + 'scm', + 'script', + 'sh', + 'sml', + 'sql', + 'tsv', + 'txt', + 'vim', + 'vtt', + 'xhtml', + 'xml', + 'xsd', + 'xsl', + 'yaml', + 'yml', +]) + +/** + * Formats Box lists with `Text? = Yes` in the representation "Supported File + * Types" table whose raw bytes are not usable as UTF-8 (binary, proprietary, or + * markup-heavy). Content for these is pulled from the `extracted_text` + * representation rather than from `GET /files/:id/content`. + */ +const REPRESENTATION_EXTENSIONS = new Set([ + 'boxcanvas', + 'boxnote', + 'doc', + 'docx', + 'fdx', + 'gdoc', + 'gsheet', + 'gslide', + 'gslides', + 'msg', + 'odp', + 'ods', + 'odt', + 'otp', + 'pdf', + 'ppt', + 'pptx', + 'rtf', + 'vi', + 'webdoc', + 'wpd', + 'xbd', + 'xdw', + 'xls', + 'xlsm', + 'xlsx', +]) + +/** Extensions rendered as HTML that must be stripped before indexing. */ +const HTML_EXTENSIONS = new Set(['htm', 'html', 'xhtml']) + +/** Bounded wait for Box to finish generating an on-demand text representation. */ +const REPRESENTATION_POLL_ATTEMPTS = 5 +const REPRESENTATION_POLL_DELAY_MS = 2000 + +/** Hosts Box serves representation and content downloads from. */ +const BOX_DOWNLOAD_HOST_SUFFIXES = ['.box.com', '.boxcloud.com'] + +interface BoxPathEntry { + id?: string + name?: string +} + +interface BoxItem { + type?: string + id: string + name?: string + etag?: string | null + size?: number + created_at?: string + modified_at?: string + extension?: string + item_status?: string + trashed_at?: string | null + path_collection?: { entries?: BoxPathEntry[] } +} + +interface BoxFolderItemsResponse { + entries?: BoxItem[] + next_marker?: string | null +} + +interface BoxRepresentationEntry { + representation?: string + content?: { url_template?: string } + info?: { url?: string } + status?: { state?: string } +} + +interface BoxFileWithRepresentations extends BoxItem { + representations?: { entries?: BoxRepresentationEntry[] } +} + +/** + * Traversal position across pages of a single sync run. Box has no recursive + * listing endpoint, so the connector walks the folder tree breadth-first and + * carries the pending-folder queue plus the current folder's marker in the cursor. + */ +interface BoxTraversalState { + /** Folder IDs discovered but not yet listed. */ + queue: string[] + /** Folder currently being listed. */ + folderId: string + /** Box marker for the next page of `folderId`, if any. */ + marker?: string +} + +function encodeCursor(state: BoxTraversalState): string { + return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url') +} + +function decodeCursor(cursor: string): BoxTraversalState | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown + if (typeof parsed !== 'object' || parsed === null) return null + const candidate = parsed as Partial + if (typeof candidate.folderId !== 'string' || !Array.isArray(candidate.queue)) return null + return { + folderId: candidate.folderId, + queue: candidate.queue.filter((id): id is string => typeof id === 'string'), + marker: typeof candidate.marker === 'string' ? candidate.marker : undefined, + } + } catch { + return null + } +} + +function normalizeFolderId(value: unknown): string { + const raw = typeof value === 'string' ? value.trim() : '' + return raw || BOX_ROOT_FOLDER_ID +} + +function getExtension(item: BoxItem): string { + if (item.extension) return item.extension.toLowerCase() + const name = item.name ?? '' + const dotIndex = name.lastIndexOf('.') + return dotIndex === -1 ? '' : name.slice(dotIndex + 1).toLowerCase() +} + +function isSupportedFile(item: BoxItem): boolean { + if (item.type !== 'file') return false + const extension = getExtension(item) + return PLAIN_TEXT_EXTENSIONS.has(extension) || REPRESENTATION_EXTENSIONS.has(extension) +} + +/** + * Builds a human-readable path from Box's `path_collection` ancestry, dropping the + * synthetic root entry (id `0`, "All Files") so paths read as `/Reports/q3.pdf`. + */ +function buildPath(item: BoxItem): string { + const ancestors = (item.path_collection?.entries ?? []) + .filter((entry) => entry.id !== BOX_ROOT_FOLDER_ID && typeof entry.name === 'string') + .map((entry) => entry.name as string) + return `/${[...ancestors, item.name ?? ''].join('/')}` +} + +/** + * Metadata-only stub. `etag` is Box's per-version entity tag and changes on every + * new file version, so it is the change indicator; `modified_at` is the fallback + * for the rare item where Box omits an etag. + */ +function fileToStub(item: BoxItem): ExternalDocument { + return { + externalId: item.id, + title: item.name || 'Untitled', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: `https://app.box.com/file/${item.id}`, + contentHash: `box:${item.id}:${item.etag ?? item.modified_at ?? ''}`, + metadata: { + path: buildPath(item), + extension: getExtension(item), + lastModified: item.modified_at, + fileSize: item.size, + }, + } +} + +function assertBoxDownloadHost(url: string): void { + const host = new URL(url).hostname.toLowerCase() + const allowed = BOX_DOWNLOAD_HOST_SUFFIXES.some( + (suffix) => host === suffix.slice(1) || host.endsWith(suffix) + ) + if (!allowed) { + throw new Error(`Refusing to download Box content from unexpected host: ${host}`) + } +} + +/** + * Streams a Box download against the connector size cap, raising + * {@link ConnectorFileTooLargeError} when the body exceeds it so an oversized file + * surfaces as a skipped row instead of being buffered whole. + */ +async function downloadWithinLimit(url: string, accessToken: string): Promise { + assertBoxDownloadHost(url) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (response.status === 202) { + throw new Error('Box content is not yet ready for download') + } + if (!response.ok) { + throw new Error(`Failed to download Box content: ${response.status}`) + } + + const buffer = await readBodyWithLimit(response, MAX_FILE_SIZE) + if (!buffer) { + throw new ConnectorFileTooLargeError(MAX_FILE_SIZE) + } + return buffer +} + +async function fetchPlainTextContent( + accessToken: string, + fileId: string, + extension: string +): Promise { + const buffer = await downloadWithinLimit(`${BOX_API_BASE}/files/${fileId}/content`, accessToken) + const text = buffer.toString('utf8') + return HTML_EXTENSIONS.has(extension) ? htmlToPlainText(text) : text +} + +/** + * Resolves the `extracted_text` representation for a file. + * + * Box generates text representations on demand: the first request reports state + * `none`, and calling the representation's `info.url` starts generation. The state + * is then polled a bounded number of times. When generation has not finished in + * time the caller returns `null` so the document is retried on the next sync rather + * than being stored with empty content. + */ +async function fetchExtractedText( + accessToken: string, + entry: BoxRepresentationEntry +): Promise { + const infoUrl = entry.info?.url + let urlTemplate = entry.content?.url_template + let state = entry.status?.state + if (!urlTemplate && !infoUrl) return null + + for (let attempt = 0; attempt <= REPRESENTATION_POLL_ATTEMPTS; attempt++) { + if ((state === 'success' || state === 'viewable') && urlTemplate) { + const buffer = await downloadWithinLimit( + urlTemplate.replace('{+asset_path}', ''), + accessToken + ) + return buffer.toString('utf8') + } + if (state === 'error' || !infoUrl) return null + if (attempt === REPRESENTATION_POLL_ATTEMPTS) break + + assertBoxDownloadHost(infoUrl) + const response = await fetchWithRetry(infoUrl, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok && response.status !== 202) return null + + if (response.status === 202) { + state = 'pending' + } else { + const info = (await response.json()) as BoxRepresentationEntry + state = info.status?.state ?? 'pending' + urlTemplate = info.content?.url_template ?? urlTemplate + } + + if (state !== 'success' && state !== 'viewable') { + await sleep(REPRESENTATION_POLL_DELAY_MS) + } + } + + return null +} + +/** + * Lists one page of a folder. A folder the credential can no longer read is + * reported rather than thrown, so one inaccessible subtree does not abort the + * whole listing — the caller flags the listing as capped instead. + */ +async function listFolderPage( + accessToken: string, + folderId: string, + marker: string | undefined +): Promise { + const params = new URLSearchParams({ + fields: ITEM_FIELDS, + limit: String(FOLDER_ITEMS_PAGE_SIZE), + usemarker: 'true', + }) + if (marker) params.set('marker', marker) + + const response = await fetchWithRetry( + `${BOX_API_BASE}/folders/${encodeURIComponent(folderId)}/items?${params.toString()}`, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + + if (response.status === 403 || response.status === 404) { + logger.warn('Skipping inaccessible Box folder', { folderId, status: response.status }) + return null + } + + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Box folder items', { + folderId, + status: response.status, + error: errorText, + }) + throw new Error(`Failed to list Box folder items: ${response.status}`) + } + + return (await response.json()) as BoxFolderItemsResponse +} + +export const boxConnector: ConnectorConfig = { + ...boxConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const rootFolderId = normalizeFolderId(sourceConfig.folderId) + const state: BoxTraversalState = cursor + ? (decodeCursor(cursor) ?? { queue: [], folderId: rootFolderId }) + : { queue: [], folderId: rootFolderId } + + const queue = [...state.queue] + const files: BoxItem[] = [] + let position: { folderId: string; marker?: string } | null = { + folderId: state.folderId, + marker: state.marker, + } + + for (let fetched = 0; fetched < FOLDER_PAGES_PER_CALL && position; fetched++) { + const page = await listFolderPage(accessToken, position.folderId, position.marker) + + if (page) { + for (const item of page.entries ?? []) { + if (item.type === 'folder') { + queue.push(item.id) + } else if (isSupportedFile(item)) { + files.push(item) + } + } + } else if (syncContext) { + /** + * A folder was skipped, so documents that still exist in Box are absent from + * this listing. Without this flag the engine would reconcile them as deleted. + */ + syncContext.listingCapped = true + } + + const nextMarker = page?.next_marker || undefined + if (nextMarker) { + position = { folderId: position.folderId, marker: nextMarker } + } else { + const nextFolderId = queue.shift() + position = nextFolderId ? { folderId: nextFolderId } : null + } + + if (files.length >= MAX_FILES_PER_CALL) break + } + + const nextState: BoxTraversalState | null = position + ? { queue, folderId: position.folderId, marker: position.marker } + : null + + const maxFiles = sourceConfig.maxFiles ? Number(sourceConfig.maxFiles) : 0 + const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 + + const stubs = files.map((item) => stubOrSkipBySize(fileToStub(item), item.size, MAX_FILE_SIZE)) + + const { documents, indexableCount, capReached } = takeIndexableWithinCap( + stubs, + isSkippedDocument, + maxFiles, + previouslyFetched + ) + + if (syncContext) syncContext.totalDocsFetched = previouslyFetched + indexableCount + + /** + * The cap truncates the listing when it stopped traversal with folders still + * pending, or when it dropped items from this very page. Reaching the cap on + * the final item of the final page hides nothing, so deletion reconciliation + * stays enabled in that case. + */ + const droppedInPage = documents.length < stubs.length + const hitLimit = capReached && (nextState !== null || droppedInPage) + if (hitLimit && syncContext) syncContext.listingCapped = true + + return { + documents, + nextCursor: hitLimit || !nextState ? undefined : encodeCursor(nextState), + hasMore: !hitLimit && nextState !== null, + } + }, + + getDocument: async ( + accessToken: string, + _sourceConfig: Record, + externalId: string + ): Promise => { + try { + const response = await fetchWithRetry( + `${BOX_API_BASE}/files/${encodeURIComponent(externalId)}?fields=${FILE_FIELDS},representations`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'x-rep-hints': '[extracted_text]', + }, + } + ) + + if (response.status === 404 || response.status === 403) return null + if (!response.ok) { + throw new Error(`Failed to get Box file metadata: ${response.status}`) + } + + const file = (await response.json()) as BoxFileWithRepresentations + if (file.trashed_at || (file.item_status && file.item_status !== 'active')) return null + if (!isSupportedFile({ ...file, type: file.type ?? 'file' })) return null + + const stub = fileToStub(file) + if (file.size && file.size > MAX_FILE_SIZE) { + return markSkipped(stub, sizeLimitSkipReason(MAX_FILE_SIZE)) + } + + const extension = getExtension(file) + + let content: string | null + try { + if (PLAIN_TEXT_EXTENSIONS.has(extension)) { + content = await fetchPlainTextContent(accessToken, file.id, extension) + } else { + const entry = (file.representations?.entries ?? []).find( + (candidate) => candidate.representation === 'extracted_text' + ) + content = entry ? await fetchExtractedText(accessToken, entry) : null + } + } catch (error) { + if (error instanceof ConnectorFileTooLargeError) { + return markSkipped(stub, sizeLimitSkipReason(error.limitBytes)) + } + throw error + } + + if (!content?.trim()) return null + + return { ...stub, content, contentDeferred: false } + } catch (error) { + logger.warn(`Failed to fetch Box document ${externalId}`, { + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const maxFiles = sourceConfig.maxFiles as string | undefined + if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) { + return { valid: false, error: 'Max files must be a positive number' } + } + + const folderId = normalizeFolderId(sourceConfig.folderId) + if (!/^\d+$/.test(folderId)) { + return { + valid: false, + error: 'Folder ID must be a numeric Box folder ID (use 0 for the root)', + } + } + + try { + const response = await fetchWithRetry( + `${BOX_API_BASE}/folders/${folderId}?fields=id,name`, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (response.status === 404) { + return { valid: false, error: 'Folder not found. Check the folder ID and try again.' } + } + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: 'Box denied access to this folder. Reconnect or pick another folder.', + } + } + if (!response.ok) { + return { valid: false, error: `Failed to access Box: ${response.status}` } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.path === 'string' && metadata.path) { + result.path = metadata.path + } + + if (typeof metadata.extension === 'string' && metadata.extension) { + result.extension = metadata.extension + } + + const lastModified = parseTagDate(metadata.lastModified) + if (lastModified) result.lastModified = lastModified + + if (metadata.fileSize != null) { + const num = Number(metadata.fileSize) + if (!Number.isNaN(num)) result.fileSize = num + } + + return result + }, +} diff --git a/apps/sim/connectors/box/index.ts b/apps/sim/connectors/box/index.ts new file mode 100644 index 00000000000..03077f48037 --- /dev/null +++ b/apps/sim/connectors/box/index.ts @@ -0,0 +1 @@ +export { boxConnector } from '@/connectors/box/box' diff --git a/apps/sim/connectors/box/meta.ts b/apps/sim/connectors/box/meta.ts new file mode 100644 index 00000000000..a8c2643ef30 --- /dev/null +++ b/apps/sim/connectors/box/meta.ts @@ -0,0 +1,42 @@ +import { BoxCompanyIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const boxConnectorMeta: ConnectorMeta = { + id: 'box', + name: 'Box', + description: 'Sync text-extractable files from Box', + version: '1.0.0', + icon: BoxCompanyIcon, + + auth: { + mode: 'oauth', + provider: 'box', + requiredScopes: ['root_readwrite'], + }, + + configFields: [ + { + id: 'folderId', + title: 'Folder ID', + type: 'short-input', + placeholder: 'e.g. 123456789 (default: entire account)', + required: false, + description: + 'Numeric Box folder ID to sync recursively. Leave empty (or use 0) to sync all files.', + }, + { + id: 'maxFiles', + title: 'Max Files', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + tagDefinitions: [ + { id: 'path', displayName: 'File Path', fieldType: 'text' }, + { id: 'extension', displayName: 'Extension', fieldType: 'text' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + { id: 'fileSize', displayName: 'File Size (bytes)', fieldType: 'number' }, + ], +} diff --git a/apps/sim/connectors/google-slides/google-slides.ts b/apps/sim/connectors/google-slides/google-slides.ts new file mode 100644 index 00000000000..3f776360cd5 --- /dev/null +++ b/apps/sim/connectors/google-slides/google-slides.ts @@ -0,0 +1,503 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { googleSlidesConnectorMeta } from '@/connectors/google-slides/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { + buildDriveParentsClause, + joinTagArray, + parseMultiValue, + parseTagDate, +} from '@/connectors/utils' + +const logger = createLogger('GoogleSlidesConnector') + +const PRESENTATION_MIME_TYPE = 'application/vnd.google-apps.presentation' + +/** Reason recorded for a presentation whose slides contain no extractable text. */ +const NO_TEXT = 'No extractable text' + +/** Guards against a pathological (or cyclic) group nesting depth. */ +const MAX_GROUP_DEPTH = 12 + +/** + * Represents a Google Drive file entry returned by the Drive API. + */ +interface DriveFile { + id: string + name: string + mimeType: string + modifiedTime?: string + createdTime?: string + webViewLink?: string + owners?: { displayName?: string; emailAddress?: string }[] +} + +/** + * A single formatting-consistent run of text within a Slides text element. + */ +interface SlidesTextElement { + textRun?: { content?: string } +} + +/** + * The Slides API `TextContent` object carried by shapes and table cells. + */ +interface SlidesTextContent { + textElements?: SlidesTextElement[] +} + +interface SlidesTableCell { + text?: SlidesTextContent +} + +interface SlidesTableRow { + tableCells?: SlidesTableCell[] +} + +/** + * A page element on a slide. Exactly one of the visual properties is set; + * only `shape`, `table`, `wordArt`, and `elementGroup` can carry extractable + * text — `image`, `video`, `line`, `sheetsChart`, and `speakerSpotlight` do not. + */ +interface SlidesPageElement { + objectId?: string + shape?: { text?: SlidesTextContent } + table?: { tableRows?: SlidesTableRow[] } + wordArt?: { renderedText?: string } + elementGroup?: { children?: SlidesPageElement[] } +} + +/** + * A Slides API `Page`. Slides carry `slideProperties.notesPage`, whose + * `notesProperties.speakerNotesObjectId` names the element holding the notes. + */ +interface SlidesPage { + objectId?: string + pageElements?: SlidesPageElement[] + notesProperties?: { speakerNotesObjectId?: string } + slideProperties?: { notesPage?: SlidesPage } +} + +interface SlidesPresentation { + slides?: SlidesPage[] +} + +/** + * Flattens a Slides `TextContent` into plain text. Slides encodes a soft line + * break as a vertical tab, which is normalized to a newline. + */ +function extractTextContent(text: SlidesTextContent | undefined): string { + const elements = text?.textElements + if (!elements) return '' + + return elements + .map((element) => element.textRun?.content ?? '') + .join('') + .replace(/\v/g, '\n') + .replace(/\n+$/, '') +} + +/** + * Walks page elements in document order, appending each element's text to + * `parts`. Groups are recursed into so nested shapes are not dropped. + */ +function collectElementText( + elements: SlidesPageElement[] | undefined, + parts: string[], + depth: number +): void { + if (!elements || depth > MAX_GROUP_DEPTH) return + + for (const element of elements) { + const shapeText = extractTextContent(element.shape?.text) + if (shapeText.trim()) parts.push(shapeText) + + const wordArtText = element.wordArt?.renderedText + if (wordArtText?.trim()) parts.push(wordArtText.trim()) + + const rows = element.table?.tableRows + if (rows) { + for (const row of rows) { + const cells = (row.tableCells ?? []) + .map((cell) => extractTextContent(cell.text).replace(/\n/g, ' ').trim()) + .filter(Boolean) + if (cells.length > 0) parts.push(cells.join(' | ')) + } + } + + if (element.elementGroup?.children) { + collectElementText(element.elementGroup.children, parts, depth + 1) + } + } +} + +/** + * Extracts the speaker notes for a slide. The notes page mirrors the slide's + * body placeholders, so only the element named by `speakerNotesObjectId` is + * read — anything else would duplicate the slide's own text. + */ +function extractSpeakerNotes(slide: SlidesPage): string { + const notesPage = slide.slideProperties?.notesPage + const notesObjectId = notesPage?.notesProperties?.speakerNotesObjectId + if (!notesPage || !notesObjectId) return '' + + const notesElement = notesPage.pageElements?.find((element) => element.objectId === notesObjectId) + return extractTextContent(notesElement?.shape?.text) +} + +/** + * Renders a presentation as plain text, preserving slide order. Each slide is + * introduced by a Markdown heading so retrieved chunks keep their position. + */ +function extractTextFromPresentation( + presentation: SlidesPresentation, + includeSpeakerNotes: boolean +): string { + const slides = presentation.slides + if (!slides) return '' + + const sections: string[] = [] + + for (let index = 0; index < slides.length; index++) { + const parts: string[] = [] + collectElementText(slides[index].pageElements, parts, 0) + + if (includeSpeakerNotes) { + const notes = extractSpeakerNotes(slides[index]) + if (notes.trim()) parts.push(`Speaker notes: ${notes}`) + } + + if (parts.length > 0) { + sections.push(`## Slide ${index + 1}\n${parts.join('\n')}`) + } + } + + return sections.join('\n\n').trim() +} + +/** + * Fetches a presentation via the Slides API and extracts its text. Only the + * `slides` field is requested — masters, layouts, and the notes master carry + * template boilerplate that would pollute the index. + */ +async function fetchPresentationContent( + accessToken: string, + presentationId: string, + includeSpeakerNotes: boolean +): Promise { + const url = `https://slides.googleapis.com/v1/presentations/${encodeURIComponent(presentationId)}?fields=slides` + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error( + `Failed to fetch Google Slides presentation ${presentationId}: ${response.status}` + ) + } + + const presentation = (await response.json()) as SlidesPresentation + return extractTextFromPresentation(presentation, includeSpeakerNotes) +} + +/** + * Resolves the speaker-notes preference. Notes are included unless the user + * explicitly opted out, so an unset legacy config keeps the richer content. + */ +function shouldIncludeSpeakerNotes(sourceConfig: Record): boolean { + return sourceConfig.includeSpeakerNotes !== 'no' +} + +/** + * Creates a lightweight stub from a Drive file entry. Content is deferred + * and only fetched via getDocument for new or changed documents. + */ +function fileToStub(file: DriveFile, includeSpeakerNotes: boolean): ExternalDocument { + return { + externalId: file.id, + title: file.name || 'Untitled', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: file.webViewLink || `https://docs.google.com/presentation/d/${file.id}/edit`, + /** + * The speaker-notes setting selects what the rendered content contains, so it + * belongs in the hash. Without it, toggling the option leaves every stored + * hash matching and no presentation is ever re-hydrated with the new scope. + */ + contentHash: `gslides:${file.id}:${file.modifiedTime ?? ''}:${includeSpeakerNotes ? 'n1' : 'n0'}`, + metadata: { + modifiedTime: file.modifiedTime, + createdTime: file.createdTime, + owners: file.owners?.map((o) => o.displayName || o.emailAddress).filter(Boolean), + }, + } +} + +/** + * Builds the Drive API query string for listing Google Slides presentations. + * When `lastSyncAt` is supplied the listing is narrowed to presentations + * touched since the previous sync. + */ +function buildQuery(sourceConfig: Record, lastSyncAt?: Date): string { + const parts: string[] = ['trashed = false', `mimeType = '${PRESENTATION_MIME_TYPE}'`] + + const parentsClause = buildDriveParentsClause(parseMultiValue(sourceConfig.folderId)) + if (parentsClause) parts.push(parentsClause) + + if (lastSyncAt && !Number.isNaN(lastSyncAt.getTime())) { + parts.push(`modifiedTime > '${lastSyncAt.toISOString()}'`) + } + + return parts.join(' and ') +} + +export const googleSlidesConnector: ConnectorConfig = { + ...googleSlidesConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record, + lastSyncAt?: Date + ): Promise => { + const query = buildQuery(sourceConfig, lastSyncAt) + const pageSize = 100 + + const queryParams = new URLSearchParams({ + q: query, + pageSize: String(pageSize), + fields: + 'nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners)', + supportsAllDrives: 'true', + includeItemsFromAllDrives: 'true', + }) + + if (cursor) { + queryParams.set('pageToken', cursor) + } + + const url = `https://www.googleapis.com/drive/v3/files?${queryParams.toString()}` + + logger.info('Listing Google Slides presentations', { query, cursor: cursor ?? 'initial' }) + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Google Slides presentations', { + status: response.status, + error: errorText, + }) + throw new Error(`Failed to list Google Slides presentations: ${response.status}`) + } + + const data = await response.json() + const files = (data.files || []) as DriveFile[] + + /** + * Drive sets `incompleteSearch` when it could not search every corpus (it + * arises with the `allDrives` scope enabled by `includeItemsFromAllDrives`). + * A partial listing drops still-existing presentations, so reconciliation + * must be suppressed to avoid hard-deleting valid documents. + */ + const incompleteSearch = data.incompleteSearch === true + + const maxDocs = sourceConfig.maxDocs ? Number(sourceConfig.maxDocs) : 0 + const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 + + const includeSpeakerNotes = shouldIncludeSpeakerNotes(sourceConfig) + let documents = files.map((file) => fileToStub(file, includeSpeakerNotes)) + let slicedSome = false + if (maxDocs > 0) { + const remaining = maxDocs - previouslyFetched + if (documents.length > remaining) { + slicedSome = true + documents = documents.slice(0, remaining) + } + } + + const totalFetched = previouslyFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxDocs > 0 && totalFetched >= maxDocs + + const nextPageToken = data.nextPageToken as string | undefined + + /** + * Mark the listing as incomplete so the sync engine skips deletion + * reconciliation when this page does not represent the full source set: + * - `slicedSome`: the page held more presentations than `maxDocs` allowed. + * - `hitLimit` with a next page: the cap was reached while pages remain. + * - `incompleteSearch`: Drive could not search every corpus, so the page is + * partial and may omit still-existing presentations. + * Reconciliation against any of these would hard-delete valid documents. + */ + if (syncContext && (slicedSome || (hitLimit && Boolean(nextPageToken)) || incompleteSearch)) { + syncContext.listingCapped = true + } + + return { + documents, + nextCursor: hitLimit ? undefined : nextPageToken, + hasMore: hitLimit ? false : Boolean(nextPageToken), + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + const fields = 'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,trashed' + const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(externalId)}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true` + + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + if (response.status === 404) return null + throw new Error(`Failed to get Google Slides metadata: ${response.status}`) + } + + const file = (await response.json()) as DriveFile & { trashed?: boolean } + + if (file.trashed) return null + if (file.mimeType !== PRESENTATION_MIME_TYPE) return null + + const includeSpeakerNotes = shouldIncludeSpeakerNotes(sourceConfig) + const content = await fetchPresentationContent(accessToken, file.id, includeSpeakerNotes) + + /** + * An image-only deck carries no extractable text. Surfacing it as a skipped + * row keeps it visible in the knowledge base UI — returning `null` would + * make the engine drop the document with no reason recorded, so the + * presentation would simply be missing and re-fetched on every sync. + */ + if (!content.trim()) { + return { + ...fileToStub(file, includeSpeakerNotes), + content: '', + contentDeferred: false, + skippedReason: NO_TEXT, + } + } + + return { ...fileToStub(file, includeSpeakerNotes), content, contentDeferred: false } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const folderIds = parseMultiValue(sourceConfig.folderId) + const maxDocs = sourceConfig.maxDocs as string | undefined + + if (maxDocs && (Number.isNaN(Number(maxDocs)) || Number(maxDocs) <= 0)) { + return { valid: false, error: 'Max presentations must be a positive number' } + } + + const includeSpeakerNotes = sourceConfig.includeSpeakerNotes + if ( + includeSpeakerNotes != null && + includeSpeakerNotes !== '' && + includeSpeakerNotes !== 'yes' && + includeSpeakerNotes !== 'no' + ) { + return { valid: false, error: 'Speaker notes must be either "yes" or "no"' } + } + + try { + if (folderIds.length > 0) { + for (const folderId of folderIds) { + const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType&supportsAllDrives=true` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { + valid: false, + error: `Folder "${folderId}" not found. Check the folder ID and permissions.`, + } + } + return { + valid: false, + error: `Failed to access folder "${folderId}": ${response.status}`, + } + } + + const folder = await response.json() + if (folder.mimeType !== 'application/vnd.google-apps.folder') { + return { valid: false, error: `"${folderId}" is not a folder` } + } + } + } else { + const probeParams = new URLSearchParams({ + pageSize: '1', + q: `trashed = false and mimeType = '${PRESENTATION_MIME_TYPE}'`, + fields: 'files(id)', + supportsAllDrives: 'true', + includeItemsFromAllDrives: 'true', + }) + const response = await fetchWithRetry( + `https://www.googleapis.com/drive/v3/files?${probeParams.toString()}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + return { valid: false, error: `Failed to access Google Slides: ${response.status}` } + } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: toError(error).message || 'Failed to validate configuration' } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + const owners = joinTagArray(metadata.owners) + if (owners) result.owners = owners + + const lastModified = parseTagDate(metadata.modifiedTime) + if (lastModified) result.lastModified = lastModified + + return result + }, +} diff --git a/apps/sim/connectors/google-slides/index.ts b/apps/sim/connectors/google-slides/index.ts new file mode 100644 index 00000000000..56d00401c45 --- /dev/null +++ b/apps/sim/connectors/google-slides/index.ts @@ -0,0 +1 @@ +export { googleSlidesConnector } from '@/connectors/google-slides/google-slides' diff --git a/apps/sim/connectors/google-slides/meta.ts b/apps/sim/connectors/google-slides/meta.ts new file mode 100644 index 00000000000..a4cc9a17aef --- /dev/null +++ b/apps/sim/connectors/google-slides/meta.ts @@ -0,0 +1,68 @@ +import { GoogleSlidesIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const googleSlidesConnectorMeta: ConnectorMeta = { + id: 'google_slides', + name: 'Google Slides', + description: 'Sync Google Slides presentations', + version: '1.0.0', + icon: GoogleSlidesIcon, + + /** + * The Slides API has no dedicated Sim OAuth service. `presentations.get` + * accepts `https://www.googleapis.com/auth/drive`, which the `google-drive` + * provider already grants — the same provider every `google_slides` tool uses. + */ + auth: { + mode: 'oauth', + provider: 'google-drive', + requiredScopes: ['https://www.googleapis.com/auth/drive'], + }, + + configFields: [ + { + id: 'folderSelector', + title: 'Folders', + type: 'selector', + selectorKey: 'google.drive', + mimeType: 'application/vnd.google-apps.folder', + canonicalParamId: 'folderId', + mode: 'basic', + multi: true, + placeholder: 'Select one or more folders (optional)', + required: false, + }, + { + id: 'folderId', + title: 'Folder IDs', + type: 'short-input', + canonicalParamId: 'folderId', + mode: 'advanced', + multi: true, + placeholder: 'e.g. 1aBcDeFg…, 2cDeFgHi… (comma-separated for multiple)', + required: false, + }, + { + id: 'includeSpeakerNotes', + title: 'Speaker Notes', + type: 'dropdown', + required: false, + options: [ + { label: 'Include speaker notes', id: 'yes' }, + { label: 'Slide text only', id: 'no' }, + ], + }, + { + id: 'maxDocs', + title: 'Max Presentations', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + tagDefinitions: [ + { id: 'owners', displayName: 'Owner', fieldType: 'text' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + ], +} diff --git a/apps/sim/connectors/google-vault/google-vault.ts b/apps/sim/connectors/google-vault/google-vault.ts new file mode 100644 index 00000000000..7c94e6205fd --- /dev/null +++ b/apps/sim/connectors/google-vault/google-vault.ts @@ -0,0 +1,722 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { googleVaultConnectorMeta } from '@/connectors/google-vault/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { computeContentHash, parseTagDate, takeIndexableWithinCap } from '@/connectors/utils' + +const logger = createLogger('GoogleVaultConnector') + +const VAULT_API_BASE = 'https://vault.googleapis.com/v1' + +/** Vault caps both `matters.list` and `matters.holds.list` page sizes at 100. */ +const PAGE_SIZE = 100 + +/** + * Matters whose child resources are enumerated in a single `listDocuments` call. + * + * The sync engine allows a bounded number of `listDocuments` calls per run, so + * spending one call per matter per child kind would truncate the listing after a + * few hundred matters. Draining a batch of matters per call keeps the call count + * proportional to `matters / BATCH` instead of `matters × kinds`. + */ +const CHILD_MATTER_BATCH = 8 + +/** Concurrent child listings issued within one batch. */ +const CHILD_CONCURRENCY = 4 + +/** + * Upper bound on child pages drained for a single matter/kind pair. Vault returns at + * most 100 children per page, so this covers 5,000 holds or saved queries in one + * matter; exceeding it marks the listing capped rather than looping unbounded. + */ +const MAX_CHILD_PAGES = 50 + +/** + * Google Vault matter, as returned by `matters.list`/`matters.get` with `view=FULL`. + * @see https://developers.google.com/workspace/vault/reference/rest/v1/matters + */ +interface VaultMatter { + matterId?: string + name?: string + description?: string + state?: string + matterRegion?: string + matterPermissions?: { accountId?: string; role?: string }[] +} + +/** Held account entry on a hold (`matters.holds` with `view=FULL_HOLD`). */ +interface VaultHeldAccount { + accountId?: string + email?: string + firstName?: string + lastName?: string + holdTime?: string +} + +/** Service-specific query options attached to a hold. */ +interface VaultCorpusQuery { + driveQuery?: { includeSharedDriveFiles?: boolean } + mailQuery?: { terms?: string; startTime?: string; endTime?: string } + groupsQuery?: { terms?: string; startTime?: string; endTime?: string } + hangoutsChatQuery?: { includeRooms?: boolean } + voiceQuery?: { coveredData?: string[] } +} + +/** + * Google Vault hold. + * @see https://developers.google.com/workspace/vault/reference/rest/v1/matters.holds + */ +interface VaultHold { + holdId?: string + name?: string + corpus?: string + updateTime?: string + orgUnit?: { orgUnitId?: string; holdTime?: string } + accounts?: VaultHeldAccount[] + query?: VaultCorpusQuery +} + +/** + * Search parameters shared by saved queries and exports. + * @see https://developers.google.com/workspace/vault/reference/rest/v1/Query + */ +interface VaultQuery { + corpus?: string + dataScope?: string + method?: string + searchMethod?: string + terms?: string + startTime?: string + endTime?: string + timeZone?: string + accountInfo?: { emails?: string[] } + orgUnitInfo?: { orgUnitId?: string } + sharedDriveInfo?: { sharedDriveIds?: string[] } + teamDriveInfo?: { teamDriveIds?: string[] } + hangoutsChatInfo?: { roomId?: string[] } + sitesUrlInfo?: { urls?: string[] } + driveDocumentInfo?: { documentIds?: { ids?: string[] } } +} + +/** + * Google Vault saved query. + * @see https://developers.google.com/workspace/vault/reference/rest/v1/matters.savedQueries + */ +interface VaultSavedQuery { + savedQueryId?: string + displayName?: string + matterId?: string + createTime?: string + query?: VaultQuery +} + +/** Child resource families enumerated under each matter. */ +type VaultChildKind = 'holds' | 'savedQueries' + +/** + * Opaque pagination state for a Vault sync. + * + * A single sync interleaves two levels of pagination: a page of matters, then the + * child resources of every matter on that page. The cursor carries the matter IDs of + * the current page so a resumed call never has to re-derive them. + * + * Child pagination is fully drained inside one call per batch of matters, so the + * cursor only has to remember how far through the matter page it has walked. + */ +interface VaultCursor { + phase: 'matters' | 'children' + /** Page token used to fetch the matters page processed in the `matters` phase. */ + mattersPageToken?: string + /** Page token for the matters page that follows the one currently being walked. */ + nextMattersPageToken?: string + matterIds?: string[] + /** Index into `matterIds` of the first matter in the next batch to process. */ + matterIndex?: number +} + +function encodeCursor(cursor: VaultCursor): string { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url') +} + +function decodeCursor(cursor?: string): VaultCursor { + if (!cursor) return { phase: 'matters' } + try { + return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as VaultCursor + } catch { + return { phase: 'matters' } + } +} + +function readString(value: unknown): string | undefined { + const text = typeof value === 'string' ? value.trim() : '' + return text.length > 0 ? text : undefined +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value + const text = readString(value)?.toLowerCase() + if (text === 'true') return true + if (text === 'false') return false + return fallback +} + +/** Parses a positive integer cap; `0` means unlimited. */ +function readMaxDocuments(value: unknown): number { + const text = readString(value) + if (!text) return 0 + const parsed = Number(text) + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0 +} + +function enabledChildKinds(sourceConfig: Record): VaultChildKind[] { + const kinds: VaultChildKind[] = [] + if (readBoolean(sourceConfig.includeHolds, true)) kinds.push('holds') + if (readBoolean(sourceConfig.includeSavedQueries, true)) kinds.push('savedQueries') + return kinds +} + +async function vaultGet(accessToken: string, url: string, label: string): Promise { + return fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }).catch((error) => { + throw new Error(`Failed to reach Google Vault (${label}): ${toError(error).message}`) + }) +} + +function appendLine(lines: string[], label: string, value?: string): void { + if (value?.trim()) lines.push(`${label}: ${value.trim()}`) +} + +/** Renders a matter as indexable plain text. */ +function renderMatter(matter: VaultMatter): string { + const lines: string[] = [] + appendLine(lines, 'Matter', matter.name) + appendLine(lines, 'Matter ID', matter.matterId) + appendLine(lines, 'State', matter.state) + appendLine(lines, 'Region', matter.matterRegion) + appendLine(lines, 'Description', matter.description) + + const permissions = (matter.matterPermissions ?? []) + .map((permission) => + [permission.accountId, permission.role].filter((part) => Boolean(part)).join(' — ') + ) + .filter((entry) => entry.length > 0) + if (permissions.length > 0) { + lines.push('Permissions:') + for (const permission of permissions) lines.push(` - ${permission}`) + } + + return lines.join('\n') +} + +/** Renders a hold, including its scope and search refinements, as plain text. */ +function renderHold(hold: VaultHold, matterId: string): string { + const lines: string[] = [] + appendLine(lines, 'Hold', hold.name) + appendLine(lines, 'Hold ID', hold.holdId) + appendLine(lines, 'Matter ID', matterId) + appendLine(lines, 'Corpus', hold.corpus) + appendLine(lines, 'Last updated', hold.updateTime) + appendLine(lines, 'Organizational unit', hold.orgUnit?.orgUnitId) + + const accounts = (hold.accounts ?? []) + .map((account) => { + const name = [account.firstName, account.lastName].filter(Boolean).join(' ') + return [account.email ?? account.accountId, name].filter(Boolean).join(' — ') + }) + .filter((entry) => entry.length > 0) + if (accounts.length > 0) { + lines.push('Held accounts:') + for (const account of accounts) lines.push(` - ${account}`) + } + + const mailLike = hold.query?.mailQuery ?? hold.query?.groupsQuery + appendLine(lines, 'Search terms', mailLike?.terms) + appendLine(lines, 'Start time', mailLike?.startTime) + appendLine(lines, 'End time', mailLike?.endTime) + if (hold.query?.driveQuery?.includeSharedDriveFiles !== undefined) { + appendLine( + lines, + 'Includes shared drive files', + String(hold.query.driveQuery.includeSharedDriveFiles) + ) + } + if (hold.query?.hangoutsChatQuery?.includeRooms !== undefined) { + appendLine(lines, 'Includes chat spaces', String(hold.query.hangoutsChatQuery.includeRooms)) + } + const coveredData = hold.query?.voiceQuery?.coveredData + if (coveredData && coveredData.length > 0) { + appendLine(lines, 'Covered Voice data', coveredData.join(', ')) + } + + return lines.join('\n') +} + +/** Renders a saved query and its search parameters as plain text. */ +function renderSavedQuery(savedQuery: VaultSavedQuery, matterId: string): string { + const lines: string[] = [] + appendLine(lines, 'Saved query', savedQuery.displayName) + appendLine(lines, 'Saved query ID', savedQuery.savedQueryId) + appendLine(lines, 'Matter ID', matterId) + appendLine(lines, 'Created', savedQuery.createTime) + + const query = savedQuery.query + appendLine(lines, 'Corpus', query?.corpus) + appendLine(lines, 'Data scope', query?.dataScope) + appendLine(lines, 'Search method', query?.method ?? query?.searchMethod) + appendLine(lines, 'Search terms', query?.terms) + appendLine(lines, 'Start time', query?.startTime) + appendLine(lines, 'End time', query?.endTime) + appendLine(lines, 'Time zone', query?.timeZone) + appendLine(lines, 'Accounts', query?.accountInfo?.emails?.join(', ')) + appendLine(lines, 'Organizational unit', query?.orgUnitInfo?.orgUnitId) + appendLine( + lines, + 'Shared drives', + (query?.sharedDriveInfo?.sharedDriveIds ?? query?.teamDriveInfo?.teamDriveIds)?.join(', ') + ) + appendLine(lines, 'Chat spaces', query?.hangoutsChatInfo?.roomId?.join(', ')) + appendLine(lines, 'Site URLs', query?.sitesUrlInfo?.urls?.join(', ')) + appendLine(lines, 'Drive documents', query?.driveDocumentInfo?.documentIds?.ids?.join(', ')) + + return lines.join('\n') +} + +/** + * Builds a matter document. + * + * Vault matters expose no modification timestamp, so change detection hashes the + * matter's own metadata fields. Every field in the hash comes from the `view=FULL` + * payload, which `listDocuments` and `getDocument` both request — so the hash is + * identical on both paths. + */ +async function matterToDocument(matter: VaultMatter): Promise { + const matterId = matter.matterId ?? '' + const canonical = JSON.stringify({ + name: matter.name ?? '', + description: matter.description ?? '', + state: matter.state ?? '', + matterRegion: matter.matterRegion ?? '', + permissions: (matter.matterPermissions ?? []) + .map((permission) => `${permission.accountId ?? ''}:${permission.role ?? ''}`) + .sort(), + }) + + return { + externalId: `matter:${matterId}`, + title: matter.name || `Matter ${matterId}`, + content: renderMatter(matter), + mimeType: 'text/plain', + contentHash: `gvault:matter:${matterId}:${await computeContentHash(canonical)}`, + metadata: { + resourceType: 'matter', + matterId, + state: matter.state, + }, + } +} + +/** + * Builds a hold document. Holds carry `updateTime`, which Vault bumps on every + * modification, so it is a sufficient change indicator. + */ +function holdToDocument(hold: VaultHold, matterId: string): ExternalDocument { + const holdId = hold.holdId ?? '' + return { + externalId: `hold:${matterId}:${holdId}`, + title: hold.name || `Hold ${holdId}`, + content: renderHold(hold, matterId), + mimeType: 'text/plain', + contentHash: `gvault:hold:${matterId}:${holdId}:${hold.updateTime ?? ''}`, + metadata: { + resourceType: 'hold', + matterId, + corpus: hold.corpus, + lastModified: hold.updateTime, + }, + } +} + +/** + * Builds a saved query document. Saved queries are immutable once created (the API + * exposes only create, get, list, and delete), so `createTime` identifies the version. + * The `v2` token in the hash tracks the rendering itself: because the source can never + * change, a rendering change would otherwise never re-index existing documents. + */ +function savedQueryToDocument(savedQuery: VaultSavedQuery, matterId: string): ExternalDocument { + const savedQueryId = savedQuery.savedQueryId ?? '' + return { + externalId: `savedQuery:${matterId}:${savedQueryId}`, + title: savedQuery.displayName || `Saved query ${savedQueryId}`, + content: renderSavedQuery(savedQuery, matterId), + mimeType: 'text/plain', + contentHash: `gvault:savedquery:v2:${matterId}:${savedQueryId}:${savedQuery.createTime ?? ''}`, + metadata: { + resourceType: 'savedQuery', + matterId, + corpus: savedQuery.query?.corpus, + lastModified: savedQuery.createTime, + }, + } +} + +/** Fetches one page of matters (or the single configured matter). */ +async function fetchMattersPage( + accessToken: string, + sourceConfig: Record, + pageToken?: string +): Promise<{ matters: VaultMatter[]; nextPageToken?: string }> { + const singleMatterId = readString(sourceConfig.matterId) + + if (singleMatterId) { + const response = await vaultGet( + accessToken, + `${VAULT_API_BASE}/matters/${encodeURIComponent(singleMatterId)}?view=FULL`, + 'matters.get' + ) + if (!response.ok) { + throw new Error(`Failed to fetch Vault matter ${singleMatterId}: ${response.status}`) + } + return { matters: [(await response.json()) as VaultMatter] } + } + + const params = new URLSearchParams({ view: 'FULL', pageSize: String(PAGE_SIZE) }) + const state = readString(sourceConfig.matterState) + if (state && state !== 'ALL') params.set('state', state) + if (pageToken) params.set('pageToken', pageToken) + + const response = await vaultGet( + accessToken, + `${VAULT_API_BASE}/matters?${params.toString()}`, + 'matters.list' + ) + if (!response.ok) { + throw new Error(`Failed to list Vault matters: ${response.status}`) + } + + const data = (await response.json()) as { matters?: VaultMatter[]; nextPageToken?: string } + return { matters: data.matters ?? [], nextPageToken: data.nextPageToken } +} + +/** Fetches one page of a matter's child resources of the given kind. */ +async function fetchChildPage( + accessToken: string, + matterId: string, + kind: VaultChildKind, + pageToken?: string +): Promise<{ documents: ExternalDocument[]; nextPageToken?: string }> { + const params = new URLSearchParams({ pageSize: String(PAGE_SIZE) }) + if (kind === 'holds') params.set('view', 'FULL_HOLD') + if (pageToken) params.set('pageToken', pageToken) + + const response = await vaultGet( + accessToken, + `${VAULT_API_BASE}/matters/${encodeURIComponent(matterId)}/${kind}?${params.toString()}`, + `matters.${kind}.list` + ) + if (!response.ok) { + throw new Error(`Failed to list ${kind} for matter ${matterId}: ${response.status}`) + } + + if (kind === 'holds') { + const data = (await response.json()) as { holds?: VaultHold[]; nextPageToken?: string } + return { + documents: (data.holds ?? []).map((hold) => holdToDocument(hold, matterId)), + nextPageToken: data.nextPageToken, + } + } + + const data = (await response.json()) as { + savedQueries?: VaultSavedQuery[] + nextPageToken?: string + } + return { + documents: (data.savedQueries ?? []).map((savedQuery) => + savedQueryToDocument(savedQuery, matterId) + ), + nextPageToken: data.nextPageToken, + } +} + +/** + * Drains every page of one matter's child listing of the given kind. + * + * `capped` is true when the listing was cut short — by the page bound or by a request + * failure (a matter the caller cannot read, or a transient error). The caller turns + * that into `syncContext.listingCapped` so deletion reconciliation is skipped for the + * run rather than hard-deleting documents that still exist at the source. + */ +async function fetchAllChildren( + accessToken: string, + matterId: string, + kind: VaultChildKind +): Promise<{ documents: ExternalDocument[]; capped: boolean }> { + const documents: ExternalDocument[] = [] + let pageToken: string | undefined + + try { + for (let page = 0; page < MAX_CHILD_PAGES; page++) { + const result = await fetchChildPage(accessToken, matterId, kind, pageToken) + documents.push(...result.documents) + if (!result.nextPageToken) return { documents, capped: false } + pageToken = result.nextPageToken + } + } catch (error) { + logger.warn(`Failed to list ${kind} for Vault matter ${matterId}`, { + error: toError(error).message, + }) + return { documents, capped: true } + } + + logger.warn(`Stopped listing ${kind} for Vault matter ${matterId} at the page bound`, { + maxChildPages: MAX_CHILD_PAGES, + }) + return { documents, capped: true } +} + +/** + * Lists every enabled child resource for a batch of matters, bounded concurrency. + * + * One `listDocuments` call covers a whole batch, which keeps the number of calls a + * sync needs proportional to the matter count rather than to `matters × kinds × + * child pages`. + */ +async function fetchChildrenForMatters( + accessToken: string, + matterIds: string[], + kinds: VaultChildKind[] +): Promise<{ documents: ExternalDocument[]; capped: boolean }> { + const tasks: { matterId: string; kind: VaultChildKind }[] = [] + for (const matterId of matterIds) { + for (const kind of kinds) tasks.push({ matterId, kind }) + } + + const documents: ExternalDocument[] = [] + let capped = false + + for (let index = 0; index < tasks.length; index += CHILD_CONCURRENCY) { + const results = await Promise.all( + tasks + .slice(index, index + CHILD_CONCURRENCY) + .map((task) => fetchAllChildren(accessToken, task.matterId, task.kind)) + ) + for (const result of results) { + documents.push(...result.documents) + if (result.capped) capped = true + } + } + + return { documents, capped } +} + +export const googleVaultConnector: ConnectorConfig = { + ...googleVaultConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const kinds = enabledChildKinds(sourceConfig) + const state = decodeCursor(cursor) + + let pageDocuments: ExternalDocument[] = [] + let nextState: VaultCursor | undefined + + if (state.phase === 'matters') { + const { matters, nextPageToken } = await fetchMattersPage( + accessToken, + sourceConfig, + state.mattersPageToken + ) + pageDocuments = await Promise.all(matters.map(matterToDocument)) + + const matterIds = matters + .map((matter) => matter.matterId) + .filter((matterId): matterId is string => Boolean(matterId)) + + if (kinds.length > 0 && matterIds.length > 0) { + nextState = { + phase: 'children', + matterIds, + matterIndex: 0, + nextMattersPageToken: nextPageToken, + } + } else if (nextPageToken) { + nextState = { phase: 'matters', mattersPageToken: nextPageToken } + } + } else { + const matterIds = state.matterIds ?? [] + const batchStart = state.matterIndex ?? 0 + const batch = matterIds.slice(batchStart, batchStart + CHILD_MATTER_BATCH) + + if (batch.length > 0 && kinds.length > 0) { + const children = await fetchChildrenForMatters(accessToken, batch, kinds) + pageDocuments = children.documents + if (children.capped && syncContext) syncContext.listingCapped = true + } + + const nextMatterIndex = batchStart + batch.length + if (nextMatterIndex < matterIds.length && kinds.length > 0) { + nextState = { ...state, matterIndex: nextMatterIndex } + } else if (state.nextMattersPageToken) { + nextState = { phase: 'matters', mattersPageToken: state.nextMattersPageToken } + } + } + + const maxDocuments = readMaxDocuments(sourceConfig.maxDocuments) + const alreadyFetched = (syncContext?.totalDocsFetched as number | undefined) ?? 0 + const { documents, indexableCount, capReached } = takeIndexableWithinCap( + pageDocuments, + () => false, + maxDocuments, + alreadyFetched + ) + if (syncContext) syncContext.totalDocsFetched = alreadyFetched + indexableCount + + const truncated = documents.length < pageDocuments.length + if (syncContext && (truncated || (capReached && nextState !== undefined))) { + syncContext.listingCapped = true + } + + return { + documents, + nextCursor: capReached || !nextState ? undefined : encodeCursor(nextState), + hasMore: !capReached && nextState !== undefined, + } + }, + + getDocument: async ( + accessToken: string, + _sourceConfig: Record, + externalId: string + ): Promise => { + const [kind, first, second] = externalId.split(':') + + try { + if (kind === 'matter') { + const response = await vaultGet( + accessToken, + `${VAULT_API_BASE}/matters/${encodeURIComponent(first)}?view=FULL`, + 'matters.get' + ) + if (response.status === 404 || response.status === 403) return null + if (!response.ok) throw new Error(`Failed to fetch Vault matter: ${response.status}`) + return await matterToDocument((await response.json()) as VaultMatter) + } + + if (kind === 'hold') { + const response = await vaultGet( + accessToken, + `${VAULT_API_BASE}/matters/${encodeURIComponent(first)}/holds/${encodeURIComponent(second)}?view=FULL_HOLD`, + 'matters.holds.get' + ) + if (response.status === 404 || response.status === 403) return null + if (!response.ok) throw new Error(`Failed to fetch Vault hold: ${response.status}`) + return holdToDocument((await response.json()) as VaultHold, first) + } + + if (kind === 'savedQuery') { + const response = await vaultGet( + accessToken, + `${VAULT_API_BASE}/matters/${encodeURIComponent(first)}/savedQueries/${encodeURIComponent(second)}`, + 'matters.savedQueries.get' + ) + if (response.status === 404 || response.status === 403) return null + if (!response.ok) throw new Error(`Failed to fetch Vault saved query: ${response.status}`) + return savedQueryToDocument((await response.json()) as VaultSavedQuery, first) + } + + logger.warn('Unrecognized Google Vault external ID', { externalId }) + return null + } catch (error) { + logger.warn(`Failed to fetch Google Vault document ${externalId}`, { + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const maxDocumentsInput = readString(sourceConfig.maxDocuments) + if (maxDocumentsInput && readMaxDocuments(maxDocumentsInput) === 0) { + return { valid: false, error: 'Max documents must be a positive number' } + } + + const matterState = readString(sourceConfig.matterState) + if (matterState && !['ALL', 'OPEN', 'CLOSED'].includes(matterState)) { + return { valid: false, error: `Unsupported matter state "${matterState}"` } + } + + const matterId = readString(sourceConfig.matterId) + + try { + const url = matterId + ? `${VAULT_API_BASE}/matters/${encodeURIComponent(matterId)}?view=BASIC` + : `${VAULT_API_BASE}/matters?pageSize=1&view=BASIC` + + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (matterId && response.status === 404) { + return { + valid: false, + error: `Matter "${matterId}" not found. Check the matter ID and your Vault permissions.`, + } + } + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: + 'Google Vault access denied. The account needs Vault privileges and the eDiscovery scope.', + } + } + return { valid: false, error: `Failed to access Google Vault: ${response.status}` } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: toError(error).message || 'Failed to validate configuration' } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + const resourceType = readString(metadata.resourceType) + if (resourceType) result.resourceType = resourceType + + const matterId = readString(metadata.matterId) + if (matterId) result.matterId = matterId + + const state = readString(metadata.state) + if (state) result.state = state + + const corpus = readString(metadata.corpus) + if (corpus) result.corpus = corpus + + const lastModified = parseTagDate(metadata.lastModified) + if (lastModified) result.lastModified = lastModified + + return result + }, +} diff --git a/apps/sim/connectors/google-vault/index.ts b/apps/sim/connectors/google-vault/index.ts new file mode 100644 index 00000000000..11eafbbded2 --- /dev/null +++ b/apps/sim/connectors/google-vault/index.ts @@ -0,0 +1 @@ +export { googleVaultConnector } from '@/connectors/google-vault/google-vault' diff --git a/apps/sim/connectors/google-vault/meta.ts b/apps/sim/connectors/google-vault/meta.ts new file mode 100644 index 00000000000..26dae0e504f --- /dev/null +++ b/apps/sim/connectors/google-vault/meta.ts @@ -0,0 +1,73 @@ +import { GoogleVaultIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const googleVaultConnectorMeta: ConnectorMeta = { + id: 'google_vault', + name: 'Google Vault', + description: 'Sync Google Vault matters, holds, and saved queries into your knowledge base', + version: '1.0.0', + icon: GoogleVaultIcon, + + auth: { + mode: 'oauth', + provider: 'google-vault', + requiredScopes: ['https://www.googleapis.com/auth/ediscovery.readonly'], + }, + + configFields: [ + { + id: 'matterId', + title: 'Matter ID', + type: 'short-input', + placeholder: 'e.g. 12345678901234567890 (leave blank to sync every matter)', + required: false, + description: 'Restrict the sync to a single matter. Leave blank to sync all matters.', + }, + { + id: 'matterState', + title: 'Matter State', + type: 'dropdown', + required: false, + options: [ + { label: 'All states', id: 'ALL' }, + { label: 'Open only', id: 'OPEN' }, + { label: 'Closed only', id: 'CLOSED' }, + ], + }, + { + id: 'includeHolds', + title: 'Include Holds', + type: 'dropdown', + required: false, + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'includeSavedQueries', + title: 'Include Saved Queries', + type: 'dropdown', + required: false, + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'maxDocuments', + title: 'Max Documents', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + tagDefinitions: [ + { id: 'resourceType', displayName: 'Resource Type', fieldType: 'text' }, + { id: 'matterId', displayName: 'Matter ID', fieldType: 'text' }, + { id: 'state', displayName: 'Matter State', fieldType: 'text' }, + { id: 'corpus', displayName: 'Corpus', fieldType: 'text' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + ], +} diff --git a/apps/sim/connectors/microsoft-excel/index.ts b/apps/sim/connectors/microsoft-excel/index.ts new file mode 100644 index 00000000000..57fecb895d1 --- /dev/null +++ b/apps/sim/connectors/microsoft-excel/index.ts @@ -0,0 +1 @@ +export { microsoftExcelConnector } from '@/connectors/microsoft-excel/microsoft-excel' diff --git a/apps/sim/connectors/microsoft-excel/meta.ts b/apps/sim/connectors/microsoft-excel/meta.ts new file mode 100644 index 00000000000..05ac3517d45 --- /dev/null +++ b/apps/sim/connectors/microsoft-excel/meta.ts @@ -0,0 +1,67 @@ +import { MicrosoftExcelIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const microsoftExcelConnectorMeta: ConnectorMeta = { + id: 'microsoft_excel', + name: 'Microsoft Excel', + description: 'Sync workbook sheet data from Microsoft Excel', + version: '1.0.0', + icon: MicrosoftExcelIcon, + + auth: { + mode: 'oauth', + provider: 'microsoft-excel', + requiredScopes: ['Files.ReadWrite'], + }, + + configFields: [ + { + id: 'driveId', + title: 'Drive ID (SharePoint)', + type: 'short-input', + required: false, + placeholder: 'Leave empty for your own OneDrive', + description: + 'The SharePoint document library (drive) ID holding the workbook. Leave empty to use your own OneDrive for Business. Workbooks stored in consumer OneDrive are not supported by the Excel API.', + }, + { + id: 'spreadsheetSelector', + title: 'Workbook', + type: 'selector', + selectorKey: 'microsoft.excel', + canonicalParamId: 'spreadsheetId', + mode: 'basic', + dependsOn: ['driveId'], + placeholder: 'Select a workbook', + required: true, + }, + { + id: 'spreadsheetId', + title: 'Workbook ID', + type: 'short-input', + canonicalParamId: 'spreadsheetId', + mode: 'advanced', + dependsOn: ['driveId'], + placeholder: 'e.g. 01ABC123DEF456', + required: true, + description: 'The Microsoft Graph drive item ID of the .xlsx workbook', + }, + { + id: 'sheetFilter', + title: 'Sheets to Sync', + type: 'dropdown', + required: false, + options: [ + { label: 'All sheets', id: 'all' }, + { label: 'First sheet only', id: 'first' }, + ], + }, + ], + + tagDefinitions: [ + { id: 'sheetTitle', displayName: 'Sheet Name', fieldType: 'text' }, + { id: 'rowCount', displayName: 'Row Count', fieldType: 'number' }, + { id: 'columnCount', displayName: 'Column Count', fieldType: 'number' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + ], +} diff --git a/apps/sim/connectors/microsoft-excel/microsoft-excel.ts b/apps/sim/connectors/microsoft-excel/microsoft-excel.ts new file mode 100644 index 00000000000..80b321cdb16 --- /dev/null +++ b/apps/sim/connectors/microsoft-excel/microsoft-excel.ts @@ -0,0 +1,692 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { RetryOptions } from '@/lib/knowledge/documents/utils' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { microsoftExcelConnectorMeta } from '@/connectors/microsoft-excel/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { markSkipped, parseTagDate, readBodyWithLimit } from '@/connectors/utils' +import type { ExcelCellValue } from '@/tools/microsoft_excel/types' +import { + escapeODataString, + getItemBasePath, + parseGraphErrorMessage, + trimTrailingEmptyRowsAndColumns, +} from '@/tools/microsoft_excel/utils' + +const logger = createLogger('MicrosoftExcelConnector') + +/** + * Separator between the workbook drive-item ID and the worksheet ID inside an + * `externalId`. Graph worksheet IDs are brace-wrapped GUIDs (`{FC03…A0}`), so they + * never contain this token. + */ +const SHEET_SEPARATOR = '__sheet__' + +/** + * Version token folded into `contentHash`. The hash is metadata-based (workbook + * `lastModifiedDateTime`), so a change to *how* cell content is rendered would + * otherwise leave every already-indexed worksheet on the old rendering until the + * workbook itself is edited. Bump this whenever the indexed text changes shape. + * `v2` = displayed `text` values instead of raw serial-number `values`. + */ +const CONTENT_FORMAT_VERSION = 'v2' + +/** + * Hard ceiling on the number of worksheets listed from a single workbook. Excel + * allows far more sheets than any knowledge base should absorb in one sync, so the + * listing stops here and flags `listingCapped` to keep deletion reconciliation from + * purging the sheets past the cap. + */ +const MAX_WORKSHEETS = 500 + +/** + * Origin every Graph response must stay on. `@odata.nextLink` is server-supplied + * and carries the bearer token when followed, so a link pointing anywhere else is + * dropped rather than requested. + */ +const GRAPH_API_BASE = 'https://graph.microsoft.com/' + +/** Maximum rows read from a single worksheet's used range. */ +const MAX_ROWS = 5000 + +/** Maximum columns read from a single worksheet's used range. */ +const MAX_COLUMNS = 200 + +/** + * Maximum cells read from a single worksheet. A workbook can declare a used range + * of millions of cells, and Graph serializes every one of them into the JSON body, + * so the row/column caps alone are not enough — the row cap is tightened further + * until the rectangle fits this budget. + */ +const MAX_CELLS = 200_000 + +/** + * Byte ceiling on a single range response. The caps above bound the *requested* + * rectangle, but individual cells carry arbitrary user text, so the body is read + * through a streaming limiter and abandoned rather than buffered if it overruns. + */ +const MAX_RANGE_RESPONSE_BYTES = 16 * 1024 * 1024 + +/** + * Byte ceiling on the used-range *metadata* response. `$select=address` keeps that + * body to a few hundred bytes, but Graph documents that unsupported query + * parameters can "fail silently" (https://learn.microsoft.com/en-us/graph/query-parameters + * — "Error handling for query parameters"), and the `usedRange` reference page — unlike + * `worksheets` — has no "Optional query parameters" section promising `$select` support. + * If the projection is ever dropped, Graph serializes the whole grid (values, text, + * formulas, numberFormat, valueTypes) into this response, so it is read through the + * streaming limiter too: the dimensions-only design degrades to a skipped worksheet + * rather than an unbounded buffer. + */ +const MAX_USED_RANGE_RESPONSE_BYTES = 1024 * 1024 + +interface Worksheet { + id: string + name: string + position: number + visibility?: string +} + +interface WorksheetListResponse { + value?: Worksheet[] + '@odata.nextLink'?: string +} + +interface WorkbookItem { + id: string + name?: string + webUrl?: string + lastModifiedDateTime?: string +} + +interface UsedRangeMetadata { + address?: string +} + +interface RangeValues { + address?: string + text?: string[][] + values?: ExcelCellValue[][] +} + +/** A1-style rectangle, all bounds 1-based and inclusive. */ +interface CellRect { + startRow: number + startColumn: number + endRow: number + endColumn: number +} + +/** Converts an A1 column label (`A`, `Z`, `AA`) to its 1-based index. */ +export function columnLabelToIndex(label: string): number { + let index = 0 + for (const char of label.toUpperCase()) { + index = index * 26 + (char.charCodeAt(0) - 64) + } + return index +} + +/** Converts a 1-based column index to its A1 label (`1` → `A`, `27` → `AA`). */ +export function columnIndexToLabel(index: number): string { + let remaining = index + let label = '' + while (remaining > 0) { + const rest = (remaining - 1) % 26 + label = String.fromCharCode(65 + rest) + label + remaining = Math.floor((remaining - 1) / 26) + } + return label +} + +/** + * Parses a Graph range address (`Sheet1!B2:F400`, `'My Sheet'!A1`) into its + * rectangle. The sheet-name prefix is split on the LAST `!` because Excel permits + * `!` inside a quoted sheet name. Returns `null` when the address is not an + * absolute A1 rectangle we can bound. + */ +export function parseRangeAddress(address: string): CellRect | null { + const bangIndex = address.lastIndexOf('!') + const local = bangIndex === -1 ? address : address.slice(bangIndex + 1) + const cellPattern = /^\$?([A-Za-z]+)\$?(\d+)$/ + + const [startCell, endCell] = local.split(':') + const start = startCell?.match(cellPattern) + if (!start) return null + + const end = endCell ? endCell.match(cellPattern) : start + if (!end) return null + + return { + startRow: Number(start[2]), + startColumn: columnLabelToIndex(start[1]), + endRow: Number(end[2]), + endColumn: columnLabelToIndex(end[1]), + } +} + +/** + * Shrinks a used-range rectangle to the connector's row, column, and cell caps. + * The rectangle is always anchored at the used range's top-left cell so the header + * row survives; the column cap is applied first, then the row cap is tightened + * further until the remaining rectangle fits {@link MAX_CELLS}. + */ +export function capRect(rect: CellRect): { rect: CellRect; capped: boolean } { + const cappedEndColumn = Math.min(rect.endColumn, rect.startColumn + MAX_COLUMNS - 1) + const columns = cappedEndColumn - rect.startColumn + 1 + const rowBudget = Math.max(1, Math.min(MAX_ROWS, Math.floor(MAX_CELLS / columns))) + const cappedEndRow = Math.min(rect.endRow, rect.startRow + rowBudget - 1) + + return { + rect: { ...rect, endColumn: cappedEndColumn, endRow: cappedEndRow }, + capped: cappedEndColumn < rect.endColumn || cappedEndRow < rect.endRow, + } +} + +/** Renders a rectangle as a sheet-relative A1 address (`B2:F400`). */ +function formatRect(rect: CellRect): string { + const start = `${columnIndexToLabel(rect.startColumn)}${rect.startRow}` + const end = `${columnIndexToLabel(rect.endColumn)}${rect.endRow}` + return start === end ? start : `${start}:${end}` +} + +/** Normalizes a Graph cell value to the plain string used in indexed content. */ +function cellToString(value: ExcelCellValue): string { + if (value === null || value === undefined) return '' + return String(value) +} + +/** + * Formats worksheet rows into an LLM-friendly text representation, labelling each + * row by index and each cell by its header name. Mirrors the Google Sheets + * connector so both spreadsheet sources chunk identically. + */ +export function formatSheetContent(headers: string[], rows: ExcelCellValue[][]): string { + if (headers.length === 0) return '' + + const lines: string[] = [] + for (let i = 0; i < rows.length; i++) { + const row = rows[i] ?? [] + lines.push(`Row ${i + 1}:`) + for (let j = 0; j < headers.length; j++) { + lines.push(` ${headers[j]}: ${cellToString(row[j])}`) + } + lines.push('') + } + + return lines.join('\n').trim() +} + +/** Builds the Graph URL for a worksheet, addressing it by name (Graph accepts id or name). */ +function worksheetUrl(basePath: string, sheetName: string): string { + return `${basePath}/workbook/worksheets('${encodeURIComponent(escapeODataString(sheetName))}')` +} + +/** Throws a Graph-formatted error for a failed response. */ +async function graphError(response: Response, context: string): Promise { + const body = await response.text().catch(() => '') + const detail = parseGraphErrorMessage(response.status, response.statusText, body) + throw new Error(`${context}: ${detail}`) +} + +/** Fetches the workbook drive item (name, webUrl, lastModifiedDateTime). */ +async function fetchWorkbookItem( + accessToken: string, + basePath: string, + retryOptions?: RetryOptions +): Promise { + const response = await fetchWithRetry( + `${basePath}?$select=id,name,webUrl,lastModifiedDateTime`, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }, + retryOptions + ) + + if (response.status === 404 || response.status === 410) return null + if (!response.ok) await graphError(response, 'Failed to fetch workbook') + + return (await response.json()) as WorkbookItem +} + +/** Lists the workbook's worksheets in tab order. */ +async function fetchWorksheets(accessToken: string, basePath: string): Promise { + const worksheets: Worksheet[] = [] + let url: string | undefined = + `${basePath}/workbook/worksheets?$select=id,name,position,visibility&$orderby=position` + + /** + * Graph paginates collection responses, so a workbook with more sheets than fit + * in one page must follow `@odata.nextLink`. Reading only the first page would + * drop the remainder from the listing without setting `listingCapped`, and the + * sync engine would then reconcile those documents away as deleted. The walk is + * bounded by `MAX_WORKSHEETS`, whose truncation the caller does flag. + */ + while (url && worksheets.length <= MAX_WORKSHEETS) { + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }) + + if (!response.ok) await graphError(response, 'Failed to list worksheets') + + const data = (await response.json()) as WorksheetListResponse + worksheets.push(...(data.value ?? [])) + + const next = data['@odata.nextLink'] + url = next?.startsWith(GRAPH_API_BASE) ? next : undefined + } + + return worksheets +} + +/** + * Fetches the worksheet's used-range dimensions WITHOUT its values. + * `$select` keeps the response to a few bytes, so the connector can decide how much + * of a potentially enormous sheet to read before requesting any cell data. + */ +async function fetchUsedRangeMetadata( + accessToken: string, + basePath: string, + sheetName: string +): Promise { + const url = `${worksheetUrl(basePath, sheetName)}/usedRange(valuesOnly=true)?$select=address` + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }) + + if (response.status === 404) return null + if (!response.ok) await graphError(response, `Failed to read used range for "${sheetName}"`) + + const buffer = await readBodyWithLimit(response, MAX_USED_RANGE_RESPONSE_BYTES) + if (!buffer) throw new RangeTooLargeError(MAX_USED_RANGE_RESPONSE_BYTES) + + return JSON.parse(buffer.toString('utf8')) as UsedRangeMetadata +} + +/** + * Raised when a range response exceeds its byte ceiling. The body is abandoned + * mid-stream rather than buffered, so the worksheet surfaces as a skipped document + * instead of pulling an unbounded payload into memory. + */ +class RangeTooLargeError extends Error { + constructor(maxBytes: number) { + super(`Worksheet range response exceeds ${maxBytes} bytes`) + this.name = 'RangeTooLargeError' + } +} + +/** + * Fetches the capped rectangle of cell values from a worksheet. + * + * Both `text` and `values` are projected. `values` carries the *raw* cell values, so a + * date or currency cell comes back as its underlying serial number (`42019`, not + * `1/15/2015`) — useless for retrieval. `text` carries the displayed strings and, + * per the Range reference, "doesn't depend on the cell width. The # sign substitution + * that happens in Excel UI doesn't affect the text value returned by the API" + * (https://learn.microsoft.com/en-us/graph/api/resources/range), so it never degrades + * to `#######`. This matches the Google Sheets connector's `FORMATTED_VALUE`. + * `values` is kept only as a fallback for the rows `text` does not cover. + */ +async function fetchRangeValues( + accessToken: string, + basePath: string, + sheetName: string, + address: string +): Promise { + const url = `${worksheetUrl(basePath, sheetName)}/range(address='${encodeURIComponent(address)}')?$select=address,text,values` + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }) + + if (!response.ok) await graphError(response, `Failed to read range for "${sheetName}"`) + + const buffer = await readBodyWithLimit(response, MAX_RANGE_RESPONSE_BYTES) + if (!buffer) throw new RangeTooLargeError(MAX_RANGE_RESPONSE_BYTES) + + return JSON.parse(buffer.toString('utf8')) as RangeValues +} + +interface WorkbookSnapshot { + workbook: WorkbookItem | null + worksheets: Worksheet[] +} + +/** + * Loads the workbook drive item and its worksheet list, memoizing the in-flight promise + * on `syncContext` for the duration of the sync run. The sync engine hydrates deferred + * documents concurrently, so without this every worksheet in the workbook would repeat + * both calls — 2N Graph requests against an API that throttles aggressively. A rejected + * promise is evicted so a transient failure does not poison the rest of the run. + */ +async function loadWorkbookSnapshot( + accessToken: string, + basePath: string, + spreadsheetId: string, + syncContext?: Record +): Promise { + const cacheKey = `workbookSnapshot:${spreadsheetId}` + const cached = syncContext?.[cacheKey] as Promise | undefined + if (cached) return cached + + const pending = (async (): Promise => { + const workbook = await fetchWorkbookItem(accessToken, basePath) + if (!workbook) return { workbook: null, worksheets: [] } + return { workbook, worksheets: await fetchWorksheets(accessToken, basePath) } + })() + + if (syncContext) { + syncContext[cacheKey] = pending + pending.catch(() => { + if (syncContext[cacheKey] === pending) delete syncContext[cacheKey] + }) + } + + return pending +} + +/** Composes the stable external ID for one worksheet inside a workbook. */ +function buildExternalId(spreadsheetId: string, worksheetId: string): string { + return `${spreadsheetId}${SHEET_SEPARATOR}${worksheetId}` +} + +/** Splits an external ID back into its workbook and worksheet IDs. */ +export function parseExternalId( + externalId: string +): { spreadsheetId: string; worksheetId: string } | null { + const index = externalId.indexOf(SHEET_SEPARATOR) + if (index <= 0) return null + + const spreadsheetId = externalId.slice(0, index) + const worksheetId = externalId.slice(index + SHEET_SEPARATOR.length) + if (!spreadsheetId || !worksheetId) return null + + return { spreadsheetId, worksheetId } +} + +/** + * Builds the deferred listing stub for one worksheet. Used by both `listDocuments` + * and `getDocument` so the metadata-based `contentHash` is byte-identical on both + * paths — the sync engine compares them directly to decide what to re-index. + */ +function sheetToStub( + spreadsheetId: string, + workbook: WorkbookItem, + sheet: Worksheet +): ExternalDocument { + const workbookTitle = workbook.name ?? 'Workbook' + return { + externalId: buildExternalId(spreadsheetId, sheet.id), + title: `${workbookTitle} - ${sheet.name}`, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: workbook.webUrl, + contentHash: `microsoft_excel:${CONTENT_FORMAT_VERSION}:${spreadsheetId}:${sheet.id}:${workbook.lastModifiedDateTime ?? ''}`, + metadata: { + spreadsheetId, + workbookName: workbookTitle, + sheetTitle: sheet.name, + worksheetId: sheet.id, + position: sheet.position, + visibility: sheet.visibility, + lastModifiedDateTime: workbook.lastModifiedDateTime, + }, + } +} + +/** Resolves the workbook's Graph base path from the connector's source config. */ +function resolveBasePath(sourceConfig: Record): { + spreadsheetId: string + basePath: string +} { + const spreadsheetId = + typeof sourceConfig.spreadsheetId === 'string' ? sourceConfig.spreadsheetId.trim() : '' + if (!spreadsheetId) { + throw new Error('Workbook ID is required') + } + + const driveId = typeof sourceConfig.driveId === 'string' ? sourceConfig.driveId.trim() : '' + return { spreadsheetId, basePath: getItemBasePath(spreadsheetId, driveId || undefined) } +} + +export const microsoftExcelConnector: ConnectorConfig = { + ...microsoftExcelConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + _cursor?: string, + syncContext?: Record + ): Promise => { + const { spreadsheetId, basePath } = resolveBasePath(sourceConfig) + + const { workbook, worksheets } = await loadWorkbookSnapshot( + accessToken, + basePath, + spreadsheetId, + syncContext + ) + + /** + * A 404/410 means the drive item is gone for good, so the listing is genuinely + * empty and reconciliation should purge the workbook's sheets. `listingCapped` + * is deliberately NOT set here — a permissions failure surfaces as 401/403 and + * throws from `fetchWorkbookItem` instead. + */ + if (!workbook) { + logger.info('Workbook not found; listing no documents', { spreadsheetId }) + return { documents: [], hasMore: false } + } + + const sheetFilter = typeof sourceConfig.sheetFilter === 'string' ? sourceConfig.sheetFilter : '' + const scoped = sheetFilter === 'first' ? worksheets.slice(0, 1) : worksheets + + const selected = scoped.slice(0, MAX_WORKSHEETS) + if (selected.length < scoped.length && syncContext) { + logger.warn('Worksheet listing truncated by the connector cap', { + spreadsheetId, + total: scoped.length, + cap: MAX_WORKSHEETS, + }) + syncContext.listingCapped = true + } + + logger.info('Listing Microsoft Excel worksheets', { + spreadsheetId, + workbookName: workbook.name, + sheetCount: selected.length, + }) + + return { + documents: selected.map((sheet) => sheetToStub(spreadsheetId, workbook, sheet)), + hasMore: false, + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string, + syncContext?: Record + ): Promise => { + const parsed = parseExternalId(externalId) + if (!parsed) { + logger.warn('Invalid external ID format', { externalId }) + return null + } + + const driveId = typeof sourceConfig.driveId === 'string' ? sourceConfig.driveId.trim() : '' + const basePath = getItemBasePath(parsed.spreadsheetId, driveId || undefined) + + const { workbook, worksheets } = await loadWorkbookSnapshot( + accessToken, + basePath, + parsed.spreadsheetId, + syncContext + ) + if (!workbook) { + logger.info('Workbook not found', { spreadsheetId: parsed.spreadsheetId }) + return null + } + + const sheet = worksheets.find((candidate) => candidate.id === parsed.worksheetId) + if (!sheet) { + logger.info('Worksheet no longer exists in the workbook', { externalId }) + return null + } + + const stub = sheetToStub(parsed.spreadsheetId, workbook, sheet) + + try { + const usedRange = await fetchUsedRangeMetadata(accessToken, basePath, sheet.name) + const address = usedRange?.address + if (!address) return null + + const rect = parseRangeAddress(address) + if (!rect) { + logger.warn('Unparseable used-range address', { externalId, address }) + return null + } + + const { rect: capped, capped: wasCapped } = capRect(rect) + if (wasCapped) { + logger.warn('Worksheet content truncated by the connector cell caps', { + externalId, + usedRangeAddress: address, + indexedRangeAddress: formatRect(capped), + }) + } + const range = await fetchRangeValues(accessToken, basePath, sheet.name, formatRect(capped)) + const values = trimTrailingEmptyRowsAndColumns(range.text ?? range.values ?? []) + + if (values.length < 2) return null + + const headers = values[0].map((header, index) => { + const label = cellToString(header).trim() + return label || `Column ${index + 1}` + }) + + const body = formatSheetContent(headers, values.slice(1)) + if (!body.trim()) return null + + const content = wasCapped + ? `${body}\n\n[Truncated: only ${formatRect(capped)} of ${address} was indexed]` + : body + + return { + ...stub, + content, + contentDeferred: false, + metadata: { + ...stub.metadata, + rowCount: values.length - 1, + columnCount: headers.length, + usedRangeAddress: address, + indexedRangeAddress: formatRect(capped), + truncated: wasCapped, + }, + } + } catch (error) { + if (error instanceof RangeTooLargeError) { + logger.info('Skipping oversized worksheet range', { externalId }) + return markSkipped(stub, 'Worksheet exceeds the connector size limit and was not indexed') + } + logger.warn('Failed to extract content from worksheet', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const sheetFilter = sourceConfig.sheetFilter + if ( + sheetFilter !== undefined && + sheetFilter !== '' && + sheetFilter !== 'all' && + sheetFilter !== 'first' + ) { + return { valid: false, error: 'Sheets to Sync must be either "all" or "first"' } + } + + let basePath: string + try { + basePath = resolveBasePath(sourceConfig).basePath + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Workbook ID is required') } + } + + try { + const workbook = await fetchWorkbookItem(accessToken, basePath, VALIDATE_RETRY_OPTIONS) + if (!workbook) { + return { + valid: false, + error: 'Workbook not found. Check the workbook ID and that your account can access it.', + } + } + + const response = await fetchWithRetry( + `${basePath}/workbook/worksheets?$select=id&$top=1`, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 403) { + return { + valid: false, + error: 'Access denied. Ensure the workbook is shared with your Microsoft account.', + } + } + if (response.status === 404) { + return { + valid: false, + error: 'This file is not an Excel workbook, or it no longer exists.', + } + } + const body = await response.text().catch(() => '') + return { + valid: false, + error: parseGraphErrorMessage(response.status, response.statusText, body), + } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.sheetTitle === 'string') { + result.sheetTitle = metadata.sheetTitle + } + + if (typeof metadata.rowCount === 'number') { + result.rowCount = metadata.rowCount + } + + if (typeof metadata.columnCount === 'number') { + result.columnCount = metadata.columnCount + } + + const lastModified = parseTagDate(metadata.lastModifiedDateTime) + if (lastModified) { + result.lastModified = lastModified + } + + return result + }, +} diff --git a/apps/sim/connectors/mintlify/index.ts b/apps/sim/connectors/mintlify/index.ts new file mode 100644 index 00000000000..80880199d18 --- /dev/null +++ b/apps/sim/connectors/mintlify/index.ts @@ -0,0 +1 @@ +export { mintlifyConnector } from '@/connectors/mintlify/mintlify' diff --git a/apps/sim/connectors/mintlify/meta.ts b/apps/sim/connectors/mintlify/meta.ts new file mode 100644 index 00000000000..7800875a90d --- /dev/null +++ b/apps/sim/connectors/mintlify/meta.ts @@ -0,0 +1,63 @@ +import { MintlifyIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +/** Default ceiling on indexed pages per sync when `maxPages` is not configured. */ +export const DEFAULT_MAX_PAGES = 500 + +/** Hard ceiling on the configurable `maxPages` value. */ +export const MAX_PAGES_LIMIT = 5000 + +export const mintlifyConnectorMeta: ConnectorMeta = { + id: 'mintlify', + name: 'Mintlify', + description: 'Sync pages from a hosted Mintlify documentation site', + version: '1.0.0', + icon: MintlifyIcon, + + /** + * The key is sent as a bearer token to the documentation host itself, not to + * `api.mintlify.com` — Mintlify's own API keys authenticate the dashboard and + * assistant APIs, which have no page-listing endpoint. It is therefore only + * useful for a site fronted by a proxy that accepts a bearer token, so it is + * declared optional and public sites can be connected with no key at all. + */ + auth: { + mode: 'apiKey', + label: 'Access Token', + placeholder: 'Only needed if your docs site requires a bearer token', + optional: true, + }, + + configFields: [ + { + id: 'siteUrl', + title: 'Documentation Site URL', + type: 'short-input', + placeholder: 'https://docs.yourcompany.com', + required: true, + description: + 'Base URL of your published Mintlify site. Pages are discovered from the llms.txt file Mintlify hosts there.', + }, + { + id: 'pathPrefix', + title: 'Path Prefix', + type: 'short-input', + placeholder: 'e.g. /guides', + required: false, + description: 'Only sync pages whose path starts with this prefix (leave empty for all pages)', + }, + { + id: 'maxPages', + title: 'Max Pages', + type: 'short-input', + placeholder: `e.g. 200 (default: ${DEFAULT_MAX_PAGES}, max: ${MAX_PAGES_LIMIT})`, + required: false, + description: 'Maximum number of documentation pages to index', + }, + ], + + tagDefinitions: [ + { id: 'section', displayName: 'Section', fieldType: 'text' }, + { id: 'description', displayName: 'Description', fieldType: 'text' }, + ], +} diff --git a/apps/sim/connectors/mintlify/mintlify.ts b/apps/sim/connectors/mintlify/mintlify.ts new file mode 100644 index 00000000000..f4b9592abf9 --- /dev/null +++ b/apps/sim/connectors/mintlify/mintlify.ts @@ -0,0 +1,637 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { validateExternalUrl } from '@/lib/core/security/input-validation' +import { + type SecureFetchRetryOptions, + secureFetchWithRetry, +} from '@/lib/knowledge/documents/secure-fetch.server' +import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { + DEFAULT_MAX_PAGES, + MAX_PAGES_LIMIT, + mintlifyConnectorMeta, +} from '@/connectors/mintlify/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { computeContentHash, htmlToPlainText } from '@/connectors/utils' + +const logger = createLogger('MintlifyConnector') + +/** Documents returned per `listDocuments` call. */ +const DOCS_PER_PAGE = 50 + +/** Byte cap for the page-index file (`llms.txt` / `sitemap.xml`) of a user-supplied host. */ +const INDEX_MAX_BYTES = 5 * 1024 * 1024 + +/** Byte cap for a single documentation page body. */ +const PAGE_MAX_BYTES = 1024 * 1024 + +/** Child sitemaps followed from a ``, bounding a hostile or huge index. */ +const MAX_CHILD_SITEMAPS = 20 + +/** A page discovered from the site's index file. */ +interface MintlifyPageLink { + /** Site-absolute path without the `.md` extension, e.g. `/docs/quickstart`. */ + path: string + title: string + description?: string + /** Nearest markdown heading above the link in `llms.txt`. */ + section?: string +} + +interface MintlifySite { + /** Scheme + host of the configured site, e.g. `https://docs.example.com`. */ + origin: string + /** Configured base URL with any trailing slash removed, e.g. `https://docs.example.com/docs`. */ + baseUrl: string + /** Hostname with a leading `www.` removed, used for same-site link filtering. */ + hostKey: string + /** Path portion of `baseUrl` without a trailing slash, e.g. `/docs`, or `''` at the origin root. */ + basePath: string +} + +/** + * Normalizes the configured documentation site URL and runs an early structural + * SSRF check via the shared `validateExternalUrl` policy. + * + * The authoritative SSRF boundary is enforced at request time: every site request + * goes through {@link secureFetchWithRetry}, which resolves DNS, re-checks the + * resolved IP, and pins the connection to it — closing the DNS-rebinding gap a + * synchronous string check cannot. + */ +function resolveSite(rawUrl: string | undefined): MintlifySite { + let url = (rawUrl || '').trim().replace(/\/+$/, '') + if (!url) { + throw new Error('Documentation site URL is required') + } + if (!url.startsWith('https://') && !url.startsWith('http://')) { + url = `https://${url}` + } + + const validation = validateExternalUrl(url, 'siteUrl') + if (!validation.isValid) { + throw new Error(validation.error || 'Invalid documentation site URL') + } + + const parsed = new URL(url) + return { + origin: parsed.origin, + baseUrl: url, + hostKey: parsed.hostname.replace(/^www\./, ''), + basePath: parsed.pathname.replace(/\/+$/, ''), + } +} + +/** Resolves the configured page cap, clamped to {@link MAX_PAGES_LIMIT}. */ +function resolveMaxPages(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_PAGES + return Math.min(Math.floor(parsed), MAX_PAGES_LIMIT) +} + +/** + * Normalizes an optional path prefix filter to a leading-slash, no-trailing-slash + * form, or `''`. + * + * The trailing slash has to go: {@link isUnderPath} accepts an exact match or + * `prefix + '/'`, so a raw `/guides/` would match neither `/guides` nor + * `/guides/intro` and the source would silently sync nothing. + */ +function resolvePathPrefix(value: unknown): string { + const trimmed = typeof value === 'string' ? value.trim() : '' + const prefix = trimmed.replace(/\/+$/, '') + if (!prefix) return '' + return prefix.startsWith('/') ? prefix : `/${prefix}` +} + +/** Bearer headers, omitted when no key is configured (public sites need none). */ +function siteHeaders(accessToken: string, accept: string): Record { + const headers: Record = { Accept: accept } + if (accessToken?.trim()) { + headers.Authorization = `Bearer ${accessToken.trim()}` + } + return headers +} + +/** + * Fetches a text resource from the user-supplied documentation host. + * + * `stripAuthOnRedirect` keeps the configured key from being forwarded to a + * redirect target on another origin. + */ +async function fetchSiteText( + url: string, + accessToken: string, + accept: string, + maxBytes: number, + retryOptions?: SecureFetchRetryOptions +): Promise<{ body: string; contentType: string } | null> { + const response = await secureFetchWithRetry( + url, + { + method: 'GET', + headers: siteHeaders(accessToken, accept), + stripAuthOnRedirect: true, + }, + { ...retryOptions, maxResponseBytes: maxBytes } + ) + + if (!response.ok) { + if (response.status === 404) return null + throw new Error(`Mintlify site returned status ${response.status} for ${url}`) + } + + return { + body: await response.text(), + contentType: response.headers.get('content-type') ?? '', + } +} + +/** + * Converts a discovered link into a site-absolute path, or `null` when it points + * off-site, is not an http(s) URL, or is not a documentation page. A leading + * `www.` is ignored on both sides because Mintlify sites commonly redirect + * between the apex and `www` host while emitting the canonical one in `llms.txt`. + */ +function toPagePath(rawHref: string, site: MintlifySite): string | null { + let parsed: URL + try { + parsed = new URL(rawHref, `${site.origin}/`) + } catch { + return null + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null + if (parsed.hostname.replace(/^www\./, '') !== site.hostKey) return null + + let path = parsed.pathname.replace(/\.md$/i, '').replace(/\/+$/, '') + if (!path) path = '/' + if (/\.(xml|json|ya?ml|txt|png|jpe?g|svg|gif|webp|pdf|zip|css|js)$/i.test(path)) return null + return path +} + +/** Derives a human-readable title from a page path, e.g. `/docs/api-keys` → `Api keys`. */ +function titleFromPath(path: string): string { + const slug = path.split('/').filter(Boolean).pop() || 'Index' + const words = slug.replace(/[-_]+/g, ' ').trim() + return words.charAt(0).toUpperCase() + words.slice(1) +} + +const LLMS_LINK_PATTERN = /^\s*[-*]?\s*\[([^\]]+)\]\(([^)\s]+)\)\s*(.*)$/ +const MARKDOWN_HEADING_PATTERN = /^(#{1,6})\s+(.+?)\s*$/ + +/** + * Strips the separator between a link and its description. `llms.txt` is a loose + * convention, not a spec: Mintlify, Trigger.dev, and Resend emit + * `](url.md): description` while Anthropic emits `](url.md) - description`, so + * both a colon and a dash lead-in are removed. + */ +const LLMS_DESCRIPTION_SEPARATOR = /^[\s:\-–—]+/ + +/** + * Parses the markdown link list Mintlify publishes at `/llms.txt`. Each entry is + * a `- [Title](https://site/path.md): description` line, grouped under markdown + * headings that name the navigation section. + */ +function parseLlmsTxt(body: string, site: MintlifySite): MintlifyPageLink[] { + const pages: MintlifyPageLink[] = [] + const seen = new Set() + let section: string | undefined + + for (const line of body.split('\n')) { + const heading = MARKDOWN_HEADING_PATTERN.exec(line) + if (heading) { + section = heading[2] + continue + } + + const match = LLMS_LINK_PATTERN.exec(line) + if (!match) continue + + const path = toPagePath(match[2], site) + if (!path || seen.has(path)) continue + seen.add(path) + + const description = match[3]?.replace(LLMS_DESCRIPTION_SEPARATOR, '').trim() + pages.push({ + path, + title: match[1].trim() || titleFromPath(path), + description: description || undefined, + section, + }) + } + + return pages +} + +const SITEMAP_LOC_PATTERN = /\s*([^<\s]+)\s*<\/loc>/gi +const SITEMAP_INDEX_PATTERN = /]/i + +/** Extracts every `` value from a sitemap document. */ +function sitemapLocations(body: string): string[] { + return [...body.matchAll(SITEMAP_LOC_PATTERN)].map((match) => match[1]) +} + +/** Parses `` page entries from a sitemap, used when the site has no `llms.txt`. */ +function parseSitemap(locations: string[], site: MintlifySite): MintlifyPageLink[] { + const pages: MintlifyPageLink[] = [] + const seen = new Set() + + for (const location of locations) { + const path = toPagePath(location, site) + if (!path || seen.has(path)) continue + seen.add(path) + pages.push({ path, title: titleFromPath(path) }) + } + + return pages +} + +/** + * Resolves a same-site child-sitemap URL, or `null` when it points off-host. + * `toPagePath` cannot be reused here because it rejects `.xml` by design. + */ +function sameSiteSitemapUrl(rawHref: string, site: MintlifySite): string | null { + try { + const parsed = new URL(rawHref, `${site.origin}/`) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null + if (parsed.hostname.replace(/^www\./, '') !== site.hostKey) return null + return parsed.toString() + } catch { + return null + } +} + +/** + * Reads the site's sitemap, following a `` into its child sitemaps. + * Without this a site that splits its sitemap would parse to zero pages — every + * `` would be a `.xml` URL that {@link toPagePath} discards. + */ +async function discoverFromSitemap( + site: MintlifySite, + accessToken: string, + retryOptions?: SecureFetchRetryOptions +): Promise { + const root = await fetchSiteText( + `${site.baseUrl}/sitemap.xml`, + accessToken, + 'application/xml', + INDEX_MAX_BYTES, + retryOptions + ) + if (!root) return [] + + if (!SITEMAP_INDEX_PATTERN.test(root.body)) { + return parseSitemap(sitemapLocations(root.body), site) + } + + const allChildUrls = sitemapLocations(root.body) + .map((location) => sameSiteSitemapUrl(location, site)) + .filter((url): url is string => Boolean(url)) + + if (allChildUrls.length > MAX_CHILD_SITEMAPS) { + throw new Error( + `Sitemap index at ${site.baseUrl}/sitemap.xml lists ${allChildUrls.length} child sitemaps (limit ${MAX_CHILD_SITEMAPS})` + ) + } + + logger.info('Following Mintlify sitemap index', { children: allChildUrls.length }) + + const locations: string[] = [] + for (const childUrl of allChildUrls) { + const child = await fetchSiteText( + childUrl, + accessToken, + 'application/xml', + INDEX_MAX_BYTES, + retryOptions + ) + /** + * A missing child sitemap is fatal rather than skipped. `fetchSiteText` + * maps 404 to `null`, so continuing here would hand the sync engine a + * listing short by one child's worth of pages — a partial listing the + * engine cannot distinguish from genuine deletions, which reconciles every + * page of that child out of the knowledge base. + */ + if (!child) { + throw new Error(`Child sitemap ${childUrl} listed in the sitemap index is unavailable`) + } + locations.push(...sitemapLocations(child.body)) + } + + return parseSitemap(locations, site) +} + +/** + * Restricts discovered pages to the configured base path. + * + * Applied to every discovery source, not just the origin-level index: a sub-path + * site's `sitemap.xml` is equally free to enumerate the whole host, and a listing + * that reaches outside the configured scope indexes pages the user did not ask + * for. A no-op when the site is configured at the host root. + */ +function withinBasePath(pages: MintlifyPageLink[], site: MintlifySite): MintlifyPageLink[] { + if (!site.basePath) return pages + return pages.filter((page) => isUnderPath(page.path, site.basePath)) +} + +/** + * Whether `path` is `prefix` itself or sits beneath it. + * + * A bare `startsWith` would also match a sibling whose name merely begins with + * the prefix — `/guides` would capture `/guides-archive` — so the boundary `/` + * is required. + */ +function isUnderPath(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}/`) +} + +/** + * Discovers every page of the site. + * + * Mintlify's REST API has no page-enumeration endpoint — its discovery API only + * supports query-driven search and path-addressed page reads — so the index files + * Mintlify auto-publishes are the enumeration path. `/llms.txt` is preferred + * because it carries titles, descriptions, and section grouping; `/sitemap.xml` + * is the fallback for sites that disabled it. + */ +async function discoverPages( + site: MintlifySite, + accessToken: string, + retryOptions?: SecureFetchRetryOptions +): Promise { + /** + * The origin-level index is only consulted for a site published at the host + * root. On a sub-path site (`https://example.com/docs`) it describes the whole + * host — the marketing site — and its handful of incidental `/docs` links is a + * far worse listing than the sitemap's. Trusting it there produced a listing of + * 13 pages for `trigger.dev/docs` against the 306 its own index publishes, + * which the sync engine would reconcile as ~293 deletions. + */ + const indexUrls = [ + ...new Set([ + `${site.baseUrl}/llms.txt`, + `${site.baseUrl}/.well-known/llms.txt`, + ...(site.basePath ? [] : [`${site.origin}/llms.txt`]), + ]), + ] + + for (const indexUrl of indexUrls) { + const result = await fetchSiteText( + indexUrl, + accessToken, + 'text/plain', + INDEX_MAX_BYTES, + retryOptions + ) + if (!result) continue + const pages = withinBasePath(parseLlmsTxt(result.body, site), site) + if (pages.length > 0) return pages + } + + return withinBasePath(await discoverFromSitemap(site, accessToken, retryOptions), site) +} + +/** Elements whose *text content* is markup/data, never prose. */ +const NON_CONTENT_ELEMENT_PATTERN = + /<(script|style|noscript|template|svg|head)\b[^>]*>[\s\S]*?<\/\1>/gi + +/** `
` / `
` body, the prose region of a rendered docs page. */ +const MAIN_REGION_PATTERN = /<(main|article)\b[^>]*>([\s\S]*)<\/\1>/i + +const HTML_COMMENT_PATTERN = //g + +/** + * Extracts prose from a full HTML document. + * + * The shared {@link htmlToPlainText} only removes tags, so a script's *contents* + * survive as text. Every other connector feeds it fragment HTML from an API + * field, where that is fine; this connector is the only one that hands it a whole + * server-rendered page. On a Next.js-rendered docs site that is catastrophic — + * `docs.sim.ai/introduction` yields 310KB of "text" of which 294KB is the RSC + * flight payload and site-wide navigation JSON, which both swamps the real page + * content in retrieval and makes every page's extraction near-identical. + * + * So non-content elements are dropped whole, and the `
`/`
` region + * is preferred over the full document to shed chrome (nav, sidebar, footer). + */ +function htmlPageToPlainText(html: string): string { + const stripped = html.replace(HTML_COMMENT_PATTERN, ' ').replace(NON_CONTENT_ELEMENT_PATTERN, ' ') + + const main = MAIN_REGION_PATTERN.exec(stripped) + const region = main ? main[2] : stripped + const text = htmlToPlainText(region) + + /** + * A site that renders its content entirely on the client leaves an empty + * `
`; fall back to the whole document rather than reporting the page as + * empty (which `getDocument` would turn into a dropped document). + */ + return text || htmlToPlainText(stripped) +} + +/** + * Builds the listing stub for a page. + * + * A Mintlify page exposes no version, ETag, or trustworthy `Last-Modified` + * (the hosted `.md` route reports the current time), so no metadata-derived + * change indicator exists. The stub therefore carries a path-only hash that can + * never equal a stored hash, which makes the sync engine re-hydrate every page; + * `getDocument` then returns a content-derived hash and the engine skips the + * write when it matches the stored one. Same trade-off as the Obsidian connector. + */ +function pageToStub(page: MintlifyPageLink, site: MintlifySite): ExternalDocument { + return { + externalId: page.path, + title: page.title, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: `${site.origin}${page.path}`, + contentHash: `mintlify:${page.path}`, + metadata: { + path: page.path, + section: page.section, + description: page.description, + }, + } +} + +export const mintlifyConnector: ConnectorConfig = { + ...mintlifyConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const site = resolveSite(sourceConfig.siteUrl as string) + const pathPrefix = resolvePathPrefix(sourceConfig.pathPrefix) + const maxPages = resolveMaxPages(sourceConfig.maxPages) + + let pages = syncContext?.pages as MintlifyPageLink[] | undefined + if (!pages) { + const discovered = await discoverPages(site, accessToken) + /** + * An empty discovery means the site's index files were unreachable or + * unparseable, not that the docs are empty — `validateConfig` refuses a + * site with no index at setup time. Returning an empty listing would let + * the sync engine reconcile every stored page away, so this fails the sync + * instead: `shouldReconcileDeletions` never runs on a thrown sync. + */ + if (discovered.length === 0) { + throw new Error( + `No pages found at ${site.baseUrl} — the site's llms.txt and sitemap.xml are unavailable` + ) + } + + const filtered = pathPrefix + ? discovered.filter((page) => isUnderPath(page.path, pathPrefix)) + : discovered + + if (filtered.length > maxPages && syncContext) { + /** + * The listing is truncated while more pages exist, so deletion + * reconciliation must be suppressed — otherwise every page past the cap + * would be hard-deleted from the knowledge base. + */ + syncContext.listingCapped = true + logger.info('Mintlify page listing capped', { + discovered: filtered.length, + maxPages, + }) + } + + pages = filtered.slice(0, maxPages) + if (syncContext) { + syncContext.pages = pages + } + } + + const offset = cursor ? Number(cursor) : 0 + const pageSlice = pages.slice(offset, offset + DOCS_PER_PAGE) + const nextOffset = offset + pageSlice.length + const hasMore = nextOffset < pages.length + + return { + documents: pageSlice.map((page) => pageToStub(page, site)), + nextCursor: hasMore ? String(nextOffset) : undefined, + hasMore, + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string, + syncContext?: Record + ): Promise => { + const site = resolveSite(sourceConfig.siteUrl as string) + const path = toPagePath(externalId, site) + if (!path) { + logger.warn('Skipping Mintlify page outside the configured site', { externalId }) + return null + } + + const pages = syncContext?.pages as MintlifyPageLink[] | undefined + const listed = pages?.find((page) => page.path === path) + + try { + /** + * Mintlify serves every page as raw Markdown at `{page}.md`. A site that + * does not (a non-Mintlify host that merely publishes an `llms.txt`, or a + * page removed from the Markdown route) answers 404 there, so the rendered + * HTML page is the fallback and gets stripped to text. + */ + const result = + (await fetchSiteText( + `${site.origin}${path === '/' ? '/index' : path}.md`, + accessToken, + 'text/markdown', + PAGE_MAX_BYTES + )) ?? + (await fetchSiteText(`${site.origin}${path}`, accessToken, 'text/html', PAGE_MAX_BYTES)) + if (!result) return null + + /** + * The `.md` route serves Markdown, which is already plain text. An HTML + * body — from the fallback above, or from a rewrite that ignores the + * extension — is stripped so raw markup is never indexed. + */ + const isHtml = + result.contentType.includes('html') || /^\s*<(!doctype\s+html|html\b)/i.test(result.body) + const content = isHtml ? htmlPageToPlainText(result.body) : result.body.trim() + if (!content) return null + + const stub = pageToStub(listed ?? { path, title: titleFromPath(path) }, site) + return { + ...stub, + content, + contentDeferred: false, + contentHash: `mintlify:${path}:${await computeContentHash(content)}`, + } + } catch (error) { + logger.warn('Failed to fetch Mintlify page', { + path, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + let site: MintlifySite + try { + site = resolveSite(sourceConfig.siteUrl as string) + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Invalid documentation site URL') } + } + + const rawMaxPages = sourceConfig.maxPages + if (rawMaxPages !== undefined && rawMaxPages !== null && rawMaxPages !== '') { + const parsed = Number(rawMaxPages) + if (!Number.isFinite(parsed) || parsed <= 0) { + return { valid: false, error: 'Max Pages must be a positive number' } + } + } + + try { + const pages = await discoverPages(site, accessToken, VALIDATE_RETRY_OPTIONS) + if (pages.length === 0) { + return { + valid: false, + error: `No pages found at ${site.baseUrl}. The site must publish an llms.txt or sitemap.xml index.`, + } + } + + const pathPrefix = resolvePathPrefix(sourceConfig.pathPrefix) + if (pathPrefix && !pages.some((page) => isUnderPath(page.path, pathPrefix))) { + return { valid: false, error: `No pages match the path prefix "${pathPrefix}"` } + } + + return { valid: true } + } catch (error) { + return { + valid: false, + error: getErrorMessage(error, 'Failed to reach the Mintlify documentation site'), + } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.section === 'string' && metadata.section.trim()) { + result.section = metadata.section.trim() + } + + if (typeof metadata.description === 'string' && metadata.description.trim()) { + result.description = metadata.description.trim() + } + + return result + }, +} diff --git a/apps/sim/connectors/pagerduty/index.ts b/apps/sim/connectors/pagerduty/index.ts new file mode 100644 index 00000000000..3de263ab54e --- /dev/null +++ b/apps/sim/connectors/pagerduty/index.ts @@ -0,0 +1 @@ +export { pagerdutyConnector } from '@/connectors/pagerduty/pagerduty' diff --git a/apps/sim/connectors/pagerduty/meta.ts b/apps/sim/connectors/pagerduty/meta.ts new file mode 100644 index 00000000000..78312e5d37e --- /dev/null +++ b/apps/sim/connectors/pagerduty/meta.ts @@ -0,0 +1,95 @@ +import { PagerDutyIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const pagerdutyConnectorMeta: ConnectorMeta = { + id: 'pagerduty', + name: 'PagerDuty', + description: 'Sync incidents, notes, and response timelines from PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + + auth: { + mode: 'apiKey', + label: 'REST API Key', + placeholder: 'Enter your PagerDuty REST API key', + }, + + /** + * Deliberately absent. PagerDuty's `since`/`until` filter incident *creation* + * time, and the REST API exposes no modified-since filter, so an incremental + * listing would never surface a status change, a new note, or a resolution on + * an incident created before the window — an incident synced while triggered + * would stay triggered forever. Every sync therefore lists the full history, + * gated by `contentHash` so unchanged incidents are never re-hydrated. + */ + + configFields: [ + { + id: 'statuses', + title: 'Status', + type: 'dropdown', + required: false, + options: [ + { label: 'All (default)', id: '' }, + { label: 'Triggered', id: 'triggered' }, + { label: 'Acknowledged', id: 'acknowledged' }, + { label: 'Resolved', id: 'resolved' }, + ], + description: 'Only sync incidents in this status. Leave on All to sync every status.', + }, + { + id: 'urgency', + title: 'Urgency', + type: 'dropdown', + required: false, + mode: 'advanced', + options: [ + { label: 'All (default)', id: '' }, + { label: 'High', id: 'high' }, + { label: 'Low', id: 'low' }, + ], + description: 'Only sync incidents at this urgency. Requires the urgencies ability.', + }, + { + id: 'serviceIds', + title: 'Filter by Services', + type: 'short-input', + required: false, + mode: 'advanced', + multi: true, + placeholder: 'Service IDs (comma-separated, default: all)', + description: 'Only sync incidents on these PagerDuty service IDs (e.g. PIJ90N7).', + }, + { + id: 'teamIds', + title: 'Filter by Teams', + type: 'short-input', + required: false, + mode: 'advanced', + multi: true, + placeholder: 'Team IDs (comma-separated, default: all)', + description: + 'Only sync incidents owned by these PagerDuty team IDs. Requires the teams ability.', + }, + { + id: 'maxIncidents', + title: 'Max Incidents', + type: 'short-input', + required: false, + placeholder: 'e.g. 200 (default: unlimited)', + description: 'Cap the number of incidents synced. Leave empty to sync all incidents.', + }, + ], + + tagDefinitions: [ + { id: 'status', displayName: 'Status', fieldType: 'text' }, + { id: 'urgency', displayName: 'Urgency', fieldType: 'text' }, + { id: 'priority', displayName: 'Priority', fieldType: 'text' }, + { id: 'service', displayName: 'Service', fieldType: 'text' }, + { id: 'teams', displayName: 'Teams', fieldType: 'text' }, + { id: 'incidentType', displayName: 'Incident Type', fieldType: 'text' }, + { id: 'incidentDate', displayName: 'Incident Date', fieldType: 'date' }, + { id: 'resolvedDate', displayName: 'Resolved Date', fieldType: 'date' }, + { id: 'incidentNumber', displayName: 'Incident Number', fieldType: 'number' }, + ], +} diff --git a/apps/sim/connectors/pagerduty/pagerduty.ts b/apps/sim/connectors/pagerduty/pagerduty.ts new file mode 100644 index 00000000000..dab4a6e7034 --- /dev/null +++ b/apps/sim/connectors/pagerduty/pagerduty.ts @@ -0,0 +1,711 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { pagerdutyConnectorMeta } from '@/connectors/pagerduty/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' + +const logger = createLogger('PagerDutyConnector') + +const PAGERDUTY_API_BASE = 'https://api.pagerduty.com' +/** PagerDuty caps `limit` at 100 on every classic-pagination listing. */ +const PAGE_SIZE = 100 +/** Cap on log entries appended to a document so a noisy incident stays bounded. */ +const MAX_LOG_ENTRIES = 200 +/** + * Hard ceiling on classic (offset) pagination in PagerDuty's REST API v2: a + * request whose `offset + limit` exceeds this value is answered with + * `400 Invalid Request` rather than served. The listing therefore stops while the + * *next* request would still fit, and the caller marks the sync as capped instead + * of letting deletion reconciliation purge the unseen tail. + */ +const MAX_LISTING_WINDOW = 10000 + +const VALID_STATUSES = new Set(['triggered', 'acknowledged', 'resolved']) +const VALID_URGENCIES = new Set(['high', 'low']) + +/** PagerDuty's common reference envelope, present on service/team/priority/agent fields. */ +interface PagerDutyReference { + id?: string + type?: string + summary?: string + html_url?: string +} + +/** Additional incident body, returned only when `include[]=body` is requested. */ +interface PagerDutyIncidentBody { + type?: string + /** + * Documented as an object in PagerDuty's OpenAPI schema, but the Incident + * Creation API only accepts string bodies, so both forms occur in practice. + */ + details?: unknown +} + +interface PagerDutyIncident { + id?: string + incident_number?: number + title?: string + status?: string + urgency?: string + incident_key?: string + created_at?: string + updated_at?: string + last_status_change_at?: string + resolved_at?: string + html_url?: string + service?: PagerDutyReference + escalation_policy?: PagerDutyReference + teams?: PagerDutyReference[] + priority?: PagerDutyReference + assignments?: Array<{ assignee?: PagerDutyReference }> + incident_type?: { name?: string } + resolve_reason?: { type?: string; incident?: PagerDutyReference } + body?: PagerDutyIncidentBody +} + +interface PagerDutyIncidentsListResponse { + incidents?: PagerDutyIncident[] + limit?: number + offset?: number + more?: boolean +} + +interface PagerDutyIncidentShowResponse { + incident?: PagerDutyIncident +} + +interface PagerDutyNote { + id?: string + content?: string + created_at?: string + updated_at?: string + user?: PagerDutyReference +} + +interface PagerDutyNotesResponse { + notes?: PagerDutyNote[] +} + +interface PagerDutyLogEntry { + id?: string + type?: string + summary?: string + created_at?: string + agent?: PagerDutyReference + note?: string +} + +interface PagerDutyLogEntriesResponse { + log_entries?: PagerDutyLogEntry[] + more?: boolean +} + +/** + * Metadata persisted on every incident document. Produced by one function so the + * deferred list stub and the hydrated document carry identical tag values. + */ +interface IncidentMetadata { + status?: string + urgency?: string + priority?: string + service?: string + teams?: string[] + incidentType?: string + incidentDate?: string + resolvedDate?: string + incidentNumber?: number +} + +/** + * Builds PagerDuty's REST headers. The REST API authenticates with a + * `Token token=` scheme rather than Bearer, and pins the v2 schema through + * the versioned Accept header. + */ +function buildHeaders(accessToken: string): Record { + return { + Authorization: `Token token=${accessToken}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + } +} + +/** + * Metadata-based content hash keyed on PagerDuty's own change indicator. + * + * `updated_at` is bumped whenever the incident is modified, so the hash is stable + * between the list stub and `getDocument`. Notes and log entries are child + * resources: PagerDuty does not guarantee they bump the parent's `updated_at`, so + * a full resync is the way to pick up note-only changes. + */ +function buildContentHash(incident: PagerDutyIncident): string { + return `pagerduty:${incident.id}:${incident.updated_at ?? ''}` +} + +function buildTitle(incident: PagerDutyIncident): string { + const title = incident.title?.trim() + const number = incident.incident_number + if (title && number != null) return `#${number}: ${title}` + return title || (number != null ? `Incident #${number}` : `Incident ${incident.id ?? ''}`.trim()) +} + +/** Extracts the human-readable summaries from a reference array. */ +function referenceLabels(references: PagerDutyReference[] | undefined): string[] | undefined { + if (!Array.isArray(references)) return undefined + const labels: string[] = [] + for (const reference of references) { + const label = reference.summary?.trim() + if (label) labels.push(label) + } + return labels.length > 0 ? labels : undefined +} + +function buildMetadata(incident: PagerDutyIncident): IncidentMetadata { + return { + status: incident.status ?? undefined, + urgency: incident.urgency ?? undefined, + priority: incident.priority?.summary ?? undefined, + service: incident.service?.summary ?? undefined, + teams: referenceLabels(incident.teams), + incidentType: incident.incident_type?.name ?? undefined, + incidentDate: incident.created_at ?? undefined, + resolvedDate: incident.resolved_at ?? undefined, + incidentNumber: + typeof incident.incident_number === 'number' ? incident.incident_number : undefined, + } +} + +/** + * Renders the incident body details, which arrive either as a plain/HTML string + * (Incident Creation API) or as a structured object (Events API payloads). + */ +function renderBodyDetails(details: unknown): string | undefined { + if (typeof details === 'string') { + const text = htmlToPlainText(details) + return text.trim() || undefined + } + if (details && typeof details === 'object') { + const lines: string[] = [] + for (const [key, value] of Object.entries(details as Record)) { + if (value == null || typeof value === 'object') continue + lines.push(`${key}: ${String(value)}`) + } + return lines.length > 0 ? lines.join('\n') : undefined + } + return undefined +} + +function incidentToStub(incident: PagerDutyIncident): ExternalDocument | null { + if (!incident.id) return null + return { + externalId: incident.id, + title: buildTitle(incident), + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: incident.html_url || undefined, + contentHash: buildContentHash(incident), + metadata: { ...buildMetadata(incident) }, + } +} + +/** + * Fetches every note on an incident. `GET /incidents/{id}/notes` takes no + * pagination parameters and returns the full set in one response. + */ +async function fetchNotes(accessToken: string, incidentId: string): Promise { + try { + const response = await fetchWithRetry( + `${PAGERDUTY_API_BASE}/incidents/${encodeURIComponent(incidentId)}/notes`, + { method: 'GET', headers: buildHeaders(accessToken) } + ) + + if (!response.ok) { + logger.warn('Failed to fetch PagerDuty incident notes', { + incidentId, + status: response.status, + }) + return [] + } + + const data = (await response.json()) as PagerDutyNotesResponse + return data.notes ?? [] + } catch (error) { + logger.warn('Error fetching PagerDuty incident notes', { + incidentId, + error: toError(error).message, + }) + return [] + } +} + +/** + * Fetches the incident's response timeline via classic offset pagination. + * + * `is_overview=true` narrows the log to the significant lifecycle changes + * (trigger, acknowledge, escalate, resolve, annotate) instead of every + * notification delivery, which keeps the indexed timeline readable. + * + * `since`/`until` are bounded to the incident's own lifetime. PagerDuty documents + * a one-month default range on `GET /incidents` but says nothing either way about + * `GET /incidents/{id}/log_entries`, and an inherited default would silently + * return an empty timeline for every incident older than the window. Every log + * entry necessarily falls inside `[created_at, now]`, so pinning that range can + * only remove ambiguity — and if PagerDuty rejects the range (an undocumented + * maximum span), the walk restarts unbounded rather than losing the timeline. + */ +async function fetchLogEntries( + accessToken: string, + incident: PagerDutyIncident +): Promise { + const incidentId = incident.id as string + const entries: PagerDutyLogEntry[] = [] + let offset = 0 + let bounded = Boolean(incident.created_at) + let truncated = false + + try { + while (entries.length < MAX_LOG_ENTRIES) { + const url = new URL( + `${PAGERDUTY_API_BASE}/incidents/${encodeURIComponent(incidentId)}/log_entries` + ) + url.searchParams.set('limit', String(PAGE_SIZE)) + url.searchParams.set('offset', String(offset)) + url.searchParams.set('is_overview', 'true') + if (bounded && incident.created_at) { + url.searchParams.set('since', incident.created_at) + url.searchParams.set('until', new Date().toISOString()) + } + + const response = await fetchWithRetry(url.toString(), { + method: 'GET', + headers: buildHeaders(accessToken), + }) + + if (!response.ok) { + if (bounded && offset === 0 && response.status === 400) { + logger.warn('PagerDuty rejected the log entry date range; retrying unbounded', { + incidentId, + }) + bounded = false + continue + } + logger.warn('Failed to fetch PagerDuty incident log entries', { + incidentId, + status: response.status, + }) + break + } + + const data = (await response.json()) as PagerDutyLogEntriesResponse + const page = data.log_entries ?? [] + entries.push(...page) + + if (!data.more || page.length === 0) break + if (entries.length >= MAX_LOG_ENTRIES) { + truncated = true + break + } + offset += page.length + } + } catch (error) { + logger.warn('Error fetching PagerDuty incident log entries', { + incidentId, + error: toError(error).message, + }) + } + + if (truncated || entries.length > MAX_LOG_ENTRIES) { + logger.warn('Truncated PagerDuty incident timeline at the per-document cap', { + incidentId, + cap: MAX_LOG_ENTRIES, + }) + } + + return entries.slice(0, MAX_LOG_ENTRIES) +} + +/** + * Formats an incident, its notes, and its timeline into a single plain-text + * document. Sections without data are omitted so open incidents do not carry + * empty resolution headers. + */ +function formatIncidentContent( + incident: PagerDutyIncident, + notes: PagerDutyNote[], + logEntries: PagerDutyLogEntry[] +): string { + const parts: string[] = [] + + parts.push(`Incident: ${buildTitle(incident)}`) + if (incident.status) parts.push(`Status: ${incident.status}`) + if (incident.urgency) parts.push(`Urgency: ${incident.urgency}`) + if (incident.priority?.summary) parts.push(`Priority: ${incident.priority.summary}`) + if (incident.service?.summary) parts.push(`Service: ${incident.service.summary}`) + if (incident.incident_type?.name) parts.push(`Type: ${incident.incident_type.name}`) + + const teams = referenceLabels(incident.teams) + if (teams) parts.push(`Teams: ${teams.join(', ')}`) + + const assignees = referenceLabels( + incident.assignments?.map((assignment) => assignment.assignee ?? {}) + ) + if (assignees) parts.push(`Assigned to: ${assignees.join(', ')}`) + + if (incident.escalation_policy?.summary) { + parts.push(`Escalation Policy: ${incident.escalation_policy.summary}`) + } + if (incident.created_at) parts.push(`Triggered: ${incident.created_at}`) + if (incident.resolved_at) parts.push(`Resolved: ${incident.resolved_at}`) + if (incident.resolve_reason?.type) parts.push(`Resolve Reason: ${incident.resolve_reason.type}`) + if (incident.incident_key) parts.push(`Incident Key: ${incident.incident_key}`) + + const details = renderBodyDetails(incident.body?.details) + if (details) { + parts.push('') + parts.push('--- Details ---') + parts.push(details) + } + + const noteLines = notes + .map((note) => { + const content = note.content?.trim() + if (!content) return undefined + const author = note.user?.summary?.trim() + const prefix = [note.created_at ? `[${note.created_at}]` : '', author ?? ''] + .filter(Boolean) + .join(' ') + return prefix ? `${prefix}: ${content}` : content + }) + .filter((line): line is string => Boolean(line)) + if (noteLines.length > 0) { + parts.push('') + parts.push('--- Notes ---') + parts.push(...noteLines) + } + + const timelineLines = logEntries + .map((entry) => { + const summary = entry.summary?.trim() + const note = entry.note?.trim() + const text = [summary, note].filter(Boolean).join(' — ') + if (!text) return undefined + const agent = entry.agent?.summary?.trim() + const prefix = [entry.created_at ? `[${entry.created_at}]` : '', agent ?? ''] + .filter(Boolean) + .join(' ') + return prefix ? `${prefix}: ${text}` : text + }) + .filter((line): line is string => Boolean(line)) + if (timelineLines.length > 0) { + parts.push('') + parts.push('--- Timeline ---') + parts.push(...timelineLines) + } + + return parts.join('\n').trim() +} + +/** + * Reads the optional `maxIncidents` cap, returning 0 (unlimited) when unset or + * not a positive number. + */ +function parseMaxIncidents(sourceConfig: Record): number { + const raw = sourceConfig.maxIncidents + if (raw == null || raw === '') return 0 + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 +} + +function readStringConfig(sourceConfig: Record, key: string): string { + const value = sourceConfig[key] + return typeof value === 'string' ? value.trim() : '' +} + +/** + * Fetches a single incident, asking for the extra sections the document renders. + * + * PagerDuty's OpenAPI schema documents `Incident.body` as "only returned if the + * `include[]=body` query parameter is provided", yet omits `body` from the same + * spec's `include[]` enum. The value is therefore requested on the strength of + * the field docs, and a rejected request is retried once without it rather than + * failing the document. Returns `null` when the incident no longer exists. + */ +async function fetchIncident( + accessToken: string, + incidentId: string +): Promise { + const base = `${PAGERDUTY_API_BASE}/incidents/${encodeURIComponent(incidentId)}` + const includes = ['teams', 'priorities', 'services'] + + for (const withBody of [true, false]) { + const url = new URL(base) + for (const include of includes) url.searchParams.append('include[]', include) + if (withBody) url.searchParams.append('include[]', 'body') + + const response = await fetchWithRetry(url.toString(), { + method: 'GET', + headers: buildHeaders(accessToken), + }) + + if (response.ok) { + const data = (await response.json()) as PagerDutyIncidentShowResponse + return data.incident ?? null + } + + if (response.status === 404 || response.status === 410) return null + if (withBody && response.status === 400) { + logger.warn('PagerDuty rejected the body include; retrying without it', { incidentId }) + continue + } + + throw new Error(`Failed to fetch PagerDuty incident: ${response.status}`) + } + + return null +} + +export const pagerdutyConnector: ConnectorConfig = { + ...pagerdutyConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const maxIncidents = parseMaxIncidents(sourceConfig) + const status = readStringConfig(sourceConfig, 'statuses') + const urgency = readStringConfig(sourceConfig, 'urgency') + const serviceIds = parseMultiValue(sourceConfig.serviceIds) + const teamIds = parseMultiValue(sourceConfig.teamIds) + + const parsedCursor = cursor ? Number(cursor) : 0 + const offset = Number.isFinite(parsedCursor) && parsedCursor > 0 ? Math.floor(parsedCursor) : 0 + + const url = new URL(`${PAGERDUTY_API_BASE}/incidents`) + url.searchParams.set('limit', String(PAGE_SIZE)) + url.searchParams.set('offset', String(offset)) + url.searchParams.append('sort_by', 'created_at:asc') + if (status) url.searchParams.append('statuses[]', status) + if (urgency) url.searchParams.append('urgencies[]', urgency) + for (const serviceId of serviceIds) url.searchParams.append('service_ids[]', serviceId) + for (const teamId of teamIds) url.searchParams.append('team_ids[]', teamId) + + /** + * Without an explicit range PagerDuty defaults `since`/`until` to the last + * month, which would silently truncate the listing and make deletion + * reconciliation purge every older document. `date_range=all` disables that + * default and returns the complete history. + */ + url.searchParams.set('date_range', 'all') + + logger.info('Listing PagerDuty incidents', { offset, maxIncidents }) + + const response = await fetchWithRetry(url.toString(), { + method: 'GET', + headers: buildHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error('Failed to list PagerDuty incidents', { + status: response.status, + error: errorText.slice(0, 500), + }) + throw new Error(`Failed to list PagerDuty incidents: ${response.status}`) + } + + const data = (await response.json()) as PagerDutyIncidentsListResponse + const incidents = data.incidents ?? [] + + const allDocuments: ExternalDocument[] = [] + let skipped = 0 + for (const incident of incidents) { + const stub = incidentToStub(incident) + if (stub) { + allDocuments.push(stub) + } else { + skipped += 1 + } + } + + /** + * An incident dropped for a missing ID is still present in PagerDuty, so the + * listing is incomplete and must not drive deletion reconciliation. + */ + if (skipped > 0 && syncContext) { + logger.warn('Skipped PagerDuty incidents without an ID', { skipped }) + syncContext.listingCapped = true + } + + const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0 + let documents = allDocuments + if (maxIncidents > 0) { + const remaining = Math.max(0, maxIncidents - prevFetched) + if (allDocuments.length > remaining) { + documents = allDocuments.slice(0, remaining) + } + } + + const totalFetched = prevFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxIncidents > 0 && totalFetched >= maxIncidents + const sourceHasMore = Boolean(data.more) + /** + * The cap only truncates the listing when it actually withheld something: a + * `maxIncidents` that happens to equal the source's exact incident count + * still yields a complete listing, and marking it capped would block deletion + * reconciliation forever. + */ + if (hitLimit && (sourceHasMore || documents.length < allDocuments.length) && syncContext) { + syncContext.listingCapped = true + } + + const nextOffset = offset + incidents.length + /** + * Measured against the request that *would* come next: PagerDuty rejects + * `offset + limit > 10000`, so a short page (which leaves `nextOffset` off a + * clean page boundary) must still stop before the sum overruns. + */ + const hitOffsetCeiling = nextOffset + PAGE_SIZE > MAX_LISTING_WINDOW + /** + * PagerDuty reported more results but served none, so the walk cannot + * advance — `offset` only moves by what the page returned. Treat it as a + * truncated listing rather than source exhaustion. + */ + const stalledPage = sourceHasMore && incidents.length === 0 + const hasMore = !hitLimit && !hitOffsetCeiling && sourceHasMore && incidents.length > 0 + + if (!hitLimit && hitOffsetCeiling && sourceHasMore && syncContext) { + logger.warn('Stopping PagerDuty listing at the pagination window ceiling', { + offset: nextOffset, + }) + syncContext.listingCapped = true + } + + if (!hitLimit && stalledPage && syncContext) { + logger.warn('PagerDuty reported more incidents but returned an empty page', { offset }) + syncContext.listingCapped = true + } + + return { + documents, + nextCursor: hasMore ? String(nextOffset) : undefined, + hasMore, + } + }, + + getDocument: async ( + accessToken: string, + _sourceConfig: Record, + externalId: string + ): Promise => { + try { + if (!externalId) return null + + const incident = await fetchIncident(accessToken, externalId) + if (!incident?.id) return null + + const [notes, logEntries] = await Promise.all([ + fetchNotes(accessToken, incident.id), + fetchLogEntries(accessToken, incident), + ]) + + const content = formatIncidentContent(incident, notes, logEntries) + if (!content.trim()) { + logger.info('Skipping PagerDuty incident with no indexable content', { externalId }) + return null + } + + return { + externalId: incident.id, + title: buildTitle(incident), + content, + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: incident.html_url || undefined, + contentHash: buildContentHash(incident), + metadata: { ...buildMetadata(incident) }, + } + } catch (error) { + logger.warn('Failed to get PagerDuty incident', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const maxIncidents = sourceConfig.maxIncidents as string | undefined + if (maxIncidents && (Number.isNaN(Number(maxIncidents)) || Number(maxIncidents) < 0)) { + return { valid: false, error: 'Max incidents must be a non-negative number' } + } + + const status = readStringConfig(sourceConfig, 'statuses') + if (status && !VALID_STATUSES.has(status)) { + return { + valid: false, + error: 'Status must be one of triggered, acknowledged, or resolved', + } + } + + const urgency = readStringConfig(sourceConfig, 'urgency') + if (urgency && !VALID_URGENCIES.has(urgency)) { + return { valid: false, error: 'Urgency must be either high or low' } + } + + try { + const response = await fetchWithRetry( + `${PAGERDUTY_API_BASE}/incidents?limit=1&date_range=all`, + { + method: 'GET', + headers: buildHeaders(accessToken), + }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + return { + valid: false, + error: `PagerDuty access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, + } + } + + return { valid: true } + } catch (error) { + const message = getErrorMessage(error, 'Failed to validate configuration') + return { valid: false, error: message } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + for (const key of ['status', 'urgency', 'priority', 'service', 'incidentType'] as const) { + const value = metadata[key] + if (typeof value === 'string' && value.trim()) result[key] = value + } + + const teams = joinTagArray(metadata.teams) + if (teams) result.teams = teams + + const incidentDate = parseTagDate(metadata.incidentDate) + if (incidentDate) result.incidentDate = incidentDate + + const resolvedDate = parseTagDate(metadata.resolvedDate) + if (resolvedDate) result.resolvedDate = resolvedDate + + if (metadata.incidentNumber != null) { + const incidentNumber = Number(metadata.incidentNumber) + if (!Number.isNaN(incidentNumber)) result.incidentNumber = incidentNumber + } + + return result + }, +} diff --git a/apps/sim/connectors/registry.server.ts b/apps/sim/connectors/registry.server.ts index ba870e2af41..229d9b5331f 100644 --- a/apps/sim/connectors/registry.server.ts +++ b/apps/sim/connectors/registry.server.ts @@ -2,6 +2,7 @@ import { airtableConnector } from '@/connectors/airtable' import { asanaConnector } from '@/connectors/asana' import { ashbyConnector } from '@/connectors/ashby' import { azureDevopsConnector } from '@/connectors/azure-devops' +import { boxConnector } from '@/connectors/box' import { clickupConnector } from '@/connectors/clickup' import { confluenceConnector } from '@/connectors/confluence' import { discordConnector } from '@/connectors/discord' @@ -20,6 +21,8 @@ import { googleDriveConnector } from '@/connectors/google-drive' import { googleFormsConnector } from '@/connectors/google-forms' import { googleMeetConnector } from '@/connectors/google-meet' import { googleSheetsConnector } from '@/connectors/google-sheets' +import { googleSlidesConnector } from '@/connectors/google-slides' +import { googleVaultConnector } from '@/connectors/google-vault' import { grainConnector } from '@/connectors/grain' import { granolaConnector } from '@/connectors/granola' import { greenhouseConnector } from '@/connectors/greenhouse' @@ -29,20 +32,25 @@ import { intercomConnector } from '@/connectors/intercom' import { jiraConnector } from '@/connectors/jira' import { jsmConnector } from '@/connectors/jsm' import { linearConnector } from '@/connectors/linear' +import { microsoftExcelConnector } from '@/connectors/microsoft-excel' import { microsoftTeamsConnector } from '@/connectors/microsoft-teams' +import { mintlifyConnector } from '@/connectors/mintlify' import { mondayConnector } from '@/connectors/monday' import { notionConnector } from '@/connectors/notion' import { obsidianConnector } from '@/connectors/obsidian' import { onedriveConnector } from '@/connectors/onedrive' import { outlookConnector } from '@/connectors/outlook' +import { pagerdutyConnector } from '@/connectors/pagerduty' import { redditConnector } from '@/connectors/reddit' import { rootlyConnector } from '@/connectors/rootly' import { s3Connector } from '@/connectors/s3' import { salesforceConnector } from '@/connectors/salesforce' import { sentryConnector } from '@/connectors/sentry' import { servicenowConnector } from '@/connectors/servicenow' +import { sftpConnector } from '@/connectors/sftp' import { sharepointConnector } from '@/connectors/sharepoint' import { slackConnector } from '@/connectors/slack' +import { trelloConnector } from '@/connectors/trello' import { typeformConnector } from '@/connectors/typeform' import type { ConnectorRegistry } from '@/connectors/types' import { webflowConnector } from '@/connectors/webflow' @@ -50,6 +58,7 @@ import { wordpressConnector } from '@/connectors/wordpress' import { xConnector } from '@/connectors/x' import { youtubeConnector } from '@/connectors/youtube' import { zendeskConnector } from '@/connectors/zendesk' +import { zohoDeskConnector } from '@/connectors/zoho-desk' import { zoomConnector } from '@/connectors/zoom' /** @@ -64,6 +73,7 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { asana: asanaConnector, ashby: ashbyConnector, azure_devops: azureDevopsConnector, + box: boxConnector, clickup: clickupConnector, confluence: confluenceConnector, discord: discordConnector, @@ -82,6 +92,8 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { google_forms: googleFormsConnector, google_meet: googleMeetConnector, google_sheets: googleSheetsConnector, + google_slides: googleSlidesConnector, + google_vault: googleVaultConnector, grain: grainConnector, granola: granolaConnector, greenhouse: greenhouseConnector, @@ -91,25 +103,31 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { jira: jiraConnector, jsm: jsmConnector, linear: linearConnector, + microsoft_excel: microsoftExcelConnector, microsoft_teams: microsoftTeamsConnector, + mintlify: mintlifyConnector, monday: mondayConnector, notion: notionConnector, obsidian: obsidianConnector, onedrive: onedriveConnector, outlook: outlookConnector, + pagerduty: pagerdutyConnector, reddit: redditConnector, rootly: rootlyConnector, s3: s3Connector, salesforce: salesforceConnector, sentry: sentryConnector, servicenow: servicenowConnector, + sftp: sftpConnector, sharepoint: sharepointConnector, slack: slackConnector, + trello: trelloConnector, typeform: typeformConnector, webflow: webflowConnector, wordpress: wordpressConnector, x: xConnector, youtube: youtubeConnector, zendesk: zendeskConnector, + zoho_desk: zohoDeskConnector, zoom: zoomConnector, } diff --git a/apps/sim/connectors/registry.ts b/apps/sim/connectors/registry.ts index b1fd50e9736..8d46de4c98a 100644 --- a/apps/sim/connectors/registry.ts +++ b/apps/sim/connectors/registry.ts @@ -2,6 +2,7 @@ import { airtableConnectorMeta } from '@/connectors/airtable/meta' import { asanaConnectorMeta } from '@/connectors/asana/meta' import { ashbyConnectorMeta } from '@/connectors/ashby/meta' import { azureDevopsConnectorMeta } from '@/connectors/azure-devops/meta' +import { boxConnectorMeta } from '@/connectors/box/meta' import { clickupConnectorMeta } from '@/connectors/clickup/meta' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' import { discordConnectorMeta } from '@/connectors/discord/meta' @@ -20,6 +21,8 @@ import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' import { googleFormsConnectorMeta } from '@/connectors/google-forms/meta' import { googleMeetConnectorMeta } from '@/connectors/google-meet/meta' import { googleSheetsConnectorMeta } from '@/connectors/google-sheets/meta' +import { googleSlidesConnectorMeta } from '@/connectors/google-slides/meta' +import { googleVaultConnectorMeta } from '@/connectors/google-vault/meta' import { grainConnectorMeta } from '@/connectors/grain/meta' import { granolaConnectorMeta } from '@/connectors/granola/meta' import { greenhouseConnectorMeta } from '@/connectors/greenhouse/meta' @@ -29,20 +32,25 @@ import { intercomConnectorMeta } from '@/connectors/intercom/meta' import { jiraConnectorMeta } from '@/connectors/jira/meta' import { jsmConnectorMeta } from '@/connectors/jsm/meta' import { linearConnectorMeta } from '@/connectors/linear/meta' +import { microsoftExcelConnectorMeta } from '@/connectors/microsoft-excel/meta' import { microsoftTeamsConnectorMeta } from '@/connectors/microsoft-teams/meta' +import { mintlifyConnectorMeta } from '@/connectors/mintlify/meta' import { mondayConnectorMeta } from '@/connectors/monday/meta' import { notionConnectorMeta } from '@/connectors/notion/meta' import { obsidianConnectorMeta } from '@/connectors/obsidian/meta' import { onedriveConnectorMeta } from '@/connectors/onedrive/meta' import { outlookConnectorMeta } from '@/connectors/outlook/meta' +import { pagerdutyConnectorMeta } from '@/connectors/pagerduty/meta' import { redditConnectorMeta } from '@/connectors/reddit/meta' import { rootlyConnectorMeta } from '@/connectors/rootly/meta' import { s3ConnectorMeta } from '@/connectors/s3/meta' import { salesforceConnectorMeta } from '@/connectors/salesforce/meta' import { sentryConnectorMeta } from '@/connectors/sentry/meta' import { servicenowConnectorMeta } from '@/connectors/servicenow/meta' +import { sftpConnectorMeta } from '@/connectors/sftp/meta' import { sharepointConnectorMeta } from '@/connectors/sharepoint/meta' import { slackConnectorMeta } from '@/connectors/slack/meta' +import { trelloConnectorMeta } from '@/connectors/trello/meta' import { typeformConnectorMeta } from '@/connectors/typeform/meta' import type { ConnectorMeta, ConnectorMetaRegistry } from '@/connectors/types' import { webflowConnectorMeta } from '@/connectors/webflow/meta' @@ -50,6 +58,7 @@ import { wordpressConnectorMeta } from '@/connectors/wordpress/meta' import { xConnectorMeta } from '@/connectors/x/meta' import { youtubeConnectorMeta } from '@/connectors/youtube/meta' import { zendeskConnectorMeta } from '@/connectors/zendesk/meta' +import { zohoDeskConnectorMeta } from '@/connectors/zoho-desk/meta' import { zoomConnectorMeta } from '@/connectors/zoom/meta' /** @@ -64,6 +73,7 @@ export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { asana: asanaConnectorMeta, ashby: ashbyConnectorMeta, azure_devops: azureDevopsConnectorMeta, + box: boxConnectorMeta, clickup: clickupConnectorMeta, confluence: confluenceConnectorMeta, discord: discordConnectorMeta, @@ -82,6 +92,8 @@ export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { google_forms: googleFormsConnectorMeta, google_meet: googleMeetConnectorMeta, google_sheets: googleSheetsConnectorMeta, + google_slides: googleSlidesConnectorMeta, + google_vault: googleVaultConnectorMeta, grain: grainConnectorMeta, granola: granolaConnectorMeta, greenhouse: greenhouseConnectorMeta, @@ -91,26 +103,32 @@ export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { jira: jiraConnectorMeta, jsm: jsmConnectorMeta, linear: linearConnectorMeta, + microsoft_excel: microsoftExcelConnectorMeta, microsoft_teams: microsoftTeamsConnectorMeta, + mintlify: mintlifyConnectorMeta, monday: mondayConnectorMeta, notion: notionConnectorMeta, obsidian: obsidianConnectorMeta, onedrive: onedriveConnectorMeta, outlook: outlookConnectorMeta, + pagerduty: pagerdutyConnectorMeta, reddit: redditConnectorMeta, rootly: rootlyConnectorMeta, s3: s3ConnectorMeta, salesforce: salesforceConnectorMeta, sentry: sentryConnectorMeta, servicenow: servicenowConnectorMeta, + sftp: sftpConnectorMeta, sharepoint: sharepointConnectorMeta, slack: slackConnectorMeta, + trello: trelloConnectorMeta, typeform: typeformConnectorMeta, webflow: webflowConnectorMeta, wordpress: wordpressConnectorMeta, x: xConnectorMeta, youtube: youtubeConnectorMeta, zendesk: zendeskConnectorMeta, + zoho_desk: zohoDeskConnectorMeta, zoom: zoomConnectorMeta, } diff --git a/apps/sim/connectors/sftp/index.ts b/apps/sim/connectors/sftp/index.ts new file mode 100644 index 00000000000..aaf2a5c21fe --- /dev/null +++ b/apps/sim/connectors/sftp/index.ts @@ -0,0 +1 @@ +export { sftpConnector } from '@/connectors/sftp/sftp' diff --git a/apps/sim/connectors/sftp/meta.ts b/apps/sim/connectors/sftp/meta.ts new file mode 100644 index 00000000000..29ac8ada690 --- /dev/null +++ b/apps/sim/connectors/sftp/meta.ts @@ -0,0 +1,107 @@ +import { SftpIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const sftpConnectorMeta: ConnectorMeta = { + id: 'sftp', + name: 'SFTP', + description: + 'Sync text-based files from a remote SFTP (SSH File Transfer Protocol) directory tree into your knowledge base', + version: '1.0.0', + icon: SftpIcon, + + auth: { + mode: 'apiKey', + label: 'Password or Private Key', + placeholder: 'Password, or paste an unencrypted OpenSSH private key', + }, + + supportsIncrementalSync: true, + + configFields: [ + { + id: 'host', + title: 'Host', + type: 'short-input', + placeholder: 'e.g. sftp.example.com', + required: true, + description: + 'Hostname of the SFTP server. Private, loopback, and link-local addresses are rejected.', + }, + { + id: 'port', + title: 'Port', + type: 'short-input', + placeholder: '22', + required: false, + description: 'SSH port. Defaults to 22.', + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'e.g. sftp-user', + required: true, + }, + { + id: 'authMethod', + title: 'Authentication Method', + type: 'dropdown', + required: false, + options: [ + { label: 'Password', id: 'password' }, + { label: 'Private Key', id: 'privateKey' }, + ], + description: + 'How the secret above is interpreted. Private keys must be unencrypted (no passphrase).', + }, + { + id: 'hostFingerprint', + title: 'Host Key Fingerprint', + type: 'short-input', + placeholder: 'e.g. SHA256:abc123... (optional)', + required: false, + description: + 'Expected SHA-256 host key fingerprint. Get it with "ssh-keyscan -t rsa,ecdsa,ed25519 | ssh-keygen -lf -" and paste the SHA256:... value. If it does not match, the connection is refused before any credential is sent. Leave empty to skip host verification (the server is then trusted on sight).', + }, + { + id: 'rootPath', + title: 'Root Path', + type: 'short-input', + placeholder: 'e.g. /home/sftp-user/docs', + required: true, + description: 'Absolute remote directory to sync. Only files under this path are indexed.', + }, + { + id: 'extensions', + title: 'File Extensions', + type: 'short-input', + placeholder: 'e.g. txt, md, csv (optional)', + required: false, + description: + 'Comma-separated list of file extensions to sync. Leave blank to use the built-in text formats.', + }, + { + id: 'maxDepth', + title: 'Max Directory Depth', + type: 'short-input', + placeholder: 'e.g. 5 (default: 5, max: 10)', + required: false, + description: 'How many directory levels below the root path to walk.', + }, + { + id: 'maxFiles', + title: 'Max Files', + type: 'short-input', + placeholder: 'e.g. 2000 (default: 2000, max: 10000)', + required: false, + description: 'Stop syncing after this many files.', + }, + ], + + tagDefinitions: [ + { id: 'directory', displayName: 'Folder', fieldType: 'text' }, + { id: 'extension', displayName: 'Extension', fieldType: 'text' }, + { id: 'fileSize', displayName: 'Size (bytes)', fieldType: 'number' }, + { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' }, + ], +} diff --git a/apps/sim/connectors/sftp/sftp.ts b/apps/sim/connectors/sftp/sftp.ts new file mode 100644 index 00000000000..97d2297450a --- /dev/null +++ b/apps/sim/connectors/sftp/sftp.ts @@ -0,0 +1,709 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { Attributes, Client, SFTPWrapper } from 'ssh2' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + createSftpConnection, + getFileType, + getSftp, + isPathSafe, + readSftpFileCapped, + sanitizePath, +} from '@/app/api/tools/sftp/utils' +import { sftpConnectorMeta } from '@/connectors/sftp/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { + CONNECTOR_MAX_FILE_BYTES, + htmlToPlainText, + markSkipped, + parseTagDate, + sizeLimitSkipReason, + stubOrSkipBySize, +} from '@/connectors/utils' + +const logger = createLogger('SftpConnector') + +/** Maximum bytes read from a single remote file. Larger files are surfaced as skipped. */ +const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES + +/** Directory levels below the root path walked when `maxDepth` is not configured. */ +const DEFAULT_MAX_DEPTH = 5 + +/** Hard ceiling on directory recursion, regardless of the configured `maxDepth`. */ +const MAX_ALLOWED_DEPTH = 10 + +/** Files listed per sync when `maxFiles` is not configured. */ +const DEFAULT_MAX_FILES = 2000 + +/** Hard ceiling on files listed per sync, regardless of the configured `maxFiles`. */ +const MAX_ALLOWED_FILES = 10_000 + +/** Hard ceiling on `readdir` calls per sync, bounding wide (rather than deep) trees. */ +const MAX_DIRECTORIES = 1000 + +/** + * Hard ceiling on entries emitted by a single walk, counting oversized files. + * Oversized files deliberately do not consume the `maxFiles` budget, so without + * this second ceiling a tree of nothing but oversized files would grow the + * listing until the walk ran out of directories. + */ +const MAX_LISTED_ENTRIES = MAX_ALLOWED_FILES + +/** Seconds to wait for the SSH handshake before giving up. */ +const READY_TIMEOUT_MS = 20_000 + +/** + * Keepalive cadence. ssh2 tears the connection down after three unanswered + * keepalives, which is what turns a server that accepts the TCP connection and + * then goes silent into an error rather than an indefinite wait. + */ +const KEEPALIVE_INTERVAL_MS = 10_000 + +/** + * Wall-clock ceiling on a directory walk. `readyTimeout` bounds only the SSH + * handshake; every SFTP request after it is unbounded, so a server that answers + * the handshake and then trickles (or never answers) `readdir` would otherwise + * hold the sync task open until its own 30-minute deadline. + */ +const LISTING_TIMEOUT_MS = 10 * 60_000 + +/** Wall-clock ceiling on fetching a single document, for the same reason. */ +const DOCUMENT_TIMEOUT_MS = 2 * 60_000 + +/** + * Slack subtracted from the incremental cutoff. `mtime` comes from the remote + * server's clock: if it runs behind ours, a file written just after a sync gets + * an `mtime` below the cutoff and would be skipped by every later incremental + * sync, silently and permanently. Re-listing a few minutes of overlap is cheap + * because unchanged documents are hash-gated by the sync engine. + */ +const INCREMENTAL_CLOCK_SKEW_SECONDS = 300 + +/** Bytes inspected when sniffing a downloaded file for binary content. */ +const BINARY_SNIFF_BYTES = 8192 + +/** + * File extensions considered safely text-extractable. Anything else (or a file + * with no extension) is skipped, since its bytes cannot be reliably decoded to + * plain text. Users override this list via the `extensions` config field. + */ +const DEFAULT_EXTENSIONS = new Set([ + 'txt', + 'md', + 'markdown', + 'csv', + 'tsv', + 'json', + 'jsonl', + 'ndjson', + 'html', + 'htm', + 'xml', + 'yaml', + 'yml', + 'log', + 'rtf', +]) + +/** Extensions whose content is rendered markup and must be flattened before indexing. */ +const HTML_EXTENSIONS = new Set(['html', 'htm']) + +/** + * Minimal shape of an `SFTPWrapper.readdir` entry. Declared structurally rather + * than importing ssh2's `FileEntryWithStats` so the connector depends only on + * the fields it reads. + */ +interface SftpDirEntry { + filename: string + attrs: Attributes +} + +/** A remote file selected for syncing during the directory walk. */ +interface SftpFileEntry { + /** Absolute remote path, used as the document's externalId. */ + path: string + /** Absolute remote path of the containing directory. */ + directory: string + size: number + /** Modification time in epoch seconds, as reported by the server. */ + mtime: number +} + +/** Connection and scope parameters resolved from sourceConfig + the stored secret. */ +interface SftpContext { + host: string + port: number + username: string + password?: string + privateKey?: string + /** Optional pinned SHA-256 host key fingerprint; empty means no verification. */ + hostFingerprint?: string + rootPath: string + allowedExtensions: Set + maxDepth: number + maxFiles: number +} + +/** + * Parses the comma-separated `extensions` override into a normalized set + * (lowercased, no leading dot). Falls back to the built-in text formats. + */ +function resolveExtensions(raw: unknown): Set { + if (typeof raw !== 'string') return DEFAULT_EXTENSIONS + const exts = raw + .split(',') + .map((e) => e.trim().toLowerCase().replace(/^\./, '')) + .filter(Boolean) + return exts.length > 0 ? new Set(exts) : DEFAULT_EXTENSIONS +} + +/** + * Clamps a numeric config value into `[1, max]`, falling back to `fallback` + * when the value is absent or not a positive number. + */ +function resolveBoundedNumber(raw: unknown, fallback: number, max: number): number { + const parsed = typeof raw === 'number' ? raw : Number((raw as string) ?? '') + if (!Number.isFinite(parsed) || parsed <= 0) return fallback + return Math.min(Math.floor(parsed), max) +} + +/** + * Unpadded base64 of a SHA-256 digest — what OpenSSH prints after the `SHA256:` + * prefix (32 digest bytes encode to 43 base64 characters). + */ +const SHA256_FINGERPRINT_PATTERN = /^[A-Za-z0-9+/]{43}$/ + +/** + * Normalizes and validates the pinned host key fingerprint. Validation matters + * because host verification is opt-in: a value that normalizes to nothing (a + * bare `SHA256:`), or an MD5 fingerprint, would otherwise be dropped and the + * connection would silently fall back to trusting whatever host answers. + */ +function resolveHostFingerprint(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined + const trimmed = raw.trim() + if (!trimmed) return undefined + + const normalized = trimmed + .replace(/^sha256:/i, '') + .replace(/=+$/, '') + .trim() + if (!SHA256_FINGERPRINT_PATTERN.test(normalized)) { + throw new Error( + 'Host key fingerprint must be a SHA-256 fingerprint, e.g. "SHA256:<43 base64 characters>". ' + + 'Get it with "ssh-keyscan -t rsa,ecdsa,ed25519 | ssh-keygen -lf -".' + ) + } + return normalized +} + +/** Extracts the lowercased extension of a path segment, or '' when there is none. */ +function getExtension(filePath: string): string { + const name = filePath.split('/').pop() ?? '' + const dotIndex = name.lastIndexOf('.') + if (dotIndex <= 0 || dotIndex === name.length - 1) return '' + return name.slice(dotIndex + 1).toLowerCase() +} + +/** + * Normalizes a remote path to an absolute, separator-collapsed form without a + * trailing slash (the root `/` is preserved). + */ +function normalizeRemotePath(raw: string): string { + const sanitized = sanitizePath(raw) + const absolute = sanitized.startsWith('/') ? sanitized : `/${sanitized}` + const trimmed = absolute.replace(/\/+$/, '') + return trimmed === '' ? '/' : trimmed +} + +/** Joins a directory and a child name into an absolute remote path. */ +function joinRemotePath(directory: string, name: string): string { + return directory === '/' ? `/${name}` : `${directory}/${name}` +} + +/** True when `candidate` is the root path itself or lives beneath it. */ +function isWithinRoot(candidate: string, rootPath: string): boolean { + if (rootPath === '/') return true + return candidate === rootPath || candidate.startsWith(`${rootPath}/`) +} + +/** + * Resolves connection parameters from the connector's sourceConfig and the + * decrypted secret (delivered as `accessToken`). The secret is interpreted as a + * password or an OpenSSH private key depending on `authMethod`. + */ +function resolveContext(accessToken: string, sourceConfig: Record): SftpContext { + const host = ((sourceConfig.host as string) ?? '').trim() + const username = ((sourceConfig.username as string) ?? '').trim() + const rawRootPath = ((sourceConfig.rootPath as string) ?? '').trim() + const secret = (accessToken ?? '').trim() + const authMethod = ((sourceConfig.authMethod as string) ?? 'password').trim() + + if (!host) throw new Error('Missing SFTP host') + if (!username) throw new Error('Missing SFTP username') + if (!rawRootPath) throw new Error('Missing root path') + if (!secret) throw new Error('Missing SFTP password or private key') + if (!isPathSafe(rawRootPath)) { + throw new Error('Root path must not contain path traversal sequences') + } + + const port = resolveBoundedNumber(sourceConfig.port, 22, 65535) + const hostFingerprint = resolveHostFingerprint(sourceConfig.hostFingerprint) + + return { + host, + port, + username, + hostFingerprint, + password: authMethod === 'privateKey' ? undefined : secret, + privateKey: authMethod === 'privateKey' ? secret : undefined, + rootPath: normalizeRemotePath(rawRootPath), + allowedExtensions: resolveExtensions(sourceConfig.extensions), + maxDepth: resolveBoundedNumber(sourceConfig.maxDepth, DEFAULT_MAX_DEPTH, MAX_ALLOWED_DEPTH), + maxFiles: resolveBoundedNumber(sourceConfig.maxFiles, DEFAULT_MAX_FILES, MAX_ALLOWED_FILES), + } +} + +/** + * Opens an SSH/SFTP session, runs `fn` under a wall-clock deadline, and always + * tears the connection down — including on the error and timeout paths — so a + * failed sync never leaks a socket. + * + * Host validation (DNS resolution plus private/loopback/reserved-IP rejection, + * with the connection pinned to the resolved address) happens inside + * {@link createSftpConnection}, which is the SSH counterpart to the HTTP + * `secureFetchWithRetry` boundary used by the other file-storage connectors. + * When the source is configured with a host key fingerprint, that same helper + * also pins the server's host key before any credential is sent. + */ +async function withSftpSession( + ctx: SftpContext, + timeoutMs: number, + fn: (sftp: SFTPWrapper) => Promise +): Promise { + let client: Client | undefined + let timer: NodeJS.Timeout | undefined + try { + client = await createSftpConnection({ + host: ctx.host, + port: ctx.port, + username: ctx.username, + password: ctx.password, + privateKey: ctx.privateKey, + hostFingerprint: ctx.hostFingerprint, + readyTimeout: READY_TIMEOUT_MS, + keepaliveInterval: KEEPALIVE_INTERVAL_MS, + }) + const sftp = await getSftp(client) + const connection = client + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + /** + * `destroy`, not `end`: a graceful close half-closes the socket and then + * waits for a FIN the unresponsive server that caused this timeout is + * unlikely to send, leaving the descriptor open. + */ + connection.destroy() + reject(new Error(`SFTP session exceeded ${Math.round(timeoutMs / 1000)}s`)) + }, timeoutMs) + }) + /** + * `race` keeps a rejection handler attached to `fn`, so the requests the + * timeout cancels settle without surfacing as unhandled rejections. + */ + return await Promise.race([fn(sftp), deadline]) + } finally { + if (timer) clearTimeout(timer) + client?.end() + } +} + +/** Promise wrapper around `SFTPWrapper.readdir`. */ +function readRemoteDirectory(sftp: SFTPWrapper, directory: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(directory, (err, list) => { + if (err) reject(err) + else resolve(list) + }) + }) +} + +/** True for the SFTP status the server returns when a path no longer exists. */ +function isNotFoundError(error: unknown): boolean { + return /no such file|not found|ENOENT/i.test(getErrorMessage(error, '')) +} + +/** + * Promise wrapper around `SFTPWrapper.stat`/`lstat`, resolving null when the + * path is gone. + * + * `follow: false` issues `SSH_FXP_LSTAT`, which describes the link itself + * instead of its target. Document reads use it so a symlink planted (or swapped + * in) under the root cannot be resolved into a file outside it; the root-path + * check in `validateConfig` follows links deliberately, since a symlinked root + * directory is a legitimate configuration. + */ +function statRemotePath( + sftp: SFTPWrapper, + remotePath: string, + { follow }: { follow: boolean } +): Promise { + return new Promise((resolve, reject) => { + const stat = follow ? sftp.stat.bind(sftp) : sftp.lstat.bind(sftp) + stat(remotePath, (err, stats) => { + if (err) { + if (isNotFoundError(err)) resolve(null) + else reject(err) + } else { + resolve(stats) + } + }) + }) +} + +/** Outcome of a bounded directory walk. */ +interface WalkResult { + files: SftpFileEntry[] + /** + * True when the walk stopped short of the full tree — a cap was hit or a + * directory could not be read — meaning still-present files are missing from + * the listing and deletion reconciliation must be suppressed. + */ + truncated: boolean +} + +/** + * Walks the remote tree breadth-first from the root path, collecting files whose + * extension is indexable. Bounded on three axes — recursion depth, number of + * `readdir` calls, and number of indexable files — so a hostile or merely huge + * remote tree can never drive an unbounded traversal. + * + * Symlinks are never followed: they are the mechanism by which a remote tree can + * escape the configured root or cycle forever. `readdir` reports link entries + * with `lstat` semantics, so a symlink is classified as `symlink` here and falls + * through both the directory and the file branch. + * + * Oversized files still ride along as skipped stubs (they surface as failed rows + * in the knowledge base) and do not consume the file budget, so they are bounded + * separately by {@link MAX_LISTED_ENTRIES}. + */ +async function walkTree( + sftp: SFTPWrapper, + ctx: SftpContext, + lastSyncAt?: Date +): Promise { + const cutoffSeconds = lastSyncAt + ? Math.floor(lastSyncAt.getTime() / 1000) - INCREMENTAL_CLOCK_SKEW_SECONDS + : undefined + const files: SftpFileEntry[] = [] + const queue: Array<{ path: string; depth: number }> = [{ path: ctx.rootPath, depth: 0 }] + + let indexableCount = 0 + let directoriesRead = 0 + let truncated = false + + while (queue.length > 0) { + if (indexableCount >= ctx.maxFiles) { + truncated = true + break + } + if (directoriesRead >= MAX_DIRECTORIES) { + truncated = true + break + } + if (files.length >= MAX_LISTED_ENTRIES) { + truncated = true + break + } + + const current = queue.shift() + if (!current) break + + let entries: SftpDirEntry[] + try { + entries = await readRemoteDirectory(sftp, current.path) + directoriesRead += 1 + } catch (error) { + /** + * A directory that cannot be read may still hold live documents, so the + * listing is incomplete and must not trigger deletion reconciliation. + */ + logger.warn('Failed to read SFTP directory', { + directory: current.path, + error: toError(error).message, + }) + truncated = true + continue + } + + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') continue + + /** + * Directory entries come from the remote server, which is not trusted to + * return real POSIX names: a filename carrying separators or NUL bytes + * would compose a path pointing outside the configured root. + */ + if (/[/\\\0]/.test(entry.filename)) { + logger.warn('Skipping SFTP entry with an illegal filename', { directory: current.path }) + continue + } + + const childPath = joinRemotePath(current.path, entry.filename) + const type = getFileType(entry.attrs) + + if (type === 'directory') { + /** + * `maxDepth` is a configured scope filter, not a cap: files below it are + * never indexed in the first place, so their absence is not evidence of + * a partial listing. Flagging it would leave `listingCapped` set on + * every sync of any tree deeper than the limit, permanently suppressing + * deletion reconciliation. + */ + if (current.depth + 1 > ctx.maxDepth) continue + /** + * The pending queue is capped as well as the number of reads: a single + * directory holding millions of subdirectories would otherwise grow the + * queue without bound long before the read ceiling stopped the walk. + */ + if (queue.length >= MAX_DIRECTORIES) { + truncated = true + continue + } + queue.push({ path: childPath, depth: current.depth + 1 }) + continue + } + + if (type !== 'file') continue + if (!ctx.allowedExtensions.has(getExtension(entry.filename))) continue + + const size = entry.attrs.size ?? 0 + if (size <= 0) continue + + const mtime = entry.attrs.mtime ?? 0 + if (cutoffSeconds !== undefined && mtime < cutoffSeconds) continue + + if (files.length >= MAX_LISTED_ENTRIES) { + truncated = true + break + } + + const oversized = size > MAX_FILE_SIZE + if (!oversized) { + if (indexableCount >= ctx.maxFiles) { + truncated = true + break + } + indexableCount += 1 + } + + files.push({ path: childPath, directory: current.path, size, mtime }) + } + } + + return { files, truncated } +} + +/** + * Builds a metadata stub for a remote file. The hash is derived purely from + * listing metadata (path, mtime, size) so change detection never requires + * downloading content, and it is produced here for both `listDocuments` and + * `getDocument` so the two can never disagree. + */ +function fileToStub(ctx: SftpContext, entry: SftpFileEntry): ExternalDocument { + const title = entry.path.split('/').pop() || entry.path + + return { + externalId: entry.path, + title, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: `sftp://${ctx.host}:${ctx.port}${entry.path}`, + contentHash: `sftp:${entry.path}:${entry.mtime}:${entry.size}`, + metadata: { + path: entry.path, + directory: entry.directory, + extension: getExtension(entry.path), + fileSize: entry.size, + lastModified: new Date(entry.mtime * 1000).toISOString(), + }, + } +} + +/** + * Heuristic binary check: a NUL byte in the leading bytes of a file never occurs + * in the UTF-8 text formats this connector indexes. + */ +function looksBinary(buffer: Buffer): boolean { + const end = Math.min(buffer.length, BINARY_SNIFF_BYTES) + for (let i = 0; i < end; i++) { + if (buffer[i] === 0) return true + } + return false +} + +export const sftpConnector: ConnectorConfig = { + ...sftpConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + _cursor?: string, + syncContext?: Record, + lastSyncAt?: Date + ): Promise => { + const ctx = resolveContext(accessToken, sourceConfig) + + logger.info('Listing SFTP files', { + host: ctx.host, + rootPath: ctx.rootPath, + incremental: Boolean(lastSyncAt), + }) + + const { files, truncated } = await withSftpSession(ctx, LISTING_TIMEOUT_MS, (sftp) => + walkTree(sftp, ctx, lastSyncAt) + ) + + const documents = files.map((entry) => + stubOrSkipBySize(fileToStub(ctx, entry), entry.size, MAX_FILE_SIZE) + ) + + /** + * A truncated walk means still-present files are absent from this listing. + * Without this flag the sync engine would hard-delete every document past + * the cap. + */ + if (truncated && syncContext) syncContext.listingCapped = true + + return { documents, hasMore: false } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + const ctx = resolveContext(accessToken, sourceConfig) + + if (!isPathSafe(externalId)) { + logger.warn('Rejecting SFTP path with traversal sequences', { externalId }) + return null + } + const remotePath = normalizeRemotePath(externalId) + if (!isWithinRoot(remotePath, ctx.rootPath)) { + logger.warn('Rejecting SFTP path outside the configured root', { remotePath }) + return null + } + + return await withSftpSession(ctx, DOCUMENT_TIMEOUT_MS, async (sftp) => { + const stats = await statRemotePath(sftp, remotePath, { follow: false }) + if (!stats) return null + /** + * `lstat` above means a symlink reports as `symlink`, not as whatever it + * points at, so a link swapped in under the root between listing and + * fetch is rejected here rather than read through. + */ + if (getFileType(stats) !== 'file') return null + + const size = stats.size ?? 0 + const entry: SftpFileEntry = { + path: remotePath, + directory: remotePath.slice(0, remotePath.lastIndexOf('/')) || '/', + size, + mtime: stats.mtime ?? 0, + } + const stub = fileToStub(ctx, entry) + + if (size > MAX_FILE_SIZE) { + logger.warn('Skipping oversized SFTP file', { remotePath, size }) + return markSkipped(stub, sizeLimitSkipReason(MAX_FILE_SIZE)) + } + + let buffer: Buffer + try { + buffer = await readSftpFileCapped(sftp, remotePath, MAX_FILE_SIZE, 'SFTP connector sync') + } catch (error) { + /** + * The reported `stat` size is attacker-controlled, so a server can + * understate it and then stream unbounded data. `readSftpFileCapped` + * destroys the stream at the cap and throws, which lands here. + */ + if (isPayloadSizeLimitError(error)) { + logger.warn('SFTP file exceeded the size cap while streaming', { remotePath }) + return markSkipped(stub, sizeLimitSkipReason(MAX_FILE_SIZE)) + } + /** + * The file was removed between the listing and this read. That is an + * absence, not a failure, so it resolves null; every other error is + * rethrown so the sync records a failed document instead of silently + * dropping one. + */ + if (isNotFoundError(error)) { + logger.warn('SFTP file disappeared before it could be read', { remotePath }) + return null + } + throw error + } + + if (looksBinary(buffer)) { + logger.warn('Skipping binary SFTP file', { remotePath }) + return markSkipped(stub, 'File appears to be binary and was not indexed') + } + + const raw = buffer.toString('utf-8') + const content = HTML_EXTENSIONS.has(getExtension(remotePath)) ? htmlToPlainText(raw) : raw + if (!content.trim()) return null + + return { ...stub, content, contentDeferred: false } + }) + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + let ctx: SftpContext + try { + ctx = resolveContext(accessToken, sourceConfig) + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Invalid configuration') } + } + + try { + const valid = await withSftpSession(ctx, DOCUMENT_TIMEOUT_MS, async (sftp) => { + const stats = await statRemotePath(sftp, ctx.rootPath, { follow: true }) + if (!stats) return false + return getFileType(stats) === 'directory' + }) + if (!valid) { + return { valid: false, error: `Root path is not an accessible directory: ${ctx.rootPath}` } + } + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to connect to the SFTP server') } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.directory === 'string' && metadata.directory.length > 0) { + result.directory = metadata.directory + } + + if (typeof metadata.extension === 'string' && metadata.extension.length > 0) { + result.extension = metadata.extension + } + + if (metadata.fileSize != null) { + const num = Number(metadata.fileSize) + if (!Number.isNaN(num)) result.fileSize = num + } + + const lastModified = parseTagDate(metadata.lastModified) + if (lastModified) result.lastModified = lastModified + + return result + }, +} diff --git a/apps/sim/connectors/trello/index.ts b/apps/sim/connectors/trello/index.ts new file mode 100644 index 00000000000..aec23722680 --- /dev/null +++ b/apps/sim/connectors/trello/index.ts @@ -0,0 +1 @@ +export { trelloConnector } from '@/connectors/trello/trello' diff --git a/apps/sim/connectors/trello/meta.ts b/apps/sim/connectors/trello/meta.ts new file mode 100644 index 00000000000..24b003b8755 --- /dev/null +++ b/apps/sim/connectors/trello/meta.ts @@ -0,0 +1,71 @@ +import { TrelloIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const trelloConnectorMeta: ConnectorMeta = { + id: 'trello', + name: 'Trello', + description: 'Sync board cards, descriptions, checklists, and comments from Trello', + version: '1.1.0', + icon: TrelloIcon, + + auth: { + mode: 'oauth', + provider: 'trello', + requiredScopes: ['read'], + }, + + configFields: [ + { + id: 'boardSelector', + title: 'Boards', + type: 'selector', + selectorKey: 'trello.boards', + canonicalParamId: 'boardIds', + mode: 'basic', + multi: true, + required: false, + placeholder: 'Select boards (empty = all open boards)', + description: + 'Boards to sync. Leave empty to sync cards from every open board you can access.', + }, + { + id: 'boardIds', + title: 'Board IDs', + type: 'short-input', + canonicalParamId: 'boardIds', + mode: 'advanced', + multi: true, + required: false, + placeholder: 'e.g. 5f2b1c8e9a1d2b0011223344 (empty = all open boards)', + description: + 'Comma-separated board IDs (24-character hex). Leave empty to sync cards from every open board you can access.', + }, + { + id: 'cardFilter', + title: 'Cards', + type: 'dropdown', + required: false, + options: [ + { label: 'Open cards only', id: 'open' }, + { label: 'All cards (including archived)', id: 'all' }, + ], + description: 'Which cards to sync. Defaults to open cards only.', + }, + { + id: 'maxCards', + title: 'Max Cards', + type: 'short-input', + required: false, + placeholder: 'e.g. 1000 (default: unlimited)', + }, + ], + + tagDefinitions: [ + { id: 'boardName', displayName: 'Board', fieldType: 'text' }, + { id: 'listName', displayName: 'List', fieldType: 'text' }, + { id: 'labels', displayName: 'Labels', fieldType: 'text' }, + { id: 'closed', displayName: 'Archived', fieldType: 'boolean' }, + { id: 'due', displayName: 'Due Date', fieldType: 'date' }, + { id: 'lastActivity', displayName: 'Last Activity', fieldType: 'date' }, + ], +} diff --git a/apps/sim/connectors/trello/trello.ts b/apps/sim/connectors/trello/trello.ts new file mode 100644 index 00000000000..721e78215df --- /dev/null +++ b/apps/sim/connectors/trello/trello.ts @@ -0,0 +1,797 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { trelloConnectorMeta } from '@/connectors/trello/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' + +const logger = createLogger('TrelloConnector') + +/** + * Trello REST API base. Every request authenticates with the Sim application + * `key` plus the user's OAuth `token` as query parameters — Trello does not + * accept a bearer header. + * @see https://developer.atlassian.com/cloud/trello/guides/rest-api/authorization/ + */ +const TRELLO_API_BASE_URL = 'https://api.trello.com/1' + +/** + * Card fields requested when listing. Kept in sync with the single-card fetch so + * the stub and the hydrated document describe the same card. `badges` carries the + * comment/checklist/attachment counters that feed the change-detection hash. + * @see https://developer.atlassian.com/cloud/trello/guides/rest-api/object-definitions/ + */ +const CARD_FIELDS = + 'id,name,desc,url,shortUrl,closed,due,dueComplete,dateLastActivity,idList,idBoard,labels,badges' + +/** + * Maximum cards requested per Trello request. The API caps long collections at + * 1000 results and documents `before`/`since` as the way to page past that cap. + * Neither the ordering of `GET /lists/{id}/cards` nor which 1000 results `limit` + * keeps is documented, so a list that needs a second request is reported as + * capped even though the extra pages are still collected. + * @see https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/ + */ +const CARD_PAGE_LIMIT = 1000 + +/** + * Soft per-call document target. Trello has no board-wide card cursor, so the + * listing walks board → list → card page. Emitting one list per call would burn + * one sync-engine page per list and hit its `MAX_PAGES` ceiling on workspaces + * with many small lists, which permanently truncates the listing. + */ +const CARD_TARGET_PER_CALL = 500 + +/** Maximum comment actions requested for, and rendered into, a card's content. */ +const COMMENT_LIMIT = 50 + +/** + * Upper bound on Trello requests issued by a single `listDocuments` call. The + * traversal is board → list → card page, so a workspace made of many small or + * empty lists would otherwise issue thousands of sequential requests inside one + * call and exhaust the sync task's time budget before returning a single page. + */ +const MAX_REQUESTS_PER_CALL = 40 + +/** Concurrency used when resolving names for explicitly configured boards. */ +const BOARD_LOOKUP_CONCURRENCY = 4 + +interface TrelloLabel { + id?: string + name?: string | null + color?: string | null +} + +interface TrelloBadges { + comments?: number | null + checkItems?: number | null + checkItemsChecked?: number | null + attachments?: number | null + description?: boolean | null +} + +interface TrelloAttachment { + id?: string + name?: string | null + url?: string | null +} + +interface TrelloMember { + id?: string + fullName?: string | null + username?: string | null +} + +interface TrelloCard { + id: string + name?: string | null + desc?: string | null + url?: string | null + shortUrl?: string | null + closed?: boolean | null + due?: string | null + dueComplete?: boolean | null + dateLastActivity?: string | null + idList?: string | null + idBoard?: string | null + labels?: TrelloLabel[] | null + badges?: TrelloBadges | null + board?: { id?: string | null; name?: string | null } | null + list?: { id?: string | null; name?: string | null } | null + actions?: TrelloAction[] | null + attachments?: TrelloAttachment[] | null + members?: TrelloMember[] | null +} + +interface TrelloAction { + id?: string + type?: string | null + date?: string | null + data?: { text?: string | null } | null + memberCreator?: { fullName?: string | null; username?: string | null } | null +} + +interface TrelloChecklistItem { + id?: string + name?: string | null + state?: string | null +} + +interface TrelloChecklist { + id?: string + name?: string | null + checkItems?: TrelloChecklistItem[] | null +} + +interface TrelloBoardRef { + id: string + name?: string | null +} + +interface TrelloListRef { + id: string + name?: string | null +} + +/** + * Pagination state encoded into `nextCursor`: which board of the resolved set is + * being read, which of that board's lists is being read, and — when that list + * holds more cards than one request can return — the id of the oldest card + * already emitted, replayed as the `before` bound for the next request. + */ +interface CursorState { + boardIndex: number + listIndex: number + beforeId?: string +} + +function encodeCursor(state: CursorState): string { + return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url') +} + +function decodeCursor(cursor?: string): CursorState { + if (!cursor) return { boardIndex: 0, listIndex: 0 } + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as + | Partial + | undefined + return { + boardIndex: Number(parsed?.boardIndex) || 0, + listIndex: Number(parsed?.listIndex) || 0, + beforeId: typeof parsed?.beforeId === 'string' ? parsed.beforeId : undefined, + } + } catch { + return { boardIndex: 0, listIndex: 0 } + } +} + +/** + * Raised when a Trello request fails, carrying the HTTP status so callers can + * distinguish a deleted card (404) from a transport or permission failure. + */ +class TrelloApiError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'TrelloApiError' + } +} + +/** + * Reads Sim's Trello application key. Trello binds every OAuth token to the key + * that issued it, so requests are unauthenticated without both halves. + */ +function requireApiKey(): string { + const apiKey = env.TRELLO_API_KEY + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + return apiKey +} + +/** + * Performs an authenticated GET against the Trello REST API and parses the JSON + * body. `params` values are appended verbatim; `key` and `token` are always added. + */ +async function trelloGet( + accessToken: string, + path: string, + params: Record = {}, + retryOptions?: Parameters[2] +): Promise { + const url = new URL(`${TRELLO_API_BASE_URL}${path}`) + for (const [name, value] of Object.entries(params)) { + url.searchParams.set(name, value) + } + url.searchParams.set('key', requireApiKey()) + url.searchParams.set('token', accessToken) + + const response = await fetchWithRetry( + url.toString(), + { method: 'GET', headers: { Accept: 'application/json' } }, + retryOptions + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + throw new TrelloApiError( + `Trello API error: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`, + response.status + ) + } + + return (await response.json()) as T +} + +/** + * Normalizes the configured card scope. Trello's cards nested resource accepts + * `all`, `closed`, `none`, `open`, and `visible`; this connector exposes only the + * two meaningful read scopes and defaults to open cards. + */ +function resolveCardFilter(sourceConfig: Record): 'open' | 'all' { + return sourceConfig.cardFilter === 'all' ? 'all' : 'open' +} + +/** + * Resolves the boards to sync. Explicitly configured board IDs are looked up so + * their names are available for tagging; otherwise every open board the member + * belongs to is enumerated. + * @see https://developer.atlassian.com/cloud/trello/rest/api-group-members/#api-members-id-boards-get + */ +async function resolveBoards( + accessToken: string, + sourceConfig: Record +): Promise { + const configured = parseMultiValue(sourceConfig.boardIds) + if (configured.length > 0) { + const resolved: TrelloBoardRef[] = [] + for (let index = 0; index < configured.length; index += BOARD_LOOKUP_CONCURRENCY) { + const batch = configured.slice(index, index + BOARD_LOOKUP_CONCURRENCY) + const settled = await Promise.all( + batch.map(async (id): Promise => { + try { + const board = await trelloGet( + accessToken, + `/boards/${encodeURIComponent(id)}`, + { fields: 'id,name' } + ) + return { id, name: board?.name ?? null } + } catch (error) { + logger.warn('Failed to resolve Trello board name', { + boardId: id, + error: toError(error).message, + }) + return { id } + } + }) + ) + resolved.push(...settled) + } + return resolved + } + + const boards = await trelloGet(accessToken, '/members/me/boards', { + filter: 'open', + fields: 'id,name', + }) + return Array.isArray(boards) ? boards.filter((board) => Boolean(board?.id)) : [] +} + +/** + * Fetches a board's lists. The list scope tracks the configured card scope so + * "All cards (including archived)" also reaches cards sitting in archived lists, + * which an `open`-only list filter would hide entirely. + * @see https://developer.atlassian.com/cloud/trello/rest/api-group-boards/#api-boards-id-lists-get + */ +async function listBoardLists( + accessToken: string, + boardId: string, + cardFilter: 'open' | 'all' +): Promise { + const lists = await trelloGet( + accessToken, + `/boards/${encodeURIComponent(boardId)}/lists`, + { filter: cardFilter === 'all' ? 'all' : 'open', fields: 'id,name' } + ) + return Array.isArray(lists) ? lists.filter((list) => Boolean(list?.id)) : [] +} + +function labelNames(card: TrelloCard): string[] { + if (!Array.isArray(card.labels)) return [] + return card.labels + .map((label) => label?.name?.trim() || label?.color?.trim() || '') + .filter((name) => name.length > 0) +} + +/** + * Change-detection hash. + * + * `dateLastActivity` alone is not a sufficient change signal: Trello documents + * neither which events bump it nor any guarantee that every content-bearing + * event does, and comment/checklist edits are rendered into the document body. + * The `badges` counters — comment count, checklist item counts, attachment count + * and the description flag — come back with the listing at no extra request cost + * and change whenever those bodies change, so they are folded in as a + * belt-and-braces signal. Must be produced identically by the stub and the + * hydrated document. + */ +function buildContentHash(card: TrelloCard): string { + const badges = card.badges ?? {} + const counters = [ + badges.comments ?? 0, + badges.checkItems ?? 0, + badges.checkItemsChecked ?? 0, + badges.attachments ?? 0, + badges.description === true ? 1 : 0, + ].join('.') + return `trello:${card.id}:${card.dateLastActivity ?? ''}:${counters}` +} + +/** + * Metadata carried on every document and fed to `mapTags`. Board and list names + * are passed in because the listing resolves them from its traversal while the + * single-card fetch resolves them from the expanded `board`/`list` objects. + */ +function cardMetadata( + card: TrelloCard, + boardName: string, + listName: string +): Record { + return { + boardId: card.idBoard ?? '', + boardName, + listId: card.idList ?? '', + listName, + labels: labelNames(card), + closed: card.closed === true, + due: card.due ?? undefined, + lastActivity: card.dateLastActivity ?? undefined, + } +} + +/** + * Builds the lightweight listing stub. Content is deferred because checklists and + * comments each require their own per-card request. + */ +function cardToStub(card: TrelloCard, boardName: string, listName: string): ExternalDocument { + return { + externalId: card.id, + title: card.name?.trim() || 'Untitled Card', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: card.url ?? card.shortUrl ?? undefined, + contentHash: buildContentHash(card), + metadata: cardMetadata(card, boardName, listName), + } +} + +/** + * Renders a card, its description, checklists, attachments, members, and comments + * as plain text. Trello returns description and comment text as Markdown, never + * HTML, so no HTML stripping is applied. + */ +function buildCardContent( + card: TrelloCard, + boardName: string, + listName: string, + checklists: TrelloChecklist[], + comments: TrelloAction[] +): string { + const parts: string[] = [] + + if (boardName) parts.push(`Board: ${boardName}`) + if (listName) parts.push(`List: ${listName}`) + parts.push(`Card: ${card.name?.trim() || 'Untitled Card'}`) + + const labels = labelNames(card) + if (labels.length > 0) parts.push(`Labels: ${labels.join(', ')}`) + + const members = (Array.isArray(card.members) ? card.members : []) + .map((member) => member?.fullName?.trim() || member?.username?.trim() || '') + .filter((name) => name.length > 0) + if (members.length > 0) parts.push(`Members: ${members.join(', ')}`) + + if (card.due) parts.push(`Due: ${card.due}${card.dueComplete === true ? ' (complete)' : ''}`) + if (card.closed === true) parts.push('Archived: Yes') + + const description = card.desc?.trim() + if (description) { + parts.push('') + parts.push('--- Description ---') + parts.push(description) + } + + const populatedChecklists = checklists.filter( + (checklist) => (checklist.checkItems?.length ?? 0) > 0 + ) + if (populatedChecklists.length > 0) { + parts.push('') + parts.push('--- Checklists ---') + for (const checklist of populatedChecklists) { + parts.push(`${checklist.name?.trim() || 'Checklist'}:`) + for (const item of checklist.checkItems ?? []) { + const name = item.name?.trim() + if (!name) continue + parts.push(`- [${item.state === 'complete' ? 'x' : ' '}] ${name}`) + } + } + } + + const attachments = (Array.isArray(card.attachments) ? card.attachments : []).filter( + (attachment) => attachment?.name?.trim() || attachment?.url?.trim() + ) + if (attachments.length > 0) { + parts.push('') + parts.push('--- Attachments ---') + for (const attachment of attachments) { + const name = attachment.name?.trim() + const url = attachment.url?.trim() + parts.push(name && url ? `- ${name}: ${url}` : `- ${name || url}`) + } + } + + const populatedComments = comments.filter((comment) => comment.data?.text?.trim()) + if (populatedComments.length > 0) { + parts.push('') + parts.push('--- Comments ---') + for (const comment of populatedComments) { + const author = + comment.memberCreator?.fullName?.trim() || + comment.memberCreator?.username?.trim() || + 'Unknown' + parts.push(`Comment by ${author}: ${comment.data?.text?.trim()}`) + } + } + + return parts.join('\n') +} + +/** + * Reads a board's lists once per sync run. The listing walks a board across + * several pages, so re-fetching its lists on every page would multiply requests + * and risk an inconsistent traversal mid-sync. + */ +async function getCachedLists( + accessToken: string, + boardId: string, + cardFilter: 'open' | 'all', + syncContext: Record | undefined +): Promise<{ lists: TrelloListRef[]; fetched: boolean }> { + const cacheKey = `lists:${boardId}` + const cached = syncContext?.[cacheKey] as TrelloListRef[] | undefined + if (cached) return { lists: cached, fetched: false } + + const lists = await listBoardLists(accessToken, boardId, cardFilter) + if (syncContext) syncContext[cacheKey] = lists + return { lists, fetched: true } +} + +export const trelloConnector: ConnectorConfig = { + ...trelloConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const maxCards = sourceConfig.maxCards ? Number(sourceConfig.maxCards) : 0 + const cardFilter = resolveCardFilter(sourceConfig) + const state = decodeCursor(cursor) + + const boards = + (syncContext?.boards as TrelloBoardRef[] | undefined) ?? + (await resolveBoards(accessToken, sourceConfig)) + if (syncContext) syncContext.boards = boards + + const markCapped = () => { + if (syncContext) syncContext.listingCapped = true + } + + const documents: ExternalDocument[] = [] + const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 + let boardIndex = state.boardIndex + let listIndex = state.listIndex + let beforeId = state.beforeId + let hitLimit = false + let requestsUsed = 0 + + while (boardIndex < boards.length) { + if (documents.length >= CARD_TARGET_PER_CALL || requestsUsed >= MAX_REQUESTS_PER_CALL) break + + const board = boards[boardIndex] + const boardName = board.name?.trim() ?? '' + + /** + * A list-level failure would drop still-existing cards from the listing, so + * the board is skipped and the listing is marked capped rather than letting + * deletion reconciliation purge those documents. + */ + let lists: TrelloListRef[] + try { + const result = await getCachedLists(accessToken, board.id, cardFilter, syncContext) + lists = result.lists + if (result.fetched) requestsUsed += 1 + } catch (error) { + requestsUsed += 1 + logger.warn('Failed to list Trello lists for board', { + boardId: board.id, + error: toError(error).message, + }) + markCapped() + boardIndex += 1 + listIndex = 0 + beforeId = undefined + continue + } + + if (listIndex >= lists.length) { + boardIndex += 1 + listIndex = 0 + beforeId = undefined + continue + } + + const list = lists[listIndex] + const listName = list.name?.trim() ?? '' + + let cards: TrelloCard[] + requestsUsed += 1 + try { + cards = await trelloGet( + accessToken, + `/lists/${encodeURIComponent(list.id)}/cards`, + { + filter: cardFilter, + fields: CARD_FIELDS, + limit: String(CARD_PAGE_LIMIT), + ...(beforeId ? { before: beforeId } : {}), + } + ) + } catch (error) { + logger.warn('Failed to list Trello cards', { + boardId: board.id, + listId: list.id, + error: toError(error).message, + }) + markCapped() + listIndex += 1 + beforeId = undefined + continue + } + + const rawCards = (Array.isArray(cards) ? cards : []).filter((card) => Boolean(card?.id)) + const pageFull = rawCards.length >= CARD_PAGE_LIMIT + + /** + * Trello's `before` bound is a creation date derived from the id, so it is + * only second-granular and a card created in the same second as the bound + * can come back again. Card ids are Mongo ObjectIds whose leading four + * bytes are the big-endian creation timestamp, so lowercase hex ordering + * is creation ordering down to the second and total (arbitrary but stable) + * within a second — enough to drop anything not strictly older than the + * bound as already emitted. + */ + const bound = beforeId + const newCards = bound ? rawCards.filter((card) => card.id < bound) : rawCards + + let oldestId: string | undefined + for (const card of newCards) { + if (!oldestId || card.id < oldestId) oldestId = card.id + } + + let stubs = newCards.map((card) => cardToStub(card, boardName, listName)) + + let slicedByCap = false + if (maxCards > 0) { + const remaining = Math.max(0, maxCards - previouslyFetched - documents.length) + if (stubs.length > remaining) { + stubs = stubs.slice(0, remaining) + slicedByCap = true + } + } + + documents.push(...stubs) + + if (pageFull && oldestId && (!beforeId || oldestId < beforeId)) { + /** + * Paging past Trello's 1000-result ceiling with `before` is only complete + * if the truncated response is the newest 1000 of the collection. Trello + * documents neither the ordering of `GET /lists/{id}/cards` nor which + * 1000 results `limit` keeps, so a card newer than the bound but absent + * from the previous response is unreachable and would look deleted. The + * listing is therefore reported as capped for any list that needs a + * second request — the cards are still collected, but deletion + * reconciliation is withheld until a deliberate full resync. + */ + markCapped() + beforeId = oldestId + } else { + if (pageFull) { + /** + * The page came back full yet yielded no strictly-older card, so + * `before` cannot advance and the rest of this list is unreachable. + */ + logger.warn('Trello list pagination stalled; remaining cards unreachable', { + boardId: board.id, + listId: list.id, + }) + markCapped() + } + listIndex += 1 + beforeId = undefined + } + + if (maxCards > 0 && previouslyFetched + documents.length >= maxCards) { + hitLimit = true + /** + * The cap only truncates the listing when source cards were actually + * left behind. Reaching the cap exactly at source exhaustion is a + * complete listing and must stay eligible for deletion reconciliation. + */ + const moreRemains = + slicedByCap || + beforeId !== undefined || + listIndex < lists.length || + boardIndex + 1 < boards.length + if (moreRemains) markCapped() + break + } + } + + const totalFetched = previouslyFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + + const exhausted = hitLimit || boardIndex >= boards.length + const nextCursor = exhausted ? undefined : encodeCursor({ boardIndex, listIndex, beforeId }) + + logger.info('Listing Trello cards', { + boardIndex, + boardTotal: boards.length, + listIndex, + cardCount: documents.length, + totalFetched, + }) + + return { documents, nextCursor, hasMore: !exhausted } + }, + + getDocument: async ( + accessToken: string, + _sourceConfig: Record, + externalId: string + ): Promise => { + if (!externalId) return null + + const cardId = encodeURIComponent(externalId) + + try { + const card = await trelloGet(accessToken, `/cards/${cardId}`, { + fields: CARD_FIELDS, + board: 'true', + board_fields: 'id,name', + list: 'true', + actions: 'commentCard', + actions_limit: String(COMMENT_LIMIT), + attachments: 'true', + attachment_fields: 'name,url', + members: 'true', + member_fields: 'fullName,username', + }) + + if (!card?.id) return null + + const boardName = card.board?.name?.trim() ?? '' + const listName = card.list?.name?.trim() ?? '' + + /** + * Checklists come from their own endpoint so `checkItems` are explicitly + * requested. A failure here degrades content rather than dropping the card. + */ + let checklists: TrelloChecklist[] = [] + try { + const fetched = await trelloGet( + accessToken, + `/cards/${cardId}/checklists`, + { checkItems: 'all', checkItem_fields: 'name,state', fields: 'name' } + ) + if (Array.isArray(fetched)) checklists = fetched + } catch (error) { + logger.warn('Failed to fetch Trello checklists', { + externalId, + error: toError(error).message, + }) + } + + const comments = (Array.isArray(card.actions) ? card.actions : []) + .filter((action) => action?.type === 'commentCard') + .slice(0, COMMENT_LIMIT) + + return { + externalId: card.id, + title: card.name?.trim() || 'Untitled Card', + content: buildCardContent(card, boardName, listName, checklists, comments), + contentDeferred: false, + mimeType: 'text/plain', + sourceUrl: card.url ?? card.shortUrl ?? undefined, + contentHash: buildContentHash(card), + metadata: cardMetadata(card, boardName, listName), + } + } catch (error) { + if (error instanceof TrelloApiError && error.status === 404) return null + logger.warn('Failed to get Trello card', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const maxCards = sourceConfig.maxCards as string | undefined + if (maxCards && (Number.isNaN(Number(maxCards)) || Number(maxCards) < 0)) { + return { valid: false, error: 'Max cards must be a non-negative number' } + } + + const cardFilter = sourceConfig.cardFilter + if (cardFilter != null && cardFilter !== '' && cardFilter !== 'open' && cardFilter !== 'all') { + return { valid: false, error: 'Cards must be either "open" or "all"' } + } + + if (!env.TRELLO_API_KEY) { + return { + valid: false, + error: 'Trello is not configured on this deployment (missing Trello application key)', + } + } + + try { + await trelloGet(accessToken, '/members/me', { fields: 'id' }, VALIDATE_RETRY_OPTIONS) + + for (const boardId of parseMultiValue(sourceConfig.boardIds)) { + await trelloGet( + accessToken, + `/boards/${encodeURIComponent(boardId)}`, + { fields: 'id' }, + VALIDATE_RETRY_OPTIONS + ) + } + + return { valid: true } + } catch (error) { + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.boardName === 'string' && metadata.boardName.trim()) { + result.boardName = metadata.boardName + } + + if (typeof metadata.listName === 'string' && metadata.listName.trim()) { + result.listName = metadata.listName + } + + const labels = joinTagArray(metadata.labels) + if (labels) result.labels = labels + + if (typeof metadata.closed === 'boolean') result.closed = metadata.closed + + const due = parseTagDate(metadata.due) + if (due) result.due = due + + const lastActivity = parseTagDate(metadata.lastActivity) + if (lastActivity) result.lastActivity = lastActivity + + return result + }, +} diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 71ad9ad6926..cc96e68a7af 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -8,7 +8,18 @@ import type { SelectorKey } from '@/hooks/selectors/types' */ export type ConnectorAuthConfig = | { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] } - | { mode: 'apiKey'; label?: string; placeholder?: string } + | { + mode: 'apiKey' + label?: string + placeholder?: string + /** + * When true, the key may be left blank — the source is reachable without + * authentication (e.g. a public documentation site). A blank key is + * stored as `null` rather than an encrypted empty string, and the + * connector receives an empty access token. + */ + optional?: boolean + } /** * A single document fetched from an external source. diff --git a/apps/sim/connectors/zoho-desk/index.ts b/apps/sim/connectors/zoho-desk/index.ts new file mode 100644 index 00000000000..e36f95a4369 --- /dev/null +++ b/apps/sim/connectors/zoho-desk/index.ts @@ -0,0 +1 @@ +export { zohoDeskConnector } from '@/connectors/zoho-desk/zoho-desk' diff --git a/apps/sim/connectors/zoho-desk/meta.ts b/apps/sim/connectors/zoho-desk/meta.ts new file mode 100644 index 00000000000..b016c59a827 --- /dev/null +++ b/apps/sim/connectors/zoho-desk/meta.ts @@ -0,0 +1,180 @@ +import { ZohoDeskIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +/** Default ceiling on tickets pulled per sync when the user leaves Max Tickets blank. */ +export const DEFAULT_MAX_TICKETS = 500 + +/** Default ceiling on Help Center articles pulled per sync. */ +export const DEFAULT_MAX_ARTICLES = 500 + +/** + * Zoho Desk REST hosts per data center. Zoho scopes every portal to the data + * center it was created in, and a token minted in one is rejected by the others, + * so the host is part of the connector configuration rather than a constant. + * + * Kept as a closed map (never a user-supplied host) so the OAuth token can only + * ever be sent to a Zoho-owned origin. + */ +export const ZOHO_DESK_DATA_CENTER_BASES = { + us: 'https://desk.zoho.com', + eu: 'https://desk.zoho.eu', + in: 'https://desk.zoho.in', + au: 'https://desk.zoho.com.au', + jp: 'https://desk.zoho.jp', + uk: 'https://desk.zoho.uk', + // Canada is the one region that is not a `zoho.` host: Zoho serves it from + // `zohocloud.ca` (accounts.zohocloud.ca / www.zohoapis.ca). `desk.zoho.ca` does + // not resolve at all. + ca: 'https://desk.zohocloud.ca', + sa: 'https://desk.zoho.sa', + cn: 'https://desk.zoho.com.cn', + sg: 'https://desk.zoho.sg', + ae: 'https://desk.zoho.ae', +} as const + +export type ZohoDeskDataCenter = keyof typeof ZOHO_DESK_DATA_CENTER_BASES + +/** Data center assumed when the user has not chosen one. */ +export const DEFAULT_ZOHO_DESK_DATA_CENTER: ZohoDeskDataCenter = 'us' + +export const zohoDeskConnectorMeta: ConnectorMeta = { + id: 'zoho_desk', + name: 'Zoho Desk', + description: 'Sync Help Center articles and support tickets from Zoho Desk', + version: '1.0.0', + icon: ZohoDeskIcon, + + auth: { + mode: 'oauth', + provider: 'zoho-desk', + requiredScopes: [ + 'Desk.basic.READ', + 'Desk.tickets.READ', + 'Desk.articles.READ', + 'Desk.organization.READ', + ], + }, + + configFields: [ + { + id: 'orgSelector', + title: 'Organization', + type: 'selector', + selectorKey: 'zoho_desk.organizations', + canonicalParamId: 'orgId', + mode: 'basic', + placeholder: 'Select an organization', + required: true, + description: 'Zoho Desk portal to sync from', + }, + { + id: 'orgId', + title: 'Organization ID', + type: 'short-input', + canonicalParamId: 'orgId', + mode: 'advanced', + placeholder: 'e.g. 706989253', + required: true, + description: 'Zoho Desk organization ID', + }, + { + id: 'dataCenter', + title: 'Data Center', + type: 'dropdown', + required: true, + description: 'Zoho data center your portal was created in', + options: [ + { label: 'United States (desk.zoho.com)', id: 'us' }, + { label: 'Europe (desk.zoho.eu)', id: 'eu' }, + { label: 'India (desk.zoho.in)', id: 'in' }, + { label: 'Australia (desk.zoho.com.au)', id: 'au' }, + { label: 'Japan (desk.zoho.jp)', id: 'jp' }, + { label: 'United Kingdom (desk.zoho.uk)', id: 'uk' }, + { label: 'Canada (desk.zohocloud.ca)', id: 'ca' }, + { label: 'Saudi Arabia (desk.zoho.sa)', id: 'sa' }, + { label: 'China (desk.zoho.com.cn)', id: 'cn' }, + { label: 'Singapore (desk.zoho.sg)', id: 'sg' }, + { label: 'United Arab Emirates (desk.zoho.ae)', id: 'ae' }, + ], + }, + { + id: 'contentType', + title: 'Content Type', + type: 'dropdown', + required: true, + description: 'What content to sync from Zoho Desk', + options: [ + { label: 'Articles & Tickets', id: 'both' }, + { label: 'Help Center Articles Only', id: 'articles' }, + { label: 'Support Tickets Only', id: 'tickets' }, + ], + }, + { + id: 'ticketStatus', + title: 'Ticket Status Filter', + type: 'short-input', + required: false, + placeholder: 'e.g. Open,On Hold (default: all statuses)', + description: + 'Comma-separated ticket statuses. Free text because a Zoho Desk portal can define its own statuses.', + }, + { + id: 'departmentIds', + title: 'Department IDs', + type: 'short-input', + required: false, + multi: true, + placeholder: 'e.g. 1892000000006907 (default: all departments)', + description: 'Restrict the ticket sync to specific departments', + }, + { + id: 'articleStatus', + title: 'Article Status Filter', + type: 'dropdown', + required: false, + description: 'Publishing status of the Help Center articles to sync', + options: [ + { label: 'All Statuses', id: 'all' }, + { label: 'Published', id: 'Published' }, + { label: 'Draft', id: 'Draft' }, + { label: 'Review', id: 'Review' }, + { label: 'Expired', id: 'Expired' }, + { label: 'Unpublished', id: 'Unpublished' }, + ], + }, + { + id: 'articleCategoryId', + title: 'Article Category ID', + type: 'short-input', + required: false, + placeholder: 'e.g. 4000000013240 (default: all categories)', + description: 'Restrict the article sync to a single knowledge base category', + }, + { + id: 'maxTickets', + title: 'Max Tickets', + type: 'short-input', + required: false, + placeholder: `e.g. 200 (default: ${DEFAULT_MAX_TICKETS})`, + description: 'Maximum number of tickets to sync', + }, + { + id: 'maxArticles', + title: 'Max Articles', + type: 'short-input', + required: false, + placeholder: `e.g. 200 (default: ${DEFAULT_MAX_ARTICLES})`, + description: 'Maximum number of Help Center articles to sync', + }, + ], + + tagDefinitions: [ + { id: 'contentType', displayName: 'Content Type', fieldType: 'text' }, + { id: 'status', displayName: 'Status', fieldType: 'text' }, + { id: 'priority', displayName: 'Priority', fieldType: 'text' }, + { id: 'category', displayName: 'Category', fieldType: 'text' }, + { id: 'tags', displayName: 'Tags', fieldType: 'text' }, + { id: 'updatedAt', displayName: 'Last Updated', fieldType: 'date' }, + { id: 'commentCount', displayName: 'Comment Count', fieldType: 'number' }, + ], +} diff --git a/apps/sim/connectors/zoho-desk/zoho-desk.ts b/apps/sim/connectors/zoho-desk/zoho-desk.ts new file mode 100644 index 00000000000..badbd6a2c39 --- /dev/null +++ b/apps/sim/connectors/zoho-desk/zoho-desk.ts @@ -0,0 +1,760 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' +import { + DEFAULT_MAX_ARTICLES, + DEFAULT_MAX_TICKETS, + DEFAULT_ZOHO_DESK_DATA_CENTER, + ZOHO_DESK_DATA_CENTER_BASES, + type ZohoDeskDataCenter, + zohoDeskConnectorMeta, +} from '@/connectors/zoho-desk/meta' + +const logger = createLogger('ZohoDeskConnector') + +/** Zoho caps the article list at 50 per request and the ticket list at 100. */ +const ARTICLES_PAGE_SIZE = 50 +const TICKETS_PAGE_SIZE = 100 + +/** Zoho caps `/conversations` at 200 entries per request. */ +const CONVERSATIONS_PAGE_SIZE = 200 + +/** + * Highest `from` index any Desk list API accepts. Zoho allows paginating over at + * most 5000 records and answers `from >= 5000` with HTTP 422 + * `UNPROCESSABLE_ENTITY: The value passed for field 'from' exceeds the range of + * '0-4999'` — an error, not an empty page. Walking into it would abort the whole + * listing, so the drain stops at the ceiling and reports the listing as capped. + */ +const MAX_LIST_OFFSET = 4999 + +/** Upper bound on conversation entries folded into one ticket document. */ +const MAX_CONVERSATION_ENTRIES = 400 + +/** + * Upper bound on threads hydrated to their full body for one ticket. Beyond this + * the truncated `summary` from `/conversations` is used, so a thousand-message + * ticket cannot turn a single `getDocument` call into a thousand HTTP requests. + */ +const MAX_HYDRATED_THREADS = 50 + +interface ZohoDeskArticleSummary { + id: string + title?: string + status?: string + locale?: string + summary?: string + modifiedTime?: string + createdTime?: string + permalink?: string + portalUrl?: string + webUrl?: string + categoryId?: string + category?: { id?: string; name?: string } + tags?: string[] + isTrashed?: boolean +} + +interface ZohoDeskArticleDetail extends ZohoDeskArticleSummary { + answer?: string +} + +interface ZohoDeskTicket { + id: string + ticketNumber?: string + subject?: string + description?: string + status?: string + statusType?: string + priority?: string + category?: string + subCategory?: string + classification?: string + channel?: string + departmentId?: string + createdTime?: string + /** Present on `GET /tickets/{id}` only — the list projection omits it. */ + modifiedTime?: string + closedTime?: string | null + customerResponseTime?: string | null + threadCount?: string + commentCount?: string + resolution?: string | null + webUrl?: string + isTrashed?: boolean +} + +interface ZohoDeskConversationEntry { + id: string + type?: string + summary?: string + content?: string + contentType?: string + createdTime?: string + commentedTime?: string + visibility?: string + isPublic?: boolean + direction?: string + channel?: string + author?: { name?: string; email?: string; type?: string } + commenter?: { name?: string; email?: string; type?: string } +} + +interface ZohoDeskThreadDetail { + id: string + content?: string + plainText?: string + contentType?: string + summary?: string +} + +interface ZohoDeskOrganization { + id: string + companyName?: string +} + +/** + * Resolves the Zoho Desk REST base (`{deskHost}/api/v1`) for the configured data + * center. The host comes from a closed map, never from user input, so the OAuth + * token can only reach a Zoho-owned origin. + * + * @throws {Error} when the configured data center is not recognized. + */ +function resolveApiBase(sourceConfig: Record): string { + const raw = typeof sourceConfig.dataCenter === 'string' ? sourceConfig.dataCenter.trim() : '' + const key = (raw || DEFAULT_ZOHO_DESK_DATA_CENTER) as ZohoDeskDataCenter + const host = ZOHO_DESK_DATA_CENTER_BASES[key] + if (!host) { + throw new Error(`Unsupported Zoho Desk data center: ${raw}`) + } + return `${host}/api/v1` +} + +/** + * Reads the required organization ID from the connector config. + * + * @throws {Error} when it is missing. + */ +function requireOrgId(sourceConfig: Record): string { + const orgId = typeof sourceConfig.orgId === 'string' ? sourceConfig.orgId.trim() : '' + if (!orgId) { + throw new Error('Organization ID is required') + } + return orgId +} + +/** + * Reads an optional positive integer cap, falling back to `fallback` when unset + * and throwing when it is present but not a positive number. + */ +function resolveMax(value: unknown, fallback: number, label: string): number { + if (value === undefined || value === null || value === '') return fallback + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 1) { + throw new Error(`${label} must be a positive number`) + } + return Math.floor(parsed) +} + +/** + * Performs an authenticated GET against the Zoho Desk API. + * + * Zoho answers an empty collection with `204 No Content` (which has no JSON body), + * so that case is normalized to an empty object rather than left to throw. + */ +async function deskGet( + url: string, + accessToken: string, + orgId: string | undefined, + retryOptions?: Parameters[2] +): Promise> { + const headers: Record = { + Authorization: `Zoho-oauthtoken ${accessToken}`, + Accept: 'application/json', + } + if (orgId) headers.orgId = orgId + + const response = await fetchWithRetry(url, { method: 'GET', headers }, retryOptions) + + if (response.status === 204) return {} + + const body = (await response.json().catch(() => ({}))) as Record + + if (!response.ok) { + const message = + typeof body.message === 'string' && body.message.trim() + ? body.message + : typeof body.errorCode === 'string' && body.errorCode.trim() + ? body.errorCode + : `Zoho Desk API HTTP error: ${response.status}` + throw new Error(message) + } + + return body +} + +/** Reads the `data` array Zoho wraps every list response in. */ +function readDataArray(body: Record): T[] { + return Array.isArray(body.data) ? (body.data as T[]) : [] +} + +/** + * Renders a Zoho content value as plain text. Zoho spells the HTML discriminator + * both as `html` (comments) and `text/html` (threads), so both are matched; a + * value without an HTML content type is passed through untouched so genuinely + * plain bodies are not mangled by tag stripping. + */ +function toPlainText(content: string | undefined, contentType: string | undefined): string { + if (!content) return '' + const normalized = contentType?.trim().toLowerCase() ?? '' + const isHtml = normalized === 'html' || normalized.startsWith('text/html') + return isHtml ? htmlToPlainText(content) : content +} + +/** + * Drains a Zoho `from`/`limit` paginated list into a single array, stopping at + * `max`. + * + * The final page asks only for the records still needed, so the walk never pulls + * more than `max`. When the cap is reached on an exactly-full page the source may + * or may not hold more, and Zoho's list responses carry no "has more" marker, so + * one extra single-record probe settles it. + * + * @param probeForMore when false the cap is treated as non-truncating. Only for + * callers that ignore `truncated` (per-ticket conversation folding), so they do + * not pay for the probe. + * @returns the collected items and whether the source still had more to give + * when the cap stopped the walk — the caller must surface that as + * `syncContext.listingCapped` so the sync engine does not hard-delete every + * document past the cap. + */ +async function drainPaginated( + fetchPage: (from: number, limit: number) => Promise, + pageSize: number, + max: number, + probeForMore = true +): Promise<{ items: T[]; truncated: boolean }> { + const items: T[] = [] + let from = 0 + + while (items.length < max) { + // Zoho refuses `from >= 5000` outright, so the walk stops one page short of + // the ceiling and declares itself capped rather than throwing a 422. + if (from > MAX_LIST_OFFSET) return { items, truncated: true } + const limit = Math.min(pageSize, max - items.length) + const page = await fetchPage(from, limit) + if (page.length === 0) return { items, truncated: false } + + items.push(...page) + + // A short page means the source is exhausted, which is exactly the case the + // sync engine must be allowed to reconcile deletions against. + if (page.length < limit) return { items, truncated: false } + from += page.length + } + + if (!probeForMore) return { items, truncated: false } + if (from > MAX_LIST_OFFSET) return { items, truncated: true } + + const probe = await fetchPage(from, 1) + return { items, truncated: probe.length > 0 } +} + +/** Lists Help Center articles, newest-first-stable on `createdTime`. */ +async function fetchArticles( + apiBase: string, + accessToken: string, + orgId: string, + options: { status?: string; categoryId?: string; max: number } +): Promise<{ items: ZohoDeskArticleSummary[]; truncated: boolean }> { + return drainPaginated( + async (from, limit) => { + const query = new URLSearchParams({ + from: String(from), + limit: String(limit), + /** + * Newest first. Zoho treats a bare field as ascending and a `-` prefix as + * descending, so `createdTime` would fill the cap with the oldest records + * and leave recent tickets and articles permanently unreachable — the cap + * sets `listingCapped`, which stops that stale tail from ever reconciling + * away. Sorting on createdTime rather than modifiedTime keeps the order + * stable across pages; edits would otherwise reshuffle rows mid-walk. + */ + sortBy: '-createdTime', + }) + if (options.status && options.status !== 'all') query.set('status', options.status) + if (options.categoryId) query.set('categoryId', options.categoryId) + const body = await deskGet(`${apiBase}/articles?${query.toString()}`, accessToken, orgId) + return readDataArray(body) + }, + ARTICLES_PAGE_SIZE, + options.max + ) +} + +/** Lists tickets, optionally filtered by status and department. */ +async function fetchTickets( + apiBase: string, + accessToken: string, + orgId: string, + options: { status?: string; departmentIds: string[]; max: number } +): Promise<{ items: ZohoDeskTicket[]; truncated: boolean }> { + return drainPaginated( + async (from, limit) => { + const query = new URLSearchParams({ + from: String(from), + limit: String(limit), + /** + * Newest first. Zoho treats a bare field as ascending and a `-` prefix as + * descending, so `createdTime` would fill the cap with the oldest records + * and leave recent tickets and articles permanently unreachable — the cap + * sets `listingCapped`, which stops that stale tail from ever reconciling + * away. Sorting on createdTime rather than modifiedTime keeps the order + * stable across pages; edits would otherwise reshuffle rows mid-walk. + */ + sortBy: '-createdTime', + }) + if (options.status) query.set('status', options.status) + if (options.departmentIds.length > 0) { + query.set('departmentIds', options.departmentIds.join(',')) + } + const body = await deskGet(`${apiBase}/tickets?${query.toString()}`, accessToken, orgId) + return readDataArray(body) + }, + TICKETS_PAGE_SIZE, + options.max + ) +} + +/** Lists the threads and comments recorded on a ticket, oldest page first. */ +async function fetchConversations( + apiBase: string, + accessToken: string, + orgId: string, + ticketId: string +): Promise { + const { items } = await drainPaginated( + async (from, limit) => { + const query = new URLSearchParams({ from: String(from), limit: String(limit) }) + const body = await deskGet( + `${apiBase}/tickets/${encodeURIComponent(ticketId)}/conversations?${query.toString()}`, + accessToken, + orgId + ) + return readDataArray(body) + }, + CONVERSATIONS_PAGE_SIZE, + MAX_CONVERSATION_ENTRIES, + false + ) + return items +} + +/** + * Fetches a thread's full body. `/conversations` returns only a truncated + * `summary` for threads, so the message text has to be read per thread. + */ +async function fetchThread( + apiBase: string, + accessToken: string, + orgId: string, + ticketId: string, + threadId: string +): Promise { + const body = await deskGet( + `${apiBase}/tickets/${encodeURIComponent(ticketId)}/threads/${encodeURIComponent(threadId)}?include=plainText`, + accessToken, + orgId + ) + // double-cast-allowed: deskGet returns an untyped JSON record; `id` is checked above + return typeof body.id === 'string' ? (body as unknown as ZohoDeskThreadDetail) : null +} + +/** Formats one conversation entry as a labelled block of plain text. */ +function formatConversationEntry( + entry: ZohoDeskConversationEntry, + hydrated: ZohoDeskThreadDetail | null +): string { + const isComment = entry.type === 'comment' + const person = isComment ? entry.commenter : entry.author + const timestamp = entry.commentedTime || entry.createdTime || '' + const visibility = isComment + ? entry.isPublic === false + ? 'Internal' + : 'Public' + : entry.visibility || 'public' + + const body = hydrated + ? hydrated.plainText?.trim() || + toPlainText(hydrated.content, hydrated.contentType) || + hydrated.summary || + '' + : toPlainText(entry.content, entry.contentType) || entry.summary || '' + + const label = isComment ? 'Comment' : 'Thread' + const author = person?.name || person?.email || 'Unknown' + return `\n[${timestamp}] ${label} (${visibility}) — ${author}:\n${body}` +} + +/** Folds a ticket and its conversation into one plain-text document body. */ +function formatTicketContent(ticket: ZohoDeskTicket, conversation: string[]): string { + const parts: string[] = [] + + if (ticket.subject) parts.push(`Subject: ${ticket.subject}`) + if (ticket.status) parts.push(`Status: ${ticket.status}`) + if (ticket.priority) parts.push(`Priority: ${ticket.priority}`) + if (ticket.classification) parts.push(`Classification: ${ticket.classification}`) + if (ticket.category) parts.push(`Category: ${ticket.category}`) + if (ticket.channel) parts.push(`Channel: ${ticket.channel}`) + if (ticket.createdTime) parts.push(`Created: ${ticket.createdTime}`) + if (ticket.modifiedTime) parts.push(`Updated: ${ticket.modifiedTime}`) + + if (ticket.description) { + parts.push('') + parts.push('--- Description ---') + // Zoho ships no content-type discriminator for `description`, and it can be + // either HTML or plain text, so strip only when markup is actually present. + parts.push( + /<[a-z][\s\S]*>/i.test(ticket.description) + ? htmlToPlainText(ticket.description) + : ticket.description + ) + } + + // The agent's resolution note is the single most reusable answer on a ticket, + // and `GET /tickets/{id}` is the only place it is returned. + if (ticket.resolution) { + parts.push('') + parts.push('--- Resolution ---') + parts.push( + /<[a-z][\s\S]*>/i.test(ticket.resolution) + ? htmlToPlainText(ticket.resolution) + : ticket.resolution + ) + } + + if (conversation.length > 0) { + parts.push('') + parts.push('--- Conversation ---') + parts.push(...conversation) + } + + return parts.join('\n') +} + +/** + * Metadata-only change indicator for a ticket. + * + * Deliberately excludes `modifiedTime`: Zoho returns it on `GET /tickets/{id}` + * but NOT on the `GET /tickets` list projection, so mixing it in would make the + * listing stub and the hydrated document hash differently and re-index every + * ticket on every sync forever. Every field below appears on both responses. + * + * `createdTime` alone would never move, so the fields that do track activity ride + * along: the conversation counters, the workflow state, and the last customer + * response. + */ +function ticketContentHash(ticket: ZohoDeskTicket): string { + return [ + 'zoho_desk:ticket', + ticket.id, + ticket.createdTime ?? '', + ticket.threadCount ?? '', + ticket.commentCount ?? '', + ticket.status ?? '', + ticket.priority ?? '', + ticket.closedTime ?? '', + ticket.customerResponseTime ?? '', + ].join(':') +} + +/** + * Builds the deferred stub for a ticket. Content is resolved lazily in + * `getDocument` because the conversation needs one call per ticket (plus one per + * thread), which would exhaust the listing pass's time budget. + */ +function ticketToStub(ticket: ZohoDeskTicket): ExternalDocument { + const number = ticket.ticketNumber ? `#${ticket.ticketNumber}` : ticket.id + return { + externalId: `ticket-${ticket.id}`, + title: `Ticket ${number}: ${ticket.subject || 'Untitled'}`, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: ticket.webUrl, + contentHash: ticketContentHash(ticket), + metadata: { + type: 'ticket', + ticketId: ticket.id, + ticketNumber: ticket.ticketNumber, + status: ticket.status, + priority: ticket.priority, + category: ticket.category, + departmentId: ticket.departmentId, + commentCount: Number(ticket.commentCount ?? 0), + updatedAt: ticket.modifiedTime || ticket.createdTime, + createdAt: ticket.createdTime, + }, + } +} + +/** Metadata-only change indicator for an article. */ +function articleContentHash(article: ZohoDeskArticleSummary): string { + return `zoho_desk:article:${article.id}:${article.modifiedTime || article.createdTime || ''}` +} + +/** + * Builds the deferred stub for an article. The article list projection carries + * only a truncated `summary`; the full `answer` body comes from the per-article + * endpoint, so content is deferred. + */ +function articleToStub(article: ZohoDeskArticleSummary): ExternalDocument { + return { + externalId: `article-${article.id}`, + title: article.title || 'Untitled', + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: article.portalUrl || article.webUrl, + contentHash: articleContentHash(article), + metadata: { + type: 'article', + articleId: article.id, + status: article.status, + locale: article.locale, + category: article.category?.name, + categoryId: article.categoryId || article.category?.id, + tags: article.tags, + updatedAt: article.modifiedTime || article.createdTime, + createdAt: article.createdTime, + }, + } +} + +export const zohoDeskConnector: ConnectorConfig = { + ...zohoDeskConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + _cursor?: string, + syncContext?: Record + ): Promise => { + const apiBase = resolveApiBase(sourceConfig) + const orgId = requireOrgId(sourceConfig) + const contentType = (sourceConfig.contentType as string) || 'both' + + const documents: ExternalDocument[] = [] + let truncated = false + + if (contentType === 'articles' || contentType === 'both') { + const max = resolveMax(sourceConfig.maxArticles, DEFAULT_MAX_ARTICLES, 'Max articles') + const articleStatus = + typeof sourceConfig.articleStatus === 'string' + ? sourceConfig.articleStatus.trim() + : undefined + const categoryId = + typeof sourceConfig.articleCategoryId === 'string' + ? sourceConfig.articleCategoryId.trim() + : undefined + + const articles = await fetchArticles(apiBase, accessToken, orgId, { + status: articleStatus, + categoryId: categoryId || undefined, + max, + }) + logger.info(`Fetched ${articles.items.length} articles from Zoho Desk`, { orgId }) + truncated = truncated || articles.truncated + + for (const article of articles.items) { + if (article.isTrashed) continue + documents.push(articleToStub(article)) + } + } + + if (contentType === 'tickets' || contentType === 'both') { + const max = resolveMax(sourceConfig.maxTickets, DEFAULT_MAX_TICKETS, 'Max tickets') + const ticketStatus = + typeof sourceConfig.ticketStatus === 'string' ? sourceConfig.ticketStatus.trim() : undefined + + const tickets = await fetchTickets(apiBase, accessToken, orgId, { + status: ticketStatus || undefined, + departmentIds: parseMultiValue(sourceConfig.departmentIds), + max, + }) + logger.info(`Fetched ${tickets.items.length} tickets from Zoho Desk`, { orgId }) + truncated = truncated || tickets.truncated + + for (const ticket of tickets.items) { + if (ticket.isTrashed) continue + documents.push(ticketToStub(ticket)) + } + } + + // A capped listing is not the full source set. Without this flag the sync + // engine treats every document past the cap as deleted and removes it. + if (truncated && syncContext) { + syncContext.listingCapped = true + } + + return { documents, hasMore: false } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + try { + const apiBase = resolveApiBase(sourceConfig) + const orgId = requireOrgId(sourceConfig) + + if (externalId.startsWith('article-')) { + const articleId = externalId.slice('article-'.length) + const body = await deskGet( + `${apiBase}/articles/${encodeURIComponent(articleId)}`, + accessToken, + orgId + ) + if (typeof body.id !== 'string') return null + // double-cast-allowed: deskGet returns an untyped JSON record; `id` is checked above + const article = body as unknown as ZohoDeskArticleDetail + if (article.isTrashed) return null + + const content = htmlToPlainText(article.answer || '') + if (!content.trim()) return null + + return { ...articleToStub(article), content, contentDeferred: false } + } + + if (externalId.startsWith('ticket-')) { + const ticketId = externalId.slice('ticket-'.length) + const body = await deskGet( + `${apiBase}/tickets/${encodeURIComponent(ticketId)}`, + accessToken, + orgId + ) + if (typeof body.id !== 'string') return null + // double-cast-allowed: deskGet returns an untyped JSON record; `id` is checked above + const ticket = body as unknown as ZohoDeskTicket + if (ticket.isTrashed) return null + + const entries = await fetchConversations(apiBase, accessToken, orgId, ticketId) + if (entries.length >= MAX_CONVERSATION_ENTRIES) { + logger.warn('Zoho Desk ticket conversation truncated at the entry cap', { + ticketId, + cap: MAX_CONVERSATION_ENTRIES, + }) + } + const blocks: string[] = [] + let hydratedThreads = 0 + + for (const entry of entries) { + let hydrated: ZohoDeskThreadDetail | null = null + if (entry.type === 'thread' && hydratedThreads < MAX_HYDRATED_THREADS) { + try { + hydrated = await fetchThread(apiBase, accessToken, orgId, ticketId, entry.id) + hydratedThreads += 1 + } catch (error) { + logger.warn('Failed to fetch Zoho Desk thread body; using summary', { + ticketId, + threadId: entry.id, + error: getErrorMessage(error), + }) + } + } + blocks.push(formatConversationEntry(entry, hydrated)) + } + + const content = formatTicketContent(ticket, blocks) + if (!content.trim()) return null + + return { ...ticketToStub(ticket), content, contentDeferred: false } + } + + return null + } catch (error) { + logger.warn('Failed to get Zoho Desk document', { + externalId, + error: toError(error).message, + }) + return null + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const orgId = typeof sourceConfig.orgId === 'string' ? sourceConfig.orgId.trim() : '' + if (!orgId) { + return { valid: false, error: 'Organization ID is required' } + } + + const dataCenter = + typeof sourceConfig.dataCenter === 'string' ? sourceConfig.dataCenter.trim() : '' + if (!dataCenter || !(dataCenter in ZOHO_DESK_DATA_CENTER_BASES)) { + return { valid: false, error: 'A supported Zoho Desk data center is required' } + } + + const contentType = + typeof sourceConfig.contentType === 'string' ? sourceConfig.contentType.trim() : '' + if (!contentType) { + return { valid: false, error: 'Content type is required' } + } + + try { + resolveMax(sourceConfig.maxTickets, DEFAULT_MAX_TICKETS, 'Max tickets') + resolveMax(sourceConfig.maxArticles, DEFAULT_MAX_ARTICLES, 'Max articles') + } catch (error) { + return { valid: false, error: toError(error).message } + } + + try { + const apiBase = resolveApiBase(sourceConfig) + // `/organizations` is the one Desk endpoint that is not org-scoped, so it + // verifies the token and the data center, and confirms the configured + // organization is actually one this credential can reach. + const body = await deskGet( + `${apiBase}/organizations`, + accessToken, + undefined, + VALIDATE_RETRY_OPTIONS + ) + const organizations = readDataArray(body) + if (!organizations.some((organization) => String(organization.id) === orgId)) { + return { + valid: false, + error: `Organization ${orgId} is not accessible with this credential in the selected data center`, + } + } + return { valid: true } + } catch (error) { + return { valid: false, error: toError(error).message || 'Failed to validate configuration' } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + if (typeof metadata.type === 'string') result.contentType = metadata.type + if (typeof metadata.status === 'string') result.status = metadata.status + if (typeof metadata.priority === 'string') result.priority = metadata.priority + if (typeof metadata.category === 'string') result.category = metadata.category + + const tags = joinTagArray(metadata.tags) + if (tags) result.tags = tags + + const updatedAt = parseTagDate(metadata.updatedAt) + if (updatedAt) result.updatedAt = updatedAt + + if (metadata.commentCount != null) { + const commentCount = Number(metadata.commentCount) + if (!Number.isNaN(commentCount)) result.commentCount = commentCount + } + + return result + }, +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-create-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-create-modal.tsx new file mode 100644 index 00000000000..cf2666e46b6 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-create-modal.tsx @@ -0,0 +1,104 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useCreateCredentialGroup } from '@/hooks/queries/credential-groups' + +interface CredentialGroupCreateModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (groupId: string) => void + workspaceId: string +} + +export function CredentialGroupCreateModal({ + open, + onOpenChange, + onCreated, + workspaceId, +}: CredentialGroupCreateModalProps) { + const createGroup = useCreateCredentialGroup() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + + const reset = () => { + setName('') + setDescription('') + createGroup.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (createGroup.isPending) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const handleCreate = async () => { + if (!name.trim() || createGroup.isPending) return + try { + const result = await createGroup.mutateAsync({ + workspaceId, + body: { + name: name.trim(), + description: description.trim() || undefined, + options: [], + }, + }) + onCreated(result.credentialGroup.id) + handleOpenChange(false) + } catch { + return + } + } + + return ( + + handleOpenChange(false)}> + Create credential group + + + + + + {createGroup.error ? getErrorMessage(createGroup.error) : null} + + + handleOpenChange(false)} + cancelDisabled={createGroup.isPending} + primaryAction={{ + label: createGroup.isPending ? 'Creating...' : 'Create', + onClick: handleCreate, + disabled: !name.trim() || createGroup.isPending, + }} + /> + + ) +} 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 new file mode 100644 index 00000000000..e0f11afb3c8 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts @@ -0,0 +1,38 @@ +/** + * @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 new file mode 100644 index 00000000000..3bab5af6bc0 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -0,0 +1,301 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn' +import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' +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 { + credentialGroupTabParam, + credentialGroupTabUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' +import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' +import { + useCredentialGroupDetail, + useResendCredentialGroupEnrollment, + useRevokeCredentialGroupEnrollment, +} from '@/hooks/queries/credential-groups' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +interface CredentialGroupDetailProps { + workspaceId: string + groupId: string + onBack: () => void +} + +type CredentialGroupTab = 'details' | 'people' + +const CREDENTIAL_GROUP_TABS = [ + { value: 'details', label: 'Details' }, + { 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[] +} + +interface CredentialProviderIconProps { + provider: CredentialGroupProvider +} + +function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { + const ProviderIcon = getCredentialGroupProviderService(provider).icon + return +} + +function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) { + const connected = connections.filter((connection) => connection.status === 'active') + const count = connected.reduce((total, connection) => total + connection.count, 0) + const providers = [...new Set(connected.map((connection) => connection.provider))] + + return ( +
+ {providers.map((provider) => { + return + })} + + {count} connected {count === 1 ? 'account' : 'accounts'} + + + ) +} + +export function CredentialGroupDetail({ + workspaceId, + groupId, + onBack, +}: CredentialGroupDetailProps) { + const detail = useCredentialGroupDetail(workspaceId, groupId) + const slackBots = useWorkspaceCredentials({ + workspaceId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + }) + const resend = useResendCredentialGroupEnrollment() + const revoke = useRevokeCredentialGroupEnrollment() + const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { + ...credentialGroupTabParam.parser, + ...credentialGroupTabUrlKeys, + }) + const [showInvite, setShowInvite] = useState(false) + const [revokingEnrollmentId, setRevokingEnrollmentId] = 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) + : null + const activeProviders = + credentialGroup?.options + .filter((option) => option.status === 'active') + .map((option) => option.provider) ?? [] + const configurationReady = + Boolean(credentialGroup?.options.length) && + credentialGroup?.options.every( + (option) => + option.provider !== 'slack' || + (option.configurationStatus === 'ready' && + slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) + ) + + const actions: SettingsAction[] = credentialGroup + ? [ + { + text: 'Invite users', + icon: Plus, + variant: 'primary', + onSelect: () => setShowInvite(true), + disabled: credentialGroup.status !== 'active' || !configurationReady, + }, + ] + : [] + + const handleResend = async (enrollment: CredentialGroupEnrollment) => { + try { + await resend.mutateAsync({ workspaceId, groupId, enrollmentId: enrollment.id }) + toast.success(`Invitation resent to ${enrollment.email}`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to resend invitation')) + } + } + + const handleRevoke = async () => { + if (!revokingEnrollment) return + try { + await revoke.mutateAsync({ + workspaceId, + groupId, + enrollmentId: revokingEnrollment.id, + }) + toast.success(`Invitation revoked for ${revokingEnrollment.email}`) + setRevokingEnrollmentId(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to revoke invitation')) + } + } + + const handleBack = () => { + void setActiveTab(null, { history: 'replace' }) + onBack() + } + + return ( + <> + + {detail.error ? ( + + {getErrorMessage(detail.error, "Couldn't load credential group")} + + ) : detail.isPending || !credentialGroup ? null : ( +
+ void setActiveTab(value as CredentialGroupTab)} + aria-label='Credential group sections' + /> + + {activeTab === 'details' && ( + + )} + + {activeTab === 'people' && ( + void detail.fetchNextPage()} + disabled={detail.isFetchingNextPage} + > + {detail.isFetchingNextPage ? 'Loading...' : 'Load more'} + + ) : undefined + } + > + {enrollments.length === 0 ? ( + No people invited yet + ) : ( +
+ {enrollments.map((enrollment) => { + const status = getEnrollmentStatus(enrollment, activeProviders) + return ( + } + 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), + }, + ]} + /> + ) + } + /> + ) + })} +
+ )} +
+ )} +
+ )} +
+ {credentialGroup && ( + + )} + !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.`} + dismissLabel='Cancel' + confirm={{ + label: revoke.isPending ? 'Revoking...' : 'Revoke', + onClick: handleRevoke, + disabled: revoke.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 new file mode 100644 index 00000000000..501f0a7b366 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { + CredentialGroup, + CredentialGroupOption, + UpdateCredentialGroupBody, +} from '@/lib/api/contracts/credential-groups' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + type CredentialGroupProvider, + type CredentialGroupStandardOAuthProvider, + getCredentialGroupProviderService, + getCredentialGroupProviderSupport, + isCredentialGroupStandardOAuthProvider, +} from '@/lib/credential-groups/providers' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { SettingRow } from '@/ee/components/setting-row' +import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' +import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +interface CredentialGroupDetailsProps { + credentialGroup: CredentialGroup + workspaceId: string +} + +function toOptionUpdateInput( + option: CredentialGroupOption +): NonNullable[number] { + const common = { + id: option.id, + label: getCredentialGroupProviderService(option.provider).name, + required: true, + } + if (option.provider !== 'slack') return { ...common, provider: option.provider } + return { + ...common, + provider: 'slack', + slackBotCredentialId: option.slackBotCredentialId, + } +} + +export function CredentialGroupDetails({ + credentialGroup, + workspaceId, +}: CredentialGroupDetailsProps) { + const updateGroup = useUpdateCredentialGroup() + const slackBots = useWorkspaceCredentials({ + workspaceId, + 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 [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 ( + options: NonNullable, + successMessage: string + ) => { + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { options }, + }) + toast.success(successMessage) + return true + } catch (error) { + toast.error(getErrorMessage(error, 'Could not update account collection')) + return false + } + } + + const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => { + const service = getCredentialGroupProviderService(provider) + const existing = credentialGroup.options.map(toOptionUpdateInput) + const nextOption: NonNullable[number] = { + provider, + label: service.name, + required: true, + } + return updateOptions([...existing, nextOption], `${service.name} added`) + } + + const openSlackSetup = (credentialId?: string) => { + setSlackSetupCredentialId(credentialId) + setSlackSetupOpen(true) + } + + const handleProviderAction = (provider: CredentialGroupProvider) => { + const support = getCredentialGroupProviderSupport(provider) + if (isCredentialGroupStandardOAuthProvider(provider)) { + void addProvider(provider) + return + } + if (support.configuration === 'slack_custom_bot') { + openSlackSetup() + return + } + 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) + const options = credentialGroup.options + .filter((option) => option.provider !== removingProvider) + .map(toOptionUpdateInput) + if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null) + } + + 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} + /> + +
+
+ + +
+ {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'} + + ) + } + /> + ) + })} +
+
+
+ + { + setSlackSetupOpen(nextOpen) + if (!nextOpen) setSlackSetupCredentialId(undefined) + }} + bots={slackBots.data ?? []} + isLoading={slackBots.isPending} + error={slackBots.error} + initialCredentialId={slackSetupCredentialId} + /> + + !open && !isUpdating && setRemovingProvider(null)} + srTitle='Remove account type' + title={`Remove ${ + removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account' + }`} + text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.' + dismissLabel='Cancel' + confirm={{ + label: isUpdating ? 'Removing...' : 'Remove', + onClick: handleRemoveProvider, + disabled: isUpdating, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx new file mode 100644 index 00000000000..17d0bdf0682 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useCallback, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { useInviteCredentialGroupEnrollments } from '@/hooks/queries/credential-groups' + +interface CredentialGroupInviteModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + groupId: string +} + +export function CredentialGroupInviteModal({ + open, + onOpenChange, + workspaceId, + groupId, +}: CredentialGroupInviteModalProps) { + const invite = useInviteCredentialGroupEnrollments() + const [emails, setEmails] = useState([]) + const [deliveryError, setDeliveryError] = useState(null) + const canSubmit = emails.length > 0 && !invite.isPending + + const validateEmail = useCallback((email: string): string | null => { + const result = quickValidateEmail(email) + return result.isValid ? null : (result.reason ?? 'Invalid email') + }, []) + + const handleEmailsChange = useCallback((next: string[]) => { + setEmails(next) + setDeliveryError(null) + }, []) + + const handleOpenChange = (nextOpen: boolean) => { + if (invite.isPending) return + onOpenChange(nextOpen) + if (!nextOpen) { + setEmails([]) + setDeliveryError(null) + invite.reset() + } + } + + const handleSubmit = async () => { + if (!canSubmit) return + setDeliveryError(null) + try { + const result = await invite.mutateAsync({ + workspaceId, + groupId, + body: { emails }, + }) + const failures = result.results.filter((item) => !item.success) + if (failures.length === 0) { + toast.success( + result.sentCount === 1 ? 'Invitation sent' : `${result.sentCount} invitations sent` + ) + handleOpenChange(false) + return + } + + setEmails(failures.map((item) => item.email)) + setDeliveryError( + result.sentCount > 0 + ? `${result.sentCount} sent. ${failures.length} failed: ${failures.map((item) => item.email).join(', ')}` + : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` + ) + } catch (error) { + setDeliveryError(getErrorMessage(error, 'Failed to send invitations')) + } + } + + return ( + + handleOpenChange(false)}>Invite users + + + + {deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)} + + + handleOpenChange(false)} + cancelDisabled={invite.isPending} + primaryAction={{ + label: invite.isPending ? 'Sending...' : 'Send invites', + onClick: handleSubmit, + disabled: !canSubmit, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx new file mode 100644 index 00000000000..a8ea91b9482 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx @@ -0,0 +1,161 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipTag } from '@sim/emcn' +import { GridOffset, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' +import { + credentialGroupIdParam, + credentialGroupIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { CredentialGroupCreateModal } from '@/ee/credential-groups/components/credential-group-create-modal' +import { CredentialGroupDetail } from '@/ee/credential-groups/components/credential-group-detail' +import { useCredentialGroups, useDeleteCredentialGroup } from '@/hooks/queries/credential-groups' + +interface CredentialGroupsSettingsProps { + workspaceId: string +} + +export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) { + const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId) + const deleteGroup = useDeleteCredentialGroup() + const [search, setSearch] = useSettingsSearch() + const [showCreate, setShowCreate] = useState(false) + const [deletingGroupId, setDeletingGroupId] = useState(null) + const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { + ...credentialGroupIdParam.parser, + ...credentialGroupIdUrlKeys, + }) + const deletingGroup = groups.find((group) => group.id === deletingGroupId) + const selectedGroup = selectedGroupId + ? groups.find((group) => group.id === selectedGroupId) + : undefined + + const query = search.trim().toLowerCase() + const filtered = query + ? groups.filter((group) => + [group.name, group.description ?? '', ...group.options.map((option) => option.label)].some( + (value) => value.toLowerCase().includes(query) + ) + ) + : groups + + const actions: SettingsAction[] = [ + { + text: 'Create group', + icon: Plus, + variant: 'primary', + onSelect: () => setShowCreate(true), + }, + ] + + const handleDelete = async () => { + if (!deletingGroupId) return + try { + await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) + setDeletingGroupId(null) + } catch { + return + } + } + + if (selectedGroup) { + return ( + void setSelectedGroupId(null, { history: 'replace' })} + /> + ) + } + + return ( + <> + + {error ? ( + + {getErrorMessage(error, "Couldn't load credential groups")} + + ) : isPending ? null : groups.length === 0 ? ( + Click "Create group" above to get started + ) : filtered.length === 0 ? ( + No groups match "{search}" + ) : ( +
+ {filtered.map((group) => { + const optionCount = group.options.length + return ( + } + title={group.name} + description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`} + onClick={() => void setSelectedGroupId(group.id)} + clickLabel={`Open ${group.name}`} + navigable + badge={ + group.status === 'disabled' ? ( + Disabled + ) : undefined + } + trailing={ + setDeletingGroupId(group.id), + }, + ]} + /> + } + /> + ) + })} +
+ )} +
+ void setSelectedGroupId(groupId)} + 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/index.ts b/apps/sim/ee/credential-groups/components/index.ts new file mode 100644 index 00000000000..d8b5132e3d7 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/index.ts @@ -0,0 +1 @@ +export { CredentialGroupsSettings } from '@/ee/credential-groups/components/credential-groups-settings' diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.test.ts b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.test.ts new file mode 100644 index 00000000000..5ad9c4a5217 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getSlackManagedUsersFailureNotification } from '@/ee/credential-groups/components/slack-managed-users-modal' + +describe('Slack managed-user authorization notifications', () => { + it('treats provider cancellation as a non-error outcome', () => { + expect(getSlackManagedUsersFailureNotification('provider_error')).toEqual({ + message: 'Slack authorization canceled', + variant: 'warning', + }) + }) + + it('keeps verification failures in the error state', () => { + expect(getSlackManagedUsersFailureNotification('invalid_response')).toEqual({ + message: 'Slack app verification failed. Please try again.', + variant: 'error', + }) + }) +}) 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 new file mode 100644 index 00000000000..a6bdef02c80 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -0,0 +1,314 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + Skeleton, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryClient } from '@tanstack/react-query' +import { SlackIcon } from '@/components/icons' +import type { WorkspaceCredential } from '@/lib/api/contracts' +import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups' +import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' + +const CHANNEL_NAME = 'slack-managed-users' +const AUTHORIZATION_TIMEOUT_MS = 10 * 60 * 1000 + +interface SlackManagedUsersModalProps { + bots: WorkspaceCredential[] + credentialGroupId: string + error: Error | null + initialCredentialId?: string + isLoading: boolean + onOpenChange: (open: boolean) => void + open: boolean + workspaceId: string +} + +interface SlackManagedUsersMessage { + type: typeof CHANNEL_NAME + ok: boolean + reason?: string + state?: string + credentialGroupId?: string + slackBotCredentialId?: string +} + +export function getSlackManagedUsersFailureNotification(reason?: string): { + message: string + variant: 'error' | 'warning' +} { + return reason === 'provider_error' + ? { message: 'Slack authorization canceled', variant: 'warning' } + : { message: 'Slack app verification failed. Please try again.', variant: 'error' } +} + +function isSlackManagedUsersMessage(value: unknown): value is SlackManagedUsersMessage { + if (!value || typeof value !== 'object') return false + const message = value as Record + return ( + message.type === CHANNEL_NAME && + typeof message.ok === 'boolean' && + (message.reason === undefined || typeof message.reason === 'string') && + (message.state === undefined || typeof message.state === 'string') && + (message.credentialGroupId === undefined || typeof message.credentialGroupId === 'string') && + (message.slackBotCredentialId === undefined || typeof message.slackBotCredentialId === 'string') + ) +} + +export function SlackManagedUsersModal({ + bots, + credentialGroupId, + error, + initialCredentialId, + isLoading, + onOpenChange, + open, + workspaceId, +}: SlackManagedUsersModalProps) { + const queryClient = useQueryClient() + const startAuthorization = useStartSlackCredentialGroupConfiguration() + const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [pending, setPending] = useState(false) + const expectedState = useRef(null) + const expectedCredentialId = useRef(null) + const popup = useRef(null) + const popupWatcher = useRef(null) + + const defaultCredentialId = initialCredentialId + ? bots.some((bot) => bot.id === initialCredentialId) + ? initialCredentialId + : '' + : bots.length === 1 + ? bots[0].id + : '' + const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId + const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) + + 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() + } + return () => channel.close() + }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) + + useEffect( + () => () => { + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popup.current?.close() + }, + [] + ) + + 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) + if (!nextOpen) reset() + } + + const handleSelectBot = (credentialId: string) => { + if (pending) return + setSelectedCredentialId(credentialId) + setClientId('') + setClientSecret('') + startAuthorization.reset() + } + + const handleSubmit = async () => { + if (!selectedBot || pending) return + if (!clientId.trim() || !clientSecret.trim()) return + + const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760') + if (!opened) { + toast.error('Allow popups to verify the Slack app') + return + } + popup.current = opened + setPending(true) + try { + const result = await startAuthorization.mutateAsync({ + workspaceId, + credentialGroupId, + body: { + slackBotCredentialId: selectedBot.id, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }, + }) + expectedState.current = result.state + expectedCredentialId.current = selectedBot.id + opened.location.href = result.authorizationUrl + const startedAt = Date.now() + popupWatcher.current = window.setInterval(() => { + if (!opened.closed && Date.now() - startedAt < AUTHORIZATION_TIMEOUT_MS) return + window.clearInterval(popupWatcher.current ?? undefined) + popupWatcher.current = null + opened.close() + popup.current = null + expectedState.current = null + expectedCredentialId.current = null + setPending(false) + toast.error('Slack authorization expired. Please try again.') + }, 500) + } catch (authorizationError) { + opened.close() + popup.current = null + setPending(false) + toast.error(getErrorMessage(authorizationError, 'Could not start Slack authorization')) + } + } + + const noBots = !isLoading && bots.length === 0 + const primaryLabel = isLoading + ? 'Loading...' + : noBots + ? 'Add Slack' + : pending + ? 'Waiting for Slack...' + : 'Verify and add' + const primaryDisabled = + isLoading || noBots || !selectedBot || pending || !clientId.trim() || !clientSecret.trim() + + return ( + + handleOpenChange(false)} + closeDisabled={pending} + > + Set up Slack + + + {isLoading ? ( +
+ + +
+ ) : noBots ? ( +

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

+ ) : ( + <> + ({ + value: bot.id, + label: bot.displayName, + icon: SlackIcon, + }))} + placeholder='Select a custom bot' + disabled={pending} + required + /> + {selectedBot ? ( + <> + + + + ) : null} + + )} + {error ? getErrorMessage(error) : null} +
+ handleOpenChange(false)} + cancelDisabled={pending} + primaryAction={{ + label: primaryLabel, + onClick: () => void handleSubmit(), + disabled: primaryDisabled, + }} + /> +
+ ) +} diff --git a/apps/sim/ee/workspace-forking/lib/background-work/store.ts b/apps/sim/ee/workspace-forking/lib/background-work/store.ts index facb4431fbd..bc9eb612f2d 100644 --- a/apps/sim/ee/workspace-forking/lib/background-work/store.ts +++ b/apps/sim/ee/workspace-forking/lib/background-work/store.ts @@ -1,6 +1,7 @@ import { backgroundWorkStatus, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { and, desc, @@ -192,9 +193,7 @@ export async function finishBackgroundWork( /** Coerce an unknown jsonb metadata value to a plain record for safe merging. */ function toMetadataRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} + return isRecordLike(value) ? (value as Record) : {} } /** diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts index a33abf5d12b..a28aed94fe8 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts @@ -9,8 +9,9 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { and, asc, eq, exists, gt, inArray, isNull, notExists, sql } from 'drizzle-orm' -import { isRecord, type SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils' import { FORK_DOCUMENT_ID_PATTERN, @@ -325,13 +326,13 @@ export function rewriteDeploymentVersionState( state: unknown, resolve: ForkCopyResolver ): { state: unknown; changed: boolean } { - if (!isRecord(state) || !isRecord(state.blocks)) return { state, changed: false } + if (!isRecordLike(state) || !isRecordLike(state.blocks)) return { state, changed: false } let nextBlocks: Record | null = null for (const [blockId, block] of Object.entries(state.blocks)) { - if (!isRecord(block)) continue + if (!isRecordLike(block)) continue const blockType = typeof block.type === 'string' ? block.type : undefined - if (!blockType || !isRecord(block.subBlocks)) continue + if (!blockType || !isRecordLike(block.subBlocks)) continue const { subBlocks: cleared, changed } = clearFailedSubBlockReferences( block.subBlocks as SubBlockRecord, blockType, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts index bf4eeebe9f4..312f765c641 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts @@ -1,10 +1,10 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { randomInt } from '@sim/utils/random' import { and, inArray, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids' const logger = createLogger('WorkspaceForkCopyChats') @@ -47,7 +47,7 @@ function remapChatOutputConfigs( ): unknown { if (!Array.isArray(value)) return value return value.map((entry) => { - if (!isRecord(entry) || typeof entry.blockId !== 'string') return entry + if (!isRecordLike(entry) || typeof entry.blockId !== 'string') return entry return { ...entry, blockId: resolveBlockId(targetWorkflowId, entry.blockId) } }) } 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 94f9d9402d9..991b1fd7a1d 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -20,7 +20,7 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { omit } from '@sim/utils/object' +import { isRecordLike, omit } from '@sim/utils/object' import { and, asc, @@ -71,7 +71,6 @@ import { recordKnowledgeBaseFileOwnership, } from '@/lib/uploads/server/metadata' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' -import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids' import { deleteCopiedResourceMappingsByTargets, type ForkMappingUpsert, @@ -538,7 +537,7 @@ export async function copyForkResourceContainers( const inserts: (typeof mcpServers.$inferInsert)[] = [] for (const row of rows) { const childId = generateId() - const headers = isRecord(row.headers) + const headers = isRecordLike(row.headers) ? Object.fromEntries( Object.entries(row.headers).map(([key, value]) => [ key, @@ -942,7 +941,7 @@ function remapTableRowResourceUrls(value: unknown, maps: ForkContentRefMaps): un }) return changed ? next : value } - if (isRecord(value)) { + if (isRecordLike(value)) { let changed = false const next: Record = {} for (const [key, item] of Object.entries(value)) { 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 3d5cbb2bf71..cc8da0e3600 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -1,6 +1,7 @@ import { folder as folderTable, workflow, workflowBlocks } from '@sim/db/schema' 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 { DbOrTx } from '@/lib/db/types' import { assertFolderCollectionHasRoom } from '@/lib/folders/queries' @@ -447,7 +448,7 @@ export async function copyWorkflowStateIntoTarget( const newBlockId = blockIdMapping.get(oldBlockId)! let updatedData = block.data - if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) { + if (isRecordLike(block.data)) { const dataObj = block.data as Record if (typeof dataObj.parentId === 'string' && blockIdMapping.has(dataObj.parentId)) { updatedData = { 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 f5684cd9b26..4a4bbbf2a5e 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts @@ -1,5 +1,6 @@ +import { isRecordLike } from '@sim/utils/object' import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork' -import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-ids' import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies' import { buildSelectorContextFromBlock, @@ -293,10 +294,10 @@ export function collectForkDependentReconfigs( if (!tools) continue for (let index = 0; index < tools.length; index++) { const tool = tools[index] - if (!isRecord(tool) || typeof tool.type !== 'string') continue + if (!isRecordLike(tool) || typeof tool.type !== 'string') continue const toolConfig = getBlock(tool.type) if (!toolConfig) continue - const toolParams = isRecord(tool.params) ? tool.params : {} + const toolParams = isRecordLike(tool.params) ? tool.params : {} // A tool's `operation` is stored at the tool level, not in params, but subblock // conditions reference it (e.g. a Gmail label only under `read_gmail`). Merge it // in so condition-gating matches the editor's `{ operation, ...params }`. diff --git a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts index 540c2e10e84..a3776dc2ccb 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts @@ -1,4 +1,5 @@ import { mcpServers, workflow } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' import { and, eq, inArray } from 'drizzle-orm' import type { ForkClearedRef, @@ -8,7 +9,6 @@ import type { import type { DbOrTx } from '@/lib/db/types' import { coerceObjectArray, - isRecord, type SubBlockRecord, } from '@/lib/workflows/persistence/remap-internal-ids' import { @@ -136,9 +136,9 @@ function collectForkWorkflowReferences( if (!array) continue for (const tool of array) { if ( - isRecord(tool) && + isRecordLike(tool) && tool.type === 'workflow_input' && - isRecord(tool.params) && + isRecordLike(tool.params) && typeof tool.params.workflowId === 'string' && tool.params.workflowId ) { 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 9dfb419f3ac..f422496a4be 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -1,13 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' +import { isRecordLike, omit } from '@sim/utils/object' import type { SubBlockType } from '@sim/workflow-types/blocks' import type { z } from 'zod' import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork' import { createMcpToolId } from '@/lib/mcp/shared' import { coerceObjectArray, - isRecord, type SubBlockRecord, } from '@/lib/workflows/persistence/remap-internal-ids' import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils' @@ -345,7 +344,7 @@ function remapEnvInValue( } // Recurse plain objects so `{{ENV}}` nested in array-form tool params (and other // object-valued subblocks) is rewritten, not just top-level strings/arrays. - if (isRecord(value)) { + if (isRecordLike(value)) { let changed = false const next: Record = {} for (const [key, nested] of Object.entries(value)) { @@ -398,7 +397,7 @@ export function remapToolBlockResources( ): Record { if (typeof tool.type !== 'string') return tool const params = tool.params - if (!isRecord(params)) return tool + if (!isRecordLike(params)) return tool let nextParams: Record | null = null const setParam = (paramId: string, value: unknown) => { @@ -678,7 +677,7 @@ function remapForkToolInputValue( next.push(nextTool) } - if (!isRecord(tool) || typeof tool.type !== 'string') { + if (!isRecordLike(tool) || typeof tool.type !== 'string') { keep(tool) return } @@ -701,7 +700,11 @@ function remapForkToolInputValue( keep(tool) return } - if (tool.type === 'mcp' && isRecord(tool.params) && typeof tool.params.serverId === 'string') { + if ( + tool.type === 'mcp' && + isRecordLike(tool.params) && + typeof tool.params.serverId === 'string' + ) { const serverId = tool.params.serverId const target = resolve('mcp-server', serverId) opts.record?.('mcp-server', serverId, target != null) @@ -774,7 +777,7 @@ function remapForkSkillInputValue( if (!array) return value let changed = false const next = array.flatMap((entry) => { - if (!isRecord(entry) || typeof entry.skillId !== 'string') return [entry] + if (!isRecordLike(entry) || typeof entry.skillId !== 'string') return [entry] if (entry.skillId.startsWith('builtin-')) return [entry] const target = resolve('skill', entry.skillId) opts.record?.('skill', entry.skillId, target != null) @@ -1221,12 +1224,12 @@ function collectClearedToolParamDependents( for (let index = 0; index < mergedTools.length; index++) { const tool = mergedTools[index] const targetTool = targetTools[index] - if (!isRecord(tool) || typeof tool.type !== 'string') continue - if (!isRecord(targetTool) || targetTool.type !== tool.type) continue + if (!isRecordLike(tool) || typeof tool.type !== 'string') continue + if (!isRecordLike(targetTool) || targetTool.type !== tool.type) continue const toolConfig = getBlock(tool.type) if (!toolConfig) continue - const targetParams = isRecord(targetTool.params) ? targetTool.params : {} - const mergedParams = isRecord(tool.params) ? tool.params : {} + const targetParams = isRecordLike(targetTool.params) ? targetTool.params : {} + const mergedParams = isRecordLike(tool.params) ? tool.params : {} // A tool's `operation` lives at the tool level, not in params, but conditions // reference it - merge it in so condition/required gating matches the editor. const mergedValues = @@ -1364,10 +1367,10 @@ export function readTargetDraftDependentValue( if (nested) { const { toolInputId, index, paramId } = nested const targetTool = coerceObjectArray(targetDraftSubBlocks[toolInputId]?.value).array?.[index] - if (!isRecord(targetTool) || typeof targetTool.type !== 'string') return '' + if (!isRecordLike(targetTool) || typeof targetTool.type !== 'string') return '' const sourceTool = coerceObjectArray(sourceSubBlocks?.[toolInputId]?.value).array?.[index] - if (!isRecord(sourceTool) || sourceTool.type !== targetTool.type) return '' - const params = isRecord(targetTool.params) ? targetTool.params : {} + if (!isRecordLike(sourceTool) || sourceTool.type !== targetTool.type) return '' + const params = isRecordLike(targetTool.params) ? targetTool.params : {} const value = params[paramId] return typeof value === 'string' ? value : '' } @@ -1395,7 +1398,7 @@ function applyNestedToolOverrides( const merged = array.map((tool, index) => { const forTool = items.filter((item) => item.index === index) if (forTool.length === 0) return tool - if (!isRecord(tool) || typeof tool.type !== 'string') return tool + if (!isRecordLike(tool) || typeof tool.type !== 'string') return tool const toolConfig = getBlock(tool.type) if (!toolConfig) return tool const allowed = new Set( @@ -1403,7 +1406,7 @@ function applyNestedToolOverrides( .filter((cfg) => cfg.id && cfg.dependsOn && cfg.selectorKey) .map((cfg) => cfg.id) ) - const params = isRecord(tool.params) ? tool.params : {} + const params = isRecordLike(tool.params) ? tool.params : {} let nextParams: Record | null = null for (const item of forTool) { if (!allowed.has(item.paramId)) continue diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 97194928e0b..77482255cff 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -41,6 +41,7 @@ export enum BlockType { WORKFLOW_INPUT = 'workflow_input', CREDENTIAL = 'credential', + CREDENTIAL_GROUP = 'credential_group', WAIT = 'wait', @@ -169,12 +170,6 @@ export const LOOP_REFERENCE = { INDEX_PATH: 'loop.index', } as const -export const PARALLEL_REFERENCE = { - INDEX: 'index', - CURRENT_ITEM: 'currentItem', - ITEMS: 'items', -} as const - export const DEFAULTS = { BLOCK_TYPE: 'unknown', BLOCK_TITLE: 'Untitled Block', @@ -277,12 +272,6 @@ export function buildResumeUiUrl( return `${prefix}${PAUSE_RESUME.PATH.UI_RESUME}/${workflowId}/${executionId}` } -export const PARSING = { - JSON_RADIX: 10, - PREVIEW_LENGTH: 200, - PREVIEW_SUFFIX: '...', -} as const - export type FieldType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' | 'plain' interface ConditionConfig { @@ -349,26 +338,6 @@ export function isAnnotationOnlyBlock(blockType: string | undefined): boolean { return blockType === BlockType.NOTE } -export function supportsHandles(blockType: string | undefined): boolean { - return !isAnnotationOnlyBlock(blockType) -} - -export function getDefaultTokens() { - return { - input: DEFAULTS.TOKENS.PROMPT, - output: DEFAULTS.TOKENS.COMPLETION, - total: DEFAULTS.TOKENS.TOTAL, - } -} - -export function getDefaultCost() { - return { - input: DEFAULTS.COST.INPUT, - output: DEFAULTS.COST.OUTPUT, - total: DEFAULTS.COST.TOTAL, - } -} - export function buildReference(path: string): string { return `${REFERENCE.START}${path}${REFERENCE.END}` } @@ -377,26 +346,10 @@ export function buildLoopReference(property: string): string { return buildReference(`${REFERENCE.PREFIX.LOOP}${REFERENCE.PATH_DELIMITER}${property}`) } -export function buildParallelReference(property: string): string { - return buildReference(`${REFERENCE.PREFIX.PARALLEL}${REFERENCE.PATH_DELIMITER}${property}`) -} - -export function buildVariableReference(variableName: string): string { - return buildReference(`${REFERENCE.PREFIX.VARIABLE}${REFERENCE.PATH_DELIMITER}${variableName}`) -} - -export function buildBlockReference(blockId: string, path?: string): string { - return buildReference(path ? `${blockId}${REFERENCE.PATH_DELIMITER}${path}` : blockId) -} - export function buildLoopIndexCondition(maxIterations: number): string { return `${buildLoopReference(LOOP_REFERENCE.INDEX)} < ${maxIterations}` } -export function buildEnvVarReference(varName: string): string { - return `${REFERENCE.ENV_VAR_START}${varName}${REFERENCE.ENV_VAR_END}` -} - export function isReference(value: string): boolean { return value.startsWith(REFERENCE.START) && value.endsWith(REFERENCE.END) } @@ -465,10 +418,6 @@ export function stripCustomToolPrefix(name: string): string { : name } -export function stripMcpToolPrefix(name: string): string { - return name.startsWith(MCP.TOOL_PREFIX) ? name.slice(MCP.TOOL_PREFIX.length) : name -} - export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 58458d14c90..2ffc212f49b 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1,5 +1,6 @@ import { createLogger, type Logger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' +import { isRecordLike } from '@sim/utils/object' import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types' import { redactApiKeys } from '@/lib/core/security/redaction' import { normalizeStringArray } from '@/lib/core/utils/arrays' @@ -852,7 +853,7 @@ export class BlockExecutor { } })() : mapping - inputs = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + inputs = isRecordLike(parsed) ? parsed : {} } const result: Record = {} diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts new file mode 100644 index 00000000000..6e3b5281454 --- /dev/null +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -0,0 +1,189 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockType } from '@/executor/constants' +import type { ExecutionContext } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + buildHeaders: vi.fn(), + enforceInviteRateLimit: vi.fn(), + listCredentials: vi.fn(), + listGroups: vi.fn(), + listPeople: vi.fn(), + sendInvite: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/delegation', () => ({ + authenticateCredentialGroupDelegation: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/list-credentials', () => ({ + listCredentialGroupCredentials: { execute: mocks.listCredentials }, +})) + +vi.mock('@/lib/credential-groups/application/list-groups', () => ({ + listCredentialGroupsForWorkflow: { execute: mocks.listGroups }, +})) + +vi.mock('@/lib/credential-groups/application/list-people', () => ({ + CREDENTIAL_GROUP_PEOPLE_STATUSES: [ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', + ], + listCredentialGroupPeople: { execute: mocks.listPeople }, +})) + +vi.mock('@/lib/credential-groups/application/send-invite', () => ({ + sendCredentialGroupInvite: { execute: mocks.sendInvite }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit, +})) + +vi.mock('@/executor/utils/http', () => ({ + buildExecutorDelegationHeaders: mocks.buildHeaders, +})) + +import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' + +const principal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credential-groups', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, +} + +const context = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + userId: 'user-1', +} as ExecutionContext + +const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBlock + +describe('CredentialGroupBlockHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' }) + mocks.authenticate.mockResolvedValue(principal) + }) + + it('recognizes only Credential Group blocks', () => { + const handler = new CredentialGroupBlockHandler() + + expect(handler.canHandle(block)).toBe(true) + expect(handler.canHandle({ metadata: { id: BlockType.CREDENTIAL } } as SerializedBlock)).toBe( + false + ) + }) + + it('lists credentials with normalized provider, email, and page filters', async () => { + mocks.listCredentials.mockResolvedValue({ + credentials: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + const result = await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_credentials', + credentialGroupId: ' group-1 ', + credentialProviderIds: '["google-email", "google-email"]', + email: ' person@example.com ', + limit: '25', + cursor: ' credential-1 ', + }) + + expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1') + expect(mocks.listCredentials).toHaveBeenCalledWith({ + principal, + input: { + credentialGroupId: 'group-1', + credentialProviderIds: ['google-email'], + email: 'person@example.com', + limit: 25, + cursor: 'credential-1', + }, + }) + expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null }) + }) + + it('lists groups under workspace-scoped delegation', async () => { + mocks.listGroups.mockResolvedValue({ + credentialGroups: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_groups', + limit: 10, + }) + + expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined) + expect(mocks.listGroups).toHaveBeenCalledWith({ + principal, + input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined }, + }) + }) + + it('applies the shared workspace invitation budget before sending', async () => { + mocks.sendInvite.mockResolvedValue({ + enrollment: { + id: 'enrollment-1', + email: 'person@example.com', + status: 'invited', + invitedAt: '2026-08-13T12:00:00.000Z', + expiresAt: '2026-08-20T12:00:00.000Z', + }, + }) + + await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'send_invite', + credentialGroupId: 'group-1', + email: ' person@example.com ', + }) + + expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1') + expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendInvite.mock.invocationCallOrder[0]! + ) + expect(mocks.sendInvite).toHaveBeenCalledWith({ + principal, + input: { credentialGroupId: 'group-1', email: 'person@example.com' }, + }) + }) + + it('fails fast on unsupported people statuses', async () => { + await expect( + new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_people', + credentialGroupId: 'group-1', + peopleStatuses: ['unknown'], + }) + ).rejects.toThrow('People statuses contain an unsupported value') + expect(mocks.listPeople).not.toHaveBeenCalled() + }) + + it('rejects unsupported operations before delegation', async () => { + await expect( + new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' }) + ).rejects.toThrow('Unsupported Credential Group operation: unknown') + expect(mocks.buildHeaders).not.toHaveBeenCalled() + expect(mocks.authenticate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts new file mode 100644 index 00000000000..d038451e22e --- /dev/null +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -0,0 +1,207 @@ +import { createLogger } from '@sim/logger' +import { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation' +import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' +import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups' +import { + CREDENTIAL_GROUP_PEOPLE_STATUSES, + listCredentialGroupPeople, +} from '@/lib/credential-groups/application/list-people' +import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/send-invite' +import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials' +import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments' +import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit' +import type { BlockOutput } from '@/blocks/types' +import { BlockType } from '@/executor/constants' +import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types' +import { buildExecutorDelegationHeaders } from '@/executor/utils/http' +import type { SerializedBlock } from '@/serializer/types' + +const logger = createLogger('CredentialGroupBlockHandler') + +const CREDENTIAL_GROUP_OPERATION_IDS = [ + 'list_credentials', + 'send_invite', + 'list_people', + 'list_groups', +] as const + +type CredentialGroupOperation = (typeof CREDENTIAL_GROUP_OPERATION_IDS)[number] + +function parseOperation(value: unknown): CredentialGroupOperation { + const operation = typeof value === 'string' ? value : 'list_credentials' + const supported = CREDENTIAL_GROUP_OPERATION_IDS.find((candidate) => candidate === operation) + if (!supported) throw new Error(`Unsupported Credential Group operation: ${operation}`) + return supported +} + +function parseStringList(value: unknown, label: string): string[] | undefined { + if (value === undefined || value === null || value === '') return undefined + + let parsed: unknown = value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return undefined + if (!trimmed.startsWith('[')) return [trimmed] + try { + parsed = JSON.parse(trimmed) + } catch { + throw new Error(`${label} must be a valid JSON array of strings`) + } + } + + if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string' && item.trim())) { + throw new Error(`${label} must be an array of non-empty strings`) + } + + const values = [...new Set(parsed.map((item) => item.trim()))] + return values.length > 0 ? values : undefined +} + +function parseLimit(value: unknown): number { + const raw = value ?? MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE + const limit = + typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE) { + throw new Error( + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}` + ) + } + return limit +} + +function parseOptionalString(value: unknown, label: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string' || !value.trim()) + throw new Error(`${label} must be a non-empty string`) + return value.trim() +} + +function requireString(value: unknown, label: string): string { + const parsed = parseOptionalString(value, label) + if (!parsed) throw new Error(`${label} is required`) + return parsed +} + +function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin { + const origin = + ctx.executorDelegationOrigin ?? + (ctx.userId + ? { + subjectUserId: ctx.userId, + workflowId: ctx.workflowId, + ...(ctx.executionId ? { executionId: ctx.executionId } : {}), + } + : undefined) + if (!origin) { + throw new Error('Credential Group operations require an authenticated workflow execution') + } + return origin +} + +export class CredentialGroupBlockHandler implements BlockHandler { + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === BlockType.CREDENTIAL_GROUP + } + + async execute( + ctx: ExecutionContext, + _block: SerializedBlock, + inputs: Record + ): Promise { + if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations') + const operation = parseOperation(inputs.operation) + const credentialGroupId = + operation === 'list_groups' + ? undefined + : requireString(inputs.credentialGroupId, 'Credential Group') + const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx)) + const authorization = headers.Authorization + if (!authorization) throw new Error('Executor delegation authorization is missing') + const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId) + + switch (operation) { + case 'list_credentials': { + const credentialProviderIds = parseStringList( + inputs.credentialProviderIds, + 'Credential provider IDs' + ) + const result = await listCredentialGroupCredentials.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + email: parseOptionalString(inputs.email, 'Email'), + credentialProviderIds, + }, + }) + logger.info('Listed Credential Group credentials', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + case 'send_invite': { + await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) + const result = await sendCredentialGroupInvite.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + email: requireString(inputs.email, 'Email'), + }, + }) + logger.info('Sent Credential Group invitation', { + credentialGroupId, + enrollmentId: result.enrollment.id, + }) + return { + enrollmentId: result.enrollment.id, + email: result.enrollment.email, + status: result.enrollment.status, + invitedAt: result.enrollment.invitedAt, + expiresAt: result.enrollment.expiresAt, + } + } + case 'list_people': { + const statuses = parseStringList(inputs.peopleStatuses, 'People statuses') + const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) + if (statuses?.some((status) => !allowedStatuses.has(status))) { + throw new Error('People statuses contain an unsupported value') + } + const result = await listCredentialGroupPeople.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + email: parseOptionalString(inputs.email, 'Email'), + statuses: statuses as CredentialGroupEnrollmentStatus[] | undefined, + }, + }) + logger.info('Listed Credential Group people', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + case 'list_groups': { + const result = await listCredentialGroupsForWorkflow.execute({ + principal, + input: { + workspaceId: ctx.workspaceId, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + }, + }) + logger.info('Listed Credential Groups', { + workspaceId: ctx.workspaceId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + } + } +} diff --git a/apps/sim/executor/handlers/credential/credential-handler.ts b/apps/sim/executor/handlers/credential/credential-handler.ts index 2619ae5bd07..ff01b77ff95 100644 --- a/apps/sim/executor/handlers/credential/credential-handler.ts +++ b/apps/sim/executor/handlers/credential/credential-handler.ts @@ -25,11 +25,14 @@ export class CredentialBlockHandler implements BlockHandler { const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select' - if (operation === 'list') { - return this.listCredentials(ctx.workspaceId, inputs) + switch (operation) { + case 'select': + return this.selectCredential(ctx.workspaceId, inputs) + case 'list': + return this.listCredentials(ctx.workspaceId, inputs) + default: + throw new Error(`Unsupported Credential operation: ${operation}`) } - - return this.selectCredential(ctx.workspaceId, inputs) } private async selectCredential( diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts index 032640d1b7e..1b019b01805 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { getBaseUrl } from '@/lib/core/utils/urls' import type { BlockOutput } from '@/blocks/types' import { @@ -126,7 +127,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { if (operation === PAUSE_RESUME.OPERATION.API) { const parsed = this.parseResponseData(inputs) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (isRecordLike(parsed)) { responseData = { ...parsed, operation, @@ -169,10 +170,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { } const responseDataWithResume = - resumeLinks && - responseData && - typeof responseData === 'object' && - !Array.isArray(responseData) + resumeLinks && isRecordLike(responseData) ? { ...responseData, _resume: resumeLinks } : responseData diff --git a/apps/sim/executor/handlers/pi/cloud/authoring/backend.ts b/apps/sim/executor/handlers/pi/cloud/authoring/backend.ts index fa2a7bcde13..d874c6ab477 100644 --- a/apps/sim/executor/handlers/pi/cloud/authoring/backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/authoring/backend.ts @@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { getMaxExecutionTimeout, getRemainingExecutionMs } from '@/lib/core/execution-limits' import { withPiSandbox } from '@/lib/execution/remote-sandbox' @@ -81,7 +82,7 @@ import { } from '@/executor/handlers/pi/search/extension-source' import { getPiProviderId } from '@/providers/pi-providers' import { executeTool } from '@/tools' -import { isRecord, requiredRecord, requiredTrimmedString } from '@/tools/github/response-parsers' +import { requiredRecord, requiredTrimmedString } from '@/tools/github/response-parsers' const logger = createLogger('PiCloudBackend') @@ -187,7 +188,7 @@ async function openPullRequest( throw new Error(`PR creation failed for branch ${branch}: ${result.error ?? 'unknown error'}`) } - if (!isRecord(result.output)) { + if (!isRecordLike(result.output)) { throw new Error(`PR creation returned an invalid response for branch ${branch}`) } const metadata = requiredRecord(result.output, 'metadata', 'GitHub create pull request response') @@ -224,7 +225,7 @@ async function repositoryDefaultBranch( `Failed to determine the repository default branch: ${result.error ?? 'unknown error'}` ) } - if (!isRecord(result.output)) { + if (!isRecordLike(result.output)) { throw new Error('GitHub repository response must be an object') } return requiredTrimmedString(result.output, 'default_branch', 'GitHub repository response') diff --git a/apps/sim/executor/handlers/pi/cloud/babysit/github.ts b/apps/sim/executor/handlers/pi/cloud/babysit/github.ts index 5113746c80b..30556c8acaf 100644 --- a/apps/sim/executor/handlers/pi/cloud/babysit/github.ts +++ b/apps/sim/executor/handlers/pi/cloud/babysit/github.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import type { BabysitRoundDecision } from '@/executor/handlers/pi/cloud/babysit/round' import { @@ -10,7 +11,6 @@ import { import { scrubPiSecrets } from '@/executor/handlers/pi/core/redaction' import { executeTool } from '@/tools' import { - isRecord, nullableNumber, nullableString, requiredBoolean, @@ -205,12 +205,12 @@ function toolFailure(label: string, error: unknown): Error { } function parseReviewThread(value: unknown, index: number): ReviewThread { - if (!isRecord(value)) throw new Error(`Review thread ${index} must be an object`) + if (!isRecordLike(value)) throw new Error(`Review thread ${index} must be an object`) const commentsValue = value.comments if (!Array.isArray(commentsValue)) throw new Error(`Review thread ${index}.comments must be an array`) const comments = commentsValue.map((comment, commentIndex) => { - if (!isRecord(comment)) { + if (!isRecordLike(comment)) { throw new Error(`Review thread ${index}.comments[${commentIndex}] must be an object`) } return { @@ -244,7 +244,7 @@ function parseReviewThread(value: unknown, index: number): ReviewThread { function parseLatestReview(value: unknown): SubmittedReviewSummary | null { if (value === null) return null - if (!isRecord(value)) throw new Error('latestReview must be an object or null') + if (!isRecordLike(value)) throw new Error('latestReview must be an object or null') return { state: requiredString(value, 'state', 'latestReview'), submittedAt: requiredString(value, 'submittedAt', 'latestReview'), @@ -290,7 +290,7 @@ export async function fetchBabysitThreads( ) if (!result.success) throw toolFailure('Failed to fetch review threads', result.error) const output = result.output - if (!isRecord(output) || !Array.isArray(output.threads)) { + if (!isRecordLike(output) || !Array.isArray(output.threads)) { throw new Error('Review thread response is incomplete') } const totalCount = requiredNumber(output, 'totalCount', 'Review thread response') @@ -365,7 +365,7 @@ function normalizeCheck(context: StatusCheckRollupContext): BabysitCheck { } function parseCheckContext(value: unknown, index: number): StatusCheckRollupContext { - if (!isRecord(value)) throw new Error(`Check context ${index} must be an object`) + if (!isRecordLike(value)) throw new Error(`Check context ${index} must be an object`) const type = requiredString(value, '__typename', `Check context ${index}`) if (type === 'CheckRun') { return { @@ -425,7 +425,7 @@ export async function fetchBabysitCheckState( ) if (!result.success) throw toolFailure('Failed to fetch checks', result.error) const output = result.output - if (!isRecord(output) || !Array.isArray(output.contexts)) { + if (!isRecordLike(output) || !Array.isArray(output.contexts)) { throw new Error('Check response is incomplete') } const totalCount = requiredNumber(output, 'totalCount', 'Check response') @@ -521,7 +521,7 @@ async function fetchCheckDiagnostic( }, { signal } ) - if (result.success && isRecord(result.output) && typeof result.output.logs === 'string') { + if (result.success && isRecordLike(result.output) && typeof result.output.logs === 'string') { text = result.output.logs } else { // GitHub Actions reports null `title` and `summary` on every check run it @@ -698,7 +698,7 @@ export async function requestBabysitReview( ) if ( result.success && - isRecord(result.output) && + isRecordLike(result.output) && typeof result.output.id === 'number' && Number.isSafeInteger(result.output.id) ) { @@ -750,11 +750,11 @@ export async function babysitReviewLandedSince( }, { signal } ) - if (!result.success || !isRecord(result.output) || !Array.isArray(result.output.items)) { + if (!result.success || !isRecordLike(result.output) || !Array.isArray(result.output.items)) { return false } for (const item of result.output.items) { - if (!isRecord(item) || !isRecord(item.user)) continue + if (!isRecordLike(item) || !isRecordLike(item.user)) continue const id = item.id const createdAt = item.created_at if ( diff --git a/apps/sim/executor/handlers/pi/cloud/github-pr.ts b/apps/sim/executor/handlers/pi/cloud/github-pr.ts index bab384185d0..dc72e46aced 100644 --- a/apps/sim/executor/handlers/pi/cloud/github-pr.ts +++ b/apps/sim/executor/handlers/pi/cloud/github-pr.ts @@ -6,10 +6,10 @@ * repository a credential is pointed at and which commit a write lands on. */ +import { isRecordLike } from '@sim/utils/object' import { executeTool } from '@/tools' import { GITHUB_GRAPHQL_URL, githubGraphQlHeaders, readGraphQlData } from '@/tools/github/graphql' import { - isRecord, nullableBoolean, nullableString, requiredBoolean, @@ -65,7 +65,7 @@ function requiredSha(record: Record, field: string, context: st } export function parsePullRequestSnapshot(value: unknown): PullRequestSnapshot { - if (!isRecord(value)) throw new Error(`${PULL_REQUEST_RESPONSE_CONTEXT} must be an object`) + if (!isRecordLike(value)) throw new Error(`${PULL_REQUEST_RESPONSE_CONTEXT} must be an object`) const head = requiredRecord(value, 'head', PULL_REQUEST_RESPONSE_CONTEXT) const base = requiredRecord(value, 'base', PULL_REQUEST_RESPONSE_CONTEXT) @@ -179,7 +179,7 @@ export async function findOpenPrForBranch( } const output = result.output - if (!isRecord(output)) { + if (!isRecordLike(output)) { throw new Error('GitHub pull request list response.output must be an object') } const items = output.items @@ -193,7 +193,7 @@ export async function findOpenPrForBranch( throw new Error(`Update PR found multiple open pull requests for branch ${params.branch}`) } - if (!isRecord(items[0])) { + if (!isRecordLike(items[0])) { throw new Error('GitHub pull request list response item must be an object') } const pullNumber = requiredNumber(items[0], 'number', 'GitHub pull request list response item') diff --git a/apps/sim/executor/handlers/pi/cloud/review/backend.ts b/apps/sim/executor/handlers/pi/cloud/review/backend.ts index 36c1bb7034e..cfd25533952 100644 --- a/apps/sim/executor/handlers/pi/cloud/review/backend.ts +++ b/apps/sim/executor/handlers/pi/cloud/review/backend.ts @@ -9,6 +9,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { withPiSandbox } from '@/lib/execution/remote-sandbox' import { resolvePiRunLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime' @@ -53,7 +54,7 @@ import { } from '@/executor/handlers/pi/search/normalize' import { getPiProviderId } from '@/providers/pi-providers' import { executeTool } from '@/tools' -import { isRecord, requiredTrimmedString } from '@/tools/github/response-parsers' +import { requiredTrimmedString } from '@/tools/github/response-parsers' import type { ReviewFindings } from '@/tools/github/review-schema' const logger = createLogger('PiCloudReviewBackend') @@ -191,7 +192,7 @@ async function submitReview( } const output: unknown = result.output - if (!isRecord(output)) throw new Error(`${REVIEW_RESPONSE_CONTEXT} must be an object`) + if (!isRecordLike(output)) throw new Error(`${REVIEW_RESPONSE_CONTEXT} must be an object`) if (output.commit_id !== null && output.commit_id !== headSha) { throw new Error('GitHub review response did not match the reviewed commit') } diff --git a/apps/sim/executor/handlers/pi/core/events.test.ts b/apps/sim/executor/handlers/pi/core/events.test.ts index 3fb216b56b6..f08d162d101 100644 --- a/apps/sim/executor/handlers/pi/core/events.test.ts +++ b/apps/sim/executor/handlers/pi/core/events.test.ts @@ -133,6 +133,7 @@ describe('normalizePiEvent', () => { expect(normalizePiEvent({ type: 'queue_update' })).toEqual({ type: 'other' }) expect(normalizePiEvent('nope')).toBeNull() expect(normalizePiEvent(null)).toBeNull() + expect(normalizePiEvent([])).toBeNull() }) }) diff --git a/apps/sim/executor/handlers/pi/core/events.ts b/apps/sim/executor/handlers/pi/core/events.ts index 0ff0864726a..2c05ad00b45 100644 --- a/apps/sim/executor/handlers/pi/core/events.ts +++ b/apps/sim/executor/handlers/pi/core/events.ts @@ -6,6 +6,8 @@ * run totals (final text, token usage, tool calls) the handler reports. */ +import { toRecordOrNull } from '@sim/utils/object' + /** A single normalized event emitted during a Pi run. */ export type PiEvent = | { type: 'text'; text: string } @@ -71,10 +73,6 @@ export function streamTextForEvent(event: PiEvent): string | null { return event.type === 'text' ? event.text : null } -function asRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null ? (value as Record) : null -} - function asString(value: unknown): string { return typeof value === 'string' ? value : '' } @@ -86,7 +84,7 @@ function asNumber(value: unknown): number { function extractAssistantText(message: Record): string { if (!Array.isArray(message.content)) return '' return message.content - .map((block) => asRecord(block)) + .map((block) => toRecordOrNull(block)) .filter((block): block is Record => block !== null) .filter((block) => asString(block.type) === 'text') .map((block) => asString(block.text)) @@ -104,11 +102,11 @@ function extractUsage( ev: Record ): { inputTokens: number; outputTokens: number } | null { const candidates: Array> = [] - const direct = asRecord(ev.usage) + const direct = toRecordOrNull(ev.usage) if (direct) candidates.push(direct) - const message = asRecord(ev.message) + const message = toRecordOrNull(ev.message) if (message) { - const messageUsage = asRecord(message.usage) + const messageUsage = toRecordOrNull(message.usage) if (messageUsage) candidates.push(messageUsage) } @@ -133,12 +131,12 @@ function extractUsage( * arrives only on the local and review paths and the cloud ones silently lose it. */ export function normalizePiEvent(raw: unknown): PiEvent | null { - const ev = asRecord(raw) + const ev = toRecordOrNull(raw) if (!ev) return null switch (asString(ev.type)) { case 'message_update': { - const assistantEvent = asRecord(ev.assistantMessageEvent) + const assistantEvent = toRecordOrNull(ev.assistantMessageEvent) const deltaType = assistantEvent ? asString(assistantEvent.type) : '' const delta = assistantEvent ? asString(assistantEvent.delta) : '' if (deltaType === 'text_delta') return { type: 'text', text: delta } @@ -157,7 +155,7 @@ export function normalizePiEvent(raw: unknown): PiEvent | null { if (ev.willRetry === true) return { type: 'other' } const messages = Array.isArray(ev.messages) ? ev.messages : [] for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = asRecord(messages[index]) + const message = toRecordOrNull(messages[index]) if (!message || asString(message.role) !== 'assistant') continue const stopReason = asString(message.stopReason) if (stopReason === 'error' || stopReason === 'aborted') { diff --git a/apps/sim/executor/handlers/pi/core/pi-sdk.ts b/apps/sim/executor/handlers/pi/core/pi-sdk.ts index 2ee17ae3e9c..30e43c61b54 100644 --- a/apps/sim/executor/handlers/pi/core/pi-sdk.ts +++ b/apps/sim/executor/handlers/pi/core/pi-sdk.ts @@ -1,5 +1,6 @@ import { InMemoryCredentialStore } from '@earendil-works/pi-ai' import type { ModelRuntime, ResourceLoader, ToolDefinition } from '@earendil-works/pi-coding-agent' +import { isRecordLike } from '@sim/utils/object' import type { PiToolSpec } from '@/executor/handlers/pi/core/backend' import { createScrubbedPiError, scrubPiSecrets } from '@/executor/handlers/pi/core/redaction' @@ -20,7 +21,7 @@ export function loadPiSdk(): Promise { } function isToolArguments(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + return isRecordLike(value) } /** diff --git a/apps/sim/executor/handlers/pi/search/normalize.ts b/apps/sim/executor/handlers/pi/search/normalize.ts index 9bc556d331f..af63a96a7c0 100644 --- a/apps/sim/executor/handlers/pi/search/normalize.ts +++ b/apps/sim/executor/handlers/pi/search/normalize.ts @@ -9,6 +9,7 @@ * holds the two request paths together. */ +import { isRecordLike } from '@sim/utils/object' import type { PiSearchProvider } from '@/executor/handlers/pi/core/keys' /** The tool name Pi sees, in every mode. */ @@ -175,10 +176,6 @@ export function buildPiSearchProviderArgs( } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function asText(value: unknown): string { return typeof value === 'string' ? value : '' } @@ -258,7 +255,7 @@ export function buildPiSearchResult(fields: { */ function firecrawlRecords(data: unknown): unknown[] { if (Array.isArray(data)) return data - if (!isRecord(data)) return [] + if (!isRecordLike(data)) return [] return Object.values(data).flatMap((value) => (Array.isArray(value) ? value : [])) } @@ -276,7 +273,7 @@ export function normalizePiSearchRecords( for (const record of records) { if (results.length >= limit) break - if (!isRecord(record)) continue + if (!isRecordLike(record)) continue let built: PiSearchResult | undefined switch (provider) { @@ -328,7 +325,7 @@ export function normalizePiSearchRecords( /** Extracts the provider's result records from a normalized-or-raw response payload. */ export function extractPiSearchRecords(provider: PiSearchProvider, payload: unknown): unknown[] { - if (!isRecord(payload)) return [] + if (!isRecordLike(payload)) return [] switch (provider) { case 'exa': return Array.isArray(payload.results) ? payload.results : [] diff --git a/apps/sim/executor/handlers/registry.ts b/apps/sim/executor/handlers/registry.ts index cd8c57d1c61..bbe0e52debb 100644 --- a/apps/sim/executor/handlers/registry.ts +++ b/apps/sim/executor/handlers/registry.ts @@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler' +import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' @@ -45,6 +46,7 @@ export function createBlockHandlers(): BlockHandler[] { new WorkflowBlockHandler(), new WaitBlockHandler(), new EvaluatorBlockHandler(), + new CredentialGroupBlockHandler(), new CredentialBlockHandler(), new GenericBlockHandler(), ] diff --git a/apps/sim/executor/handlers/shared/response-format.ts b/apps/sim/executor/handlers/shared/response-format.ts index 1d31b6ff557..9f1ad68a5ef 100644 --- a/apps/sim/executor/handlers/shared/response-format.ts +++ b/apps/sim/executor/handlers/shared/response-format.ts @@ -49,38 +49,6 @@ export function parseResponseFormat(responseFormat?: string | object): any { return undefined } -/** - * Validate and extract messages from a raw input value. - * - * Accepts a JSON string or an array. Each entry must have - * `role` (string) and `content` (string). - */ -export function resolveMessages(raw: unknown): Array<{ role: string; content: string }> { - if (!raw) { - throw new Error('Messages input is required') - } - - let messages: unknown[] - if (typeof raw === 'string') { - try { - messages = JSON.parse(raw) - } catch { - throw new Error('Messages must be a valid JSON array') - } - } else if (Array.isArray(raw)) { - messages = raw - } else { - throw new Error('Messages must be an array of {role, content} objects') - } - - return messages.map((msg: any, i: number) => { - if (!msg.role || typeof msg.content !== 'string') { - throw new Error(`Message at index ${i} must have "role" (string) and "content" (string)`) - } - return { role: String(msg.role), content: msg.content } - }) -} - /** * Try to parse the LLM response content as structured JSON and spread * the fields into the block output. Falls back to returning raw content. diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index c1dd651be1a..f0f0083192d 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { isPlainRecord } from '@sim/utils/object' +import { isPlainRecord, isRecordLike } from '@sim/utils/object' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { PiiBlockOutputRedaction } from '@/executor/execution/types' import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' @@ -160,8 +160,7 @@ export async function runCustomBlockTool( }) // Custom blocks never stream (no `onStream` on the synthetic ctx), so the // handler always returns the projected BlockOutput object. - const normalized: Record = - output && typeof output === 'object' && !Array.isArray(output) ? output : { result: output } + const normalized: Record = isRecordLike(output) ? output : { result: output } return { success: true, output: normalized } } catch (error) { // The handler throws a consumer-safe `ChildWorkflowError` on failure. The diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 796aae1eb01..b56631f9167 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -347,7 +347,7 @@ export class WorkflowBlockHandler implements BlockHandler { if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) { const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping) - if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) { + if (isRecordLike(normalized)) { // Custom blocks key their mapping by the source field's stable id so a // rename never orphans the consumer's value; remap id → current name // before the child (which is addressed by name) receives it. diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts index b37675bc849..010b7cae36e 100644 --- a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' import type { TraceSpan } from '@/lib/logs/types' import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' @@ -123,10 +124,9 @@ export async function runWorkflowTool( workflowId: params.workflowId, inputMapping, }) - const normalized: Record = - output && typeof output === 'object' && !Array.isArray(output) - ? (output as Record) - : { result: output } + const normalized: Record = isRecordLike(output) + ? (output as Record) + : { result: output } const result: ToolResponse = { success: true, output: normalized } await markResultProvenanceCrossing(options.resolvedSecretTraceRegistry, result) return result diff --git a/apps/sim/executor/human-in-the-loop/utils.ts b/apps/sim/executor/human-in-the-loop/utils.ts index 1b060cf833a..0b2c5467cae 100644 --- a/apps/sim/executor/human-in-the-loop/utils.ts +++ b/apps/sim/executor/human-in-the-loop/utils.ts @@ -27,18 +27,6 @@ export function generatePauseContextId( return contextId } -export function buildTriggerBlockId(nodeId: string): string { - if (nodeId.includes('__response')) { - return nodeId.replace('__response', '__trigger') - } - - if (nodeId.endsWith('_response')) { - return nodeId.replace(/_response$/, '_trigger') - } - - return `${nodeId}__trigger` -} - export function mapNodeMetadataToPauseScopes( ctx: ExecutionContext, nodeMetadata: NodeMetadataLike diff --git a/apps/sim/executor/types/loop.ts b/apps/sim/executor/types/loop.ts index eebc87e4a61..e2be8cca95a 100644 --- a/apps/sim/executor/types/loop.ts +++ b/apps/sim/executor/types/loop.ts @@ -3,7 +3,3 @@ import type { SerializedLoop } from '@/serializer/types' export interface LoopConfigWithNodes extends SerializedLoop { nodes: string[] } - -export function isLoopConfigWithNodes(config: SerializedLoop): config is LoopConfigWithNodes { - return Array.isArray((config as any).nodes) -} diff --git a/apps/sim/executor/types/parallel.ts b/apps/sim/executor/types/parallel.ts index 3ec58d7941d..6b7d279796e 100644 --- a/apps/sim/executor/types/parallel.ts +++ b/apps/sim/executor/types/parallel.ts @@ -3,9 +3,3 @@ import type { SerializedParallel } from '@/serializer/types' export interface ParallelConfigWithNodes extends SerializedParallel { nodes: string[] } - -export function isParallelConfigWithNodes( - config: SerializedParallel -): config is ParallelConfigWithNodes { - return Array.isArray((config as any).nodes) -} diff --git a/apps/sim/executor/utils/json.ts b/apps/sim/executor/utils/json.ts index 8890e7cbb88..1b3bd11ca67 100644 --- a/apps/sim/executor/utils/json.ts +++ b/apps/sim/executor/utils/json.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { EVALUATOR } from '@/executor/constants' const logger = createLogger('JSONUtils') @@ -16,18 +15,6 @@ export function parseJSON(value: unknown, fallback: T): T { } } -export function parseJSONOrThrow(value: string): any { - try { - return JSON.parse(value.trim()) - } catch (error) { - throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Parse error')}`) - } -} - -export function normalizeJSONString(value: string): string { - return value.replace(/'/g, '"') -} - export function stringifyJSON(value: any, indent?: number): string { try { return JSON.stringify(value, null, indent ?? EVALUATOR.JSON_INDENT) diff --git a/apps/sim/executor/utils/reference-validation.ts b/apps/sim/executor/utils/reference-validation.ts index 91130a19683..bef3dd45c42 100644 --- a/apps/sim/executor/utils/reference-validation.ts +++ b/apps/sim/executor/utils/reference-validation.ts @@ -1,4 +1,3 @@ -import { isLikelyReferenceSegment } from '@/lib/workflows/sanitization/references' import { REFERENCE } from '@/executor/constants' /** @@ -145,21 +144,3 @@ export function createCombinedPattern(): RegExp { 'g' ) } - -/** - * Replaces variable references with smart validation. - * Distinguishes < operator from < bracket using isLikelyReferenceSegment. - */ -export function replaceValidReferences( - template: string, - replacer: (match: string, index: number, template: string) => string -): string { - const pattern = createReferencePattern() - - return template.replace(pattern, (match, _content, index) => { - if (!isLikelyReferenceSegment(match)) { - return match - } - return replacer(match, index, template) - }) -} diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index 169307452cf..a40fc42260a 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -580,7 +580,7 @@ function extractSubBlocks(block: SerializedBlock): Record | und } const subBlocks = maybeWithSubBlocks.subBlocks - if (subBlocks && typeof subBlocks === 'object' && !Array.isArray(subBlocks)) { + if (isRecordLike(subBlocks)) { return subBlocks } diff --git a/apps/sim/executor/utils/subflow-utils.ts b/apps/sim/executor/utils/subflow-utils.ts index 63ad2593824..0dc00d93466 100644 --- a/apps/sim/executor/utils/subflow-utils.ts +++ b/apps/sim/executor/utils/subflow-utils.ts @@ -39,10 +39,6 @@ export function isParallelSentinelNodeId(nodeId: string): boolean { return SubflowNodeIdCodec.isParallelSentinelNodeId(nodeId) } -export function isSentinelNodeId(nodeId: string): boolean { - return isLoopSentinelNodeId(nodeId) || isParallelSentinelNodeId(nodeId) -} - export function extractLoopIdFromSentinel(sentinelId: string): string | null { return SubflowNodeIdCodec.extractLoopIdFromSentinel(sentinelId) } diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index 1bc54b1c0e7..aa0c8c93b42 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -3,6 +3,8 @@ */ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { compileCodePlaceholders } from '@/lib/execution/code-placeholders' +import { CodeLanguage } from '@/lib/execution/languages' import { projectResolvedModelInput } from '@/lib/execution/model-input-provenance' import { LARGE_ARRAY_MANIFEST_VERSION, @@ -125,8 +127,55 @@ function createResolver( } } +/** Runs one condition expression through the resolver and returns the value the handler receives. */ +async function resolveConditionExpression( + value: string, + environmentVariables: Record +): Promise { + const { ctx, resolver } = createResolver() + ctx.environmentVariables = environmentVariables + const conditionBlock = createBlock('condition', 'Condition', BlockType.CONDITION) + const result = await resolver.resolveInputs( + ctx, + conditionBlock.id, + { conditions: JSON.stringify([{ id: 'condition-1', title: 'if', value }]) }, + conditionBlock + ) + return (result.conditions as Array<{ value: string }>)[0].value +} + +/** + * Completes the round trip a condition actually takes: resolver, then the execution-boundary + * compiler, then evaluation of the same `Boolean(...)` wrapper `condition-handler.ts` builds. + */ +async function evaluateResolvedCondition( + value: string, + environmentVariables: Record +): Promise { + const expression = await resolveConditionExpression(value, environmentVariables) + const compiled = await compileCodePlaceholders({ + code: `const context = {};\nreturn Boolean(${expression})`, + language: CodeLanguage.JavaScript, + environmentVariables, + }) + const installed: string[] = [] + try { + for (const binding of compiled.bindings) { + Object.defineProperty(globalThis, binding.name, { + configurable: true, + value: binding.value, + writable: true, + }) + installed.push(binding.name) + } + return Boolean(new Function(compiled.code)()) + } finally { + for (const name of installed) Reflect.deleteProperty(globalThis, name) + } +} + describe('VariableResolver function block inputs', () => { - it('preserves legacy condition environment substitution semantics', async () => { + it('inlines only structurally inert condition literals and defers the rest to the compiler', async () => { const { ctx, resolver } = createResolver() ctx.environmentVariables = { API_KEY: 'token', @@ -152,10 +201,76 @@ describe('VariableResolver function block inputs', () => { expect(result.conditions).toEqual([ { id: 'condition-1', title: 'if', value: '123 === 123' }, { id: 'condition-2', title: 'else if', value: 'true === true' }, - { id: 'condition-3', title: 'else if', value: '"Bearer token" === "Bearer token"' }, + { + id: 'condition-3', + title: 'else if', + value: '"Bearer {{API_KEY}}" === "Bearer token"', + }, ]) }) + it('preserves legacy condition outcomes end to end through the boundary compiler', async () => { + const environmentVariables = { + API_KEY: 'token', + BOOLEAN_VALUE: 'true', + NUMBER_VALUE: '123', + NULL_VALUE: 'null', + NEGATIVE: '-5', + EXPONENT: '1e3', + } + const cases = [ + { value: '{{NUMBER_VALUE}} === 123', expected: true }, + { value: '{{BOOLEAN_VALUE}} === true', expected: true }, + { value: '"Bearer {{API_KEY}}" === "Bearer token"', expected: true }, + { value: `'{{API_KEY}}' === 'token'`, expected: true }, + { value: '{{NULL_VALUE}} === null', expected: true }, + { value: '{{NEGATIVE}} === -5', expected: true }, + { value: '{{EXPONENT}} === 1000', expected: true }, + { value: '{{NUMBER_VALUE}} === 999', expected: false }, + ] + + /** A padded value must stay byte-identical: numeric bare, exact string when quoted. */ + expect(await evaluateResolvedCondition('{{PADDED}} === 123', { PADDED: ' 123 ' })).toBe(true) + expect(await evaluateResolvedCondition(`'{{PADDED}}' === ' 123 '`, { PADDED: ' 123 ' })).toBe( + true + ) + + for (const { value, expected } of cases) { + expect( + await evaluateResolvedCondition(value, environmentVariables), + `condition ${value} should evaluate to ${expected}` + ).toBe(expected) + } + }) + + it('stops a secret value from breaking or forging a condition', async () => { + await expect( + evaluateResolvedCondition(`'{{NAME}}' === 'bob'`, { NAME: `x' || true || '` }) + ).resolves.toBe(false) + await expect( + evaluateResolvedCondition(`'{{NAME}}' === "O'Brien"`, { NAME: "O'Brien" }) + ).resolves.toBe(true) + await expect( + evaluateResolvedCondition(`'{{NAME}}' === 'a\\nb'`, { NAME: 'a\nb' }) + ).resolves.toBe(true) + }) + + it('compares a bare string placeholder instead of throwing a reference error', async () => { + await expect( + evaluateResolvedCondition(`{{NAME}} === 'alice'`, { NAME: 'alice' }) + ).resolves.toBe(true) + await expect(evaluateResolvedCondition(`{{NAME}} === 'alice'`, { NAME: 'bob' })).resolves.toBe( + false + ) + }) + + it('keeps a resolved secret out of the code sent to the execution boundary', async () => { + const resolved = await resolveConditionExpression(`'{{API_KEY}}' === 'token'`, { + API_KEY: 'token', + }) + expect(resolved).toBe(`'{{API_KEY}}' === 'token'`) + }) + it('does not log malformed Condition source while falling back to legacy resolution', async () => { const { ctx, resolver } = createResolver() const secret = 'condition-fallback-secret-value' diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index b2cbd009555..f5e50e2d50a 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -109,6 +109,34 @@ async function replaceEnvVarsAsync( return result + template.slice(cursor) } +/** + * A number, boolean, or null literal, optionally padded with spaces or tabs. + * + * Every character this admits — digits, `.`, `-`, `+`, `e`, the three keywords, spaces, and + * tabs — is inert in both places a Condition placeholder can land. In expression position none + * of them introduces an operator or a comment; inside a string literal none of them terminates + * it. Padding is admitted rather than trimmed so the inlined text stays byte-identical to the + * stored value: whitespace is meaningless in expression position but significant inside a + * quoted string, and only the untrimmed value is correct in both. Line terminators stay out — + * a raw newline would break a single-quoted string. + */ +const STRUCTURALLY_INERT_CONDITION_LITERAL = + /^[ \t]*(?:-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|true|false|null)[ \t]*$/ + +/** + * Whether an environment variable value may be inlined into a Condition expression as source. + * + * Condition expressions are user-authored JavaScript, so an inlined value is parsed as code. + * Only self-contained literals are safe to inline; every other value keeps its `{{NAME}}` + * placeholder and is bound as a string by the execution-boundary compiler instead. That keeps + * `{{COUNT}} === 3` and `{{ENABLED}} === true` comparing as literals — the long-standing + * behavior — while a value containing a quote, newline, or operator can no longer break the + * expression or forge its result. + */ +function isStructurallyInertConditionLiteral(value: string): boolean { + return STRUCTURALLY_INERT_CONDITION_LITERAL.test(value) +} + type ShellQuoteContext = 'single' | 'double' | null type CodeStringQuoteContext = ShellQuoteContext | 'triple-single' | 'triple-double' | 'template' type CodeScanMode = @@ -1419,7 +1447,8 @@ export class VariableResolver { result = await replaceEnvVarsAsync(result, async (match) => { const resolved = await this.resolveReference(match, resolutionContext) - return typeof resolved === 'string' ? resolved : match + if (typeof resolved !== 'string') return match + return isStructurallyInertConditionLiteral(resolved) ? resolved : match }) ctx.resolvedSecretTraceRegistry?.recordResolvedInputProjection( inputPath, diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts new file mode 100644 index 00000000000..d45dbed396d --- /dev/null +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -0,0 +1,196 @@ +'use client' + +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + createCredentialGroupContract, + deleteCredentialGroupContract, + getCredentialGroupContract, + inviteCredentialGroupEnrollmentsContract, + resendCredentialGroupEnrollmentContract, + revokeCredentialGroupEnrollmentContract, + startSlackCredentialGroupConfigurationContract, + updateCredentialGroupContract, +} from '@/lib/api/contracts/credential-groups' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { + CREDENTIAL_GROUP_DETAIL_STALE_TIME, + CREDENTIAL_GROUP_LIST_STALE_TIME, + credentialGroupKeys, + fetchCredentialGroupList, +} from '@/hooks/queries/utils/credential-group-queries' + +export function useCredentialGroups(workspaceId?: string) { + return useQuery({ + queryKey: credentialGroupKeys.list(workspaceId), + queryFn: async ({ signal }) => { + if (!workspaceId) return [] + return fetchCredentialGroupList(workspaceId, signal) + }, + enabled: Boolean(workspaceId), + staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + }) +} + +export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) { + return useInfiniteQuery({ + queryKey: credentialGroupKeys.detail(workspaceId, groupId), + queryFn: ({ signal, pageParam }) => { + if (!workspaceId || !groupId) + throw new Error('Credential group detail identifiers are required') + return requestJson(getCredentialGroupContract, { + params: { id: workspaceId, groupId }, + query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) }, + signal, + }) + }, + initialPageParam: null as string | null, + getNextPageParam: (lastPage: ContractJsonResponse) => + lastPage.nextCursor ?? undefined, + enabled: Boolean(workspaceId && groupId), + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + }) +} + +export function useCreateCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + body, + }: { + workspaceId: string + body: ContractBodyInput + }) => requestJson(createCredentialGroupContract, { params: { id: workspaceId }, body }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useDeleteCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ workspaceId, groupId }: { workspaceId: string; groupId: string }) => + requestJson(deleteCredentialGroupContract, { + params: { id: workspaceId, groupId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useUpdateCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(updateCredentialGroupContract, { + 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), + }) + }, + }) +} + +export function useStartSlackCredentialGroupConfiguration() { + return useMutation({ + mutationFn: async ({ + workspaceId, + credentialGroupId, + body, + }: { + workspaceId: string + credentialGroupId: string + body: ContractBodyInput + }) => + requestJson(startSlackCredentialGroupConfigurationContract, { + params: { id: workspaceId, groupId: credentialGroupId }, + body, + }), + }) +} + +export function useInviteCredentialGroupEnrollments() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(inviteCredentialGroupEnrollmentsContract, { + params: { id: workspaceId, groupId }, + body, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useResendCredentialGroupEnrollment() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + enrollmentId, + }: { + workspaceId: string + groupId: string + enrollmentId: string + }) => + requestJson(resendCredentialGroupEnrollmentContract, { + params: { id: workspaceId, groupId, enrollmentId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useRevokeCredentialGroupEnrollment() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + enrollmentId, + }: { + workspaceId: string + groupId: string + enrollmentId: string + }) => + requestJson(revokeCredentialGroupEnrollmentContract, { + params: { id: workspaceId, groupId, enrollmentId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 1a24ce87383..2c3efa310ad 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -143,11 +143,6 @@ export function useBillingUsageNotifications(): boolean { return data?.billingUsageNotificationsEnabled ?? true } -export function useErrorNotificationsEnabled(): boolean { - const { data } = useGeneralSettings() - return data?.errorNotificationsEnabled ?? true -} - /** * The user's effective scheduling timezone: their saved preference, or the * browser-detected zone when unset. Use this wherever a task's timezone is diff --git a/apps/sim/hooks/queries/mothership-admin.ts b/apps/sim/hooks/queries/mothership-admin.ts index 1bcadf2dd74..f98fedc44af 100644 --- a/apps/sim/hooks/queries/mothership-admin.ts +++ b/apps/sim/hooks/queries/mothership-admin.ts @@ -182,28 +182,6 @@ export function useMothershipLicenses(environment: MothershipEnv) { }) } -export function useMothershipLicenseDetails( - environment: MothershipEnv, - id?: string, - name?: string -) { - return useQuery({ - queryKey: mothershipKeys.licenseDetails(environment, id, name), - queryFn: ({ signal }) => - mothershipPost( - 'licenses/details', - environment, - { - ...(id ? { id } : {}), - ...(name ? { name } : {}), - }, - signal - ), - enabled: !!(id || name), - staleTime: MOTHERSHIP_LICENSE_DETAIL_STALE_TIME, - }) -} - export function useGenerateLicense(environment: MothershipEnv) { const queryClient = useQueryClient() return useMutation({ diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index aae58765e98..b2dad6357c0 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -10,7 +10,6 @@ import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { addMothershipChatResourceContract, - createMothershipChatContract, deleteMothershipChatContract, forkMothershipChatContract, getMothershipChatContract, @@ -683,47 +682,6 @@ export function useSetMothershipChatPinned(workspaceId?: string) { }) } -async function createChat(workspaceId: string): Promise<{ id: string }> { - const { id } = await requestJson(createMothershipChatContract, { body: { workspaceId } }) - return { id } -} - -export function useCreateMothershipChat(workspaceId?: string) { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: () => { - if (!workspaceId) throw new Error('workspaceId is required') - return createChat(workspaceId) - }, - onSuccess: (data) => { - if (!workspaceId) return - const existing = - queryClient.getQueryData(mothershipChatKeys.list(workspaceId)) ?? - [] - const newChat: MothershipChatMetadata = { - id: data.id, - name: 'New chat', - updatedAt: new Date(), - isActive: false, - isUnread: false, - isPinned: false, - deletedAt: null, - } - const pinnedCount = existing.findIndex((chat) => !chat.isPinned) - const insertAt = pinnedCount === -1 ? existing.length : pinnedCount - queryClient.setQueryData(mothershipChatKeys.list(workspaceId), [ - ...existing.slice(0, insertAt), - newChat, - ...existing.slice(insertAt), - ]) - }, - onSettled: () => { - if (!workspaceId) return - queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) }) - }, - }) -} - async function forkChat(params: { chatId: string upToMessageId: string diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index 25338567464..e3a5becbc0b 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -1,10 +1,9 @@ import { createLogger } from '@sim/logger' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ConnectedAccount, disconnectOAuthContract, - listConnectedAccountsContract, listOAuthConnectionsContract, type OAuthAccountSummary, type OAuthConnection, @@ -265,29 +264,3 @@ export function useDisconnectOAuthService() { /** Connected OAuth account for a specific provider. */ export type { ConnectedAccount } - -async function fetchConnectedAccounts( - provider: string, - signal?: AbortSignal -): Promise { - const data = await requestJson(listConnectedAccountsContract, { - query: { provider }, - signal, - }) - return data.accounts -} - -/** - * Fetches connected accounts for a specific OAuth provider. - * @param provider - The provider ID (e.g., 'slack', 'google') - * @param options - Query options including enabled flag - */ -export function useConnectedAccounts(provider: string, options?: { enabled?: boolean }) { - return useQuery({ - queryKey: oauthConnectionsKeys.account(provider), - queryFn: ({ signal }) => fetchConnectedAccounts(provider, signal), - enabled: options?.enabled ?? true, - staleTime: OAUTH_CONNECTED_ACCOUNTS_STALE_TIME, - placeholderData: keepPreviousData, - }) -} diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index a0ce1295633..f811e9fb54e 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -1,11 +1,6 @@ import { createLogger } from '@sim/logger' -import { - keepPreviousData, - type UseQueryResult, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { isRecordLike } from '@sim/utils/object' +import { type UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' @@ -19,8 +14,6 @@ import { getMemberRemovalImpactContract, getOrganizationMemberUsageLimitContract, getOrganizationRosterContract, - listOrganizationMembersContract, - type OrganizationMembersResponse, type OrganizationMemberUsageLimitData, type OrganizationRoster, type RemovalImpactCredential, @@ -29,7 +22,6 @@ import { type RosterWorkspaceAccess, removeOrganizationMemberContract, transferOwnershipContract, - updateOrganizationContract, updateOrganizationMemberRoleContract, updateOrganizationMemberUsageLimitContract, updateOrganizationUsageLimitContract, @@ -39,8 +31,6 @@ import { type OrganizationBillingApiResponse, } from '@/lib/api/contracts/subscription' import { client } from '@/lib/auth/auth-client' -import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' -import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { subscriptionKeys } from '@/hooks/queries/utils/subscription-keys' import { workspaceKeys } from '@/hooks/queries/workspace' @@ -62,41 +52,8 @@ export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000 */ export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0 -type OrganizationSubscriptionCandidate = { - id: string - referenceId: string - status: string - plan: string - cancelAtPeriodEnd?: boolean - periodEnd?: number | Date - trialEnd?: number | Date -} - type OrganizationBillingQueryResult = UseQueryResult -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function isOrganizationSubscriptionCandidate( - value: unknown -): value is OrganizationSubscriptionCandidate { - if (!isRecord(value)) return false - return ( - typeof value.id === 'string' && - typeof value.referenceId === 'string' && - typeof value.status === 'string' && - typeof value.plan === 'string' && - (value.cancelAtPeriodEnd === undefined || typeof value.cancelAtPeriodEnd === 'boolean') && - (value.periodEnd === undefined || - typeof value.periodEnd === 'number' || - value.periodEnd instanceof Date) && - (value.trialEnd === undefined || - typeof value.trialEnd === 'number' || - value.trialEnd instanceof Date) - ) -} - function readNumber(value: unknown): number | undefined { if (typeof value === 'number') return value if (typeof value === 'string') { @@ -188,40 +145,6 @@ export function useMemberRemovalImpact( }) } -/** - * Fetches the current viewer's account-scoped organizations. - * - * `activeOrganization` reflects the viewer session's selected organization. It - * must not be used as the organization context for a routed workspace; those - * surfaces use the workspace host context instead. Billing data is fetched - * separately, and the Better Auth client does not accept an AbortSignal. - */ -async function fetchOrganizations(_signal?: AbortSignal) { - const [orgsResponse, activeOrgResponse] = await Promise.all([ - client.organization.list(), - client.organization.getFullOrganization(), - ]) - - return { - organizations: orgsResponse.data || [], - activeOrganization: activeOrgResponse.data, - } -} - -/** - * Reads the viewer's account organizations and account-scoped active organization. - * - * Workspace-bound consumers must use the routed workspace host context instead - * of `activeOrganization`. - */ -export function useOrganizations() { - return useQuery({ - queryKey: organizationKeys.lists(), - queryFn: ({ signal }) => fetchOrganizations(signal), - staleTime: ORGANIZATION_LIST_STALE_TIME, - }) -} - /** * Fetch a specific organization by ID. * @@ -250,53 +173,6 @@ export function useOrganization(orgId: string) { }) } -/** - * Fetch organization subscription data - */ -async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSignal) { - if (!orgId) { - return null - } - - const response = await client.subscription.list({ - query: { referenceId: orgId }, - }) - - if (response.error) { - logger.error('Error fetching organization subscription', { error: response.error }) - return null - } - - // Any paid subscription attached to the org counts as its active sub. - // Priority: Enterprise > Team > Pro (matches `getHighestPrioritySubscription`). - // This intentionally includes `pro_*` plans that have been transferred - // to the org — they are pooled org-scoped subscriptions. - const rawSubscriptions: unknown = response.data - const entitled = (Array.isArray(rawSubscriptions) ? rawSubscriptions : []) - .filter(isOrganizationSubscriptionCandidate) - .filter((sub) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan)) - const enterpriseSubscription = entitled.find((sub) => isEnterprise(sub.plan)) - const teamSubscription = entitled.find((sub) => isTeam(sub.plan)) - const proSubscription = entitled.find((sub) => !isEnterprise(sub.plan) && !isTeam(sub.plan)) - const activeSubscription = enterpriseSubscription || teamSubscription || proSubscription - - return activeSubscription || null -} - -/** - * Hook to fetch organization subscription - */ -export function useOrganizationSubscription(orgId: string) { - return useQuery({ - queryKey: organizationKeys.subscription(orgId), - queryFn: ({ signal }) => fetchOrganizationSubscription(orgId, signal), - enabled: !!orgId, - retry: false, - staleTime: ORGANIZATION_SUBSCRIPTION_STALE_TIME, - placeholderData: keepPreviousData, - }) -} - /** * Fetch organization billing data */ @@ -333,46 +209,6 @@ export function useOrganizationBilling( }) } -/** - * Fetch organization member usage data - */ -async function fetchOrganizationMembers( - orgId: string, - signal?: AbortSignal -): Promise { - try { - return await requestJson(listOrganizationMembersContract, { - params: { id: orgId }, - query: { include: 'usage' }, - signal, - }) - } catch (error) { - if (error instanceof ApiClientError && error.status === 404) { - return { - success: true, - data: [], - total: 0, - userRole: 'member', - hasAdminAccess: false, - } - } - throw error - } -} - -/** - * Hook to fetch organization members with usage data - */ -export function useOrganizationMembers(orgId: string) { - return useQuery({ - queryKey: organizationKeys.memberUsage(orgId), - queryFn: ({ signal }) => fetchOrganizationMembers(orgId, signal), - enabled: !!orgId, - staleTime: ORGANIZATION_MEMBERS_STALE_TIME, - placeholderData: keepPreviousData, - }) -} - /** * Update organization usage limit mutation with optimistic updates */ @@ -402,8 +238,8 @@ export function useUpdateOrganizationUsageLimit() { queryClient.setQueryData( organizationKeys.billing(organizationId), (old: unknown) => { - if (!isRecord(old) || !isRecord(old.data)) return old - const usage = isRecord(old.data.usage) ? old.data.usage : {} + if (!isRecordLike(old) || !isRecordLike(old.data)) return old + const usage = isRecordLike(old.data.usage) ? old.data.usage : {} const currentUsage = readNumber(old.data.currentUsage) ?? readNumber(usage.current) ?? @@ -663,30 +499,6 @@ export function useResendInvitation() { }) } -/** - * Update organization settings mutation - */ -type UpdateOrganizationParams = { - orgId: string -} & ContractBodyInput - -export function useUpdateOrganization() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ orgId, ...updates }: UpdateOrganizationParams) => { - return requestJson(updateOrganizationContract, { - params: { id: orgId }, - body: updates, - }) - }, - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) - }, - }) -} - /** * Create organization mutation */ diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index a22fe07d4fe..c2d563daaad 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -4,7 +4,6 @@ import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { deployWorkflowContract } from '@/lib/api/contracts/deployments' import { - getScheduleByIdContract, getScheduleContract, listWorkspaceSchedulesContract, reactivateScheduleContract, @@ -95,28 +94,6 @@ export function useWorkspaceSchedules(workspaceId?: string, options?: { enabled? }) } -/** - * Fetch a single workflow schedule by id — a lightweight by-id read instead of - * the whole-workspace `useWorkspaceSchedules` fetch. - */ -export function useScheduleById(scheduleId?: string) { - return useQuery({ - queryKey: scheduleKeys.byId(scheduleId ?? ''), - queryFn: async ({ signal }) => { - if (!scheduleId) throw new Error('Schedule ID required') - - const data = await requestJson(getScheduleByIdContract, { - params: { id: scheduleId }, - signal, - }) - return data.schedule - }, - enabled: Boolean(scheduleId), - staleTime: SCHEDULE_DETAIL_STALE_TIME, - placeholderData: keepPreviousData, - }) -} - /** * Hook to fetch schedule data for a workflow block */ diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 5809f6b2eba..ad4b38dcf45 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -8,14 +8,11 @@ import { getUserBillingContract, getUserUsageLimitContract, type InvoicesApiResponse, - purchaseCreditsContract, type SubscriptionApiResponse, updateUsageLimitContract, } from '@/lib/api/contracts/subscription' -import { organizationKeys } from '@/hooks/queries/organization' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' import { subscriptionKeys } from '@/hooks/queries/utils/subscription-keys' -import { workspaceKeys } from '@/hooks/queries/workspace' export type { SubscriptionApiResponse } @@ -62,18 +59,6 @@ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { }) } -/** - * Prefetch subscription data into a QueryClient cache. - * Use on hover to warm data before navigation. - */ -export function prefetchSubscriptionData(queryClient: QueryClient) { - queryClient.prefetchQuery({ - queryKey: subscriptionKeys.user(false), - queryFn: ({ signal }) => fetchSubscriptionData(false, signal), - staleTime: SUBSCRIPTION_DATA_STALE_TIME, - }) -} - /** * Prefetch the billing queries the Upgrade page gates on: the * organization-scoped subscription variant (`includeOrg: true`, a different @@ -260,81 +245,6 @@ export function useUpdateUsageLimit() { }) } -/** - * Upgrade subscription mutation - */ -interface UpgradeSubscriptionParams { - plan: string - orgId?: string -} - -export function useUpgradeSubscription() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ plan }: UpgradeSubscriptionParams) => { - return { plan } - }, - onSettled: (_data, _error, variables) => { - return Promise.all([ - queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }), - queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }), - queryClient.invalidateQueries({ queryKey: subscriptionKeys.invoicesAll() }), - queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }), - invalidateWorkspaceUsage(queryClient), - ...(variables.orgId - ? [ - queryClient.invalidateQueries({ - queryKey: organizationKeys.billing(variables.orgId), - }), - queryClient.invalidateQueries({ - queryKey: organizationKeys.subscription(variables.orgId), - }), - ] - : []), - ]) - }, - }) -} - -/** - * Purchase credits mutation - */ -interface PurchaseCreditsParams { - amount: ContractBodyInput['amount'] - requestId: ContractBodyInput['requestId'] - orgId?: string -} - -export function usePurchaseCredits() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ amount, requestId }: PurchaseCreditsParams) => { - return requestJson(purchaseCreditsContract, { - body: { amount, requestId }, - }) - }, - onSettled: (_data, _error, variables) => { - return Promise.all([ - queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }), - queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }), - invalidateWorkspaceUsage(queryClient), - ...(variables.orgId - ? [ - queryClient.invalidateQueries({ - queryKey: organizationKeys.billing(variables.orgId), - }), - queryClient.invalidateQueries({ - queryKey: organizationKeys.subscription(variables.orgId), - }), - ] - : []), - ]) - }, - }) -} - /** * Open billing portal mutation */ diff --git a/apps/sim/hooks/queries/utils/credential-group-queries.ts b/apps/sim/hooks/queries/utils/credential-group-queries.ts new file mode 100644 index 00000000000..780b2f31f49 --- /dev/null +++ b/apps/sim/hooks/queries/utils/credential-group-queries.ts @@ -0,0 +1,26 @@ +import { requestJson } from '@/lib/api/client/request' +import type { CredentialGroup } from '@/lib/api/contracts/credential-groups' +import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups' + +export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY +export const CREDENTIAL_GROUP_LIST_STALE_TIME = 30 * 1000 + +export const credentialGroupKeys = { + all: ['credential-groups'] as const, + lists: () => [...credentialGroupKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...credentialGroupKeys.lists(), workspaceId ?? ''] as const, + details: () => [...credentialGroupKeys.all, 'detail'] as const, + detail: (workspaceId?: string, groupId?: string) => + [...credentialGroupKeys.details(), workspaceId ?? '', groupId ?? ''] as const, +} + +export async function fetchCredentialGroupList( + workspaceId: string, + signal?: AbortSignal +): Promise { + const data = await requestJson(listCredentialGroupsContract, { + params: { id: workspaceId }, + signal, + }) + return data.credentialGroups +} diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 69c775f2dde..da45c0f0ac4 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -10,10 +10,8 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' -import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits' import { type CreateWorkspaceFileBody, createWorkspaceFileContract, @@ -69,16 +67,6 @@ export const WORKSPACE_STORAGE_INFO_STALE_TIME = 60 * 1000 /** Cloud storage (S3/Blob) is env-driven and does not change at runtime. */ export const CLOUD_STORAGE_CONFIGURED_STALE_TIME = Number.POSITIVE_INFINITY -/** - * Storage info type - */ -interface StorageInfo { - usedBytes: number - limitBytes: number - percentUsed: number - plan?: string -} - /** * Hook to fetch a single workspace file record by ID. * Shares the `list(workspaceId, 'active')` query key with {@link useWorkspaceFiles} so no extra @@ -467,44 +455,6 @@ export function useWorkspaceFileBinary( }) } -/** - * Fetch storage info from API - */ -async function fetchStorageInfo(signal?: AbortSignal): Promise { - try { - const data = await requestJson(getUsageLimitsContract, { signal }) - - if (data.success && data.storage) { - return { - usedBytes: data.storage.usedBytes, - limitBytes: data.storage.limitBytes, - percentUsed: data.storage.percentUsed, - plan: data.usage?.plan || 'free', - } - } - - return null - } catch (error) { - if (isApiClientError(error) && error.status === 404) { - return null - } - throw error - } -} - -/** - * Hook to fetch storage info - */ -export function useStorageInfo(enabled = true) { - return useQuery({ - queryKey: workspaceFilesKeys.storageInfo(), - queryFn: ({ signal }) => fetchStorageInfo(signal), - enabled, - retry: false, // Don't retry on 404 - staleTime: WORKSPACE_STORAGE_INFO_STALE_TIME, // 1 minute - storage info doesn't change often - }) -} - async function fetchCloudStorageConfigured(signal?: AbortSignal): Promise { const data = await requestJson(fileStorageStatusContract, { signal }) return data.cloudConfigured === true diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 490eae2cc5f..f98748e1ea6 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -399,30 +399,6 @@ export function useWorkspaceSettings(workspaceId: string) { }) } -type UpdateWorkspaceSettingsParams = { workspaceId: string } & Pick< - ContractBodyInput, - 'billedAccountUserId' -> - -/** - * Updates workspace settings (e.g., billing configuration). - * Invalidates the workspace settings cache on success. - */ -export function useUpdateWorkspaceSettings() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ workspaceId, ...updates }: UpdateWorkspaceSettingsParams) => { - return requestJson(updateWorkspaceContract, { params: { id: workspaceId }, body: updates }) - }, - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: workspaceKeys.settings(variables.workspaceId), - }) - }, - }) -} - /** Workspace with admin access metadata. */ export interface AdminWorkspace { id: string diff --git a/apps/sim/hooks/use-operation-access.ts b/apps/sim/hooks/use-operation-access.ts new file mode 100644 index 00000000000..1395e20658c --- /dev/null +++ b/apps/sim/hooks/use-operation-access.ts @@ -0,0 +1,99 @@ +'use client' + +import { useMemo } from 'react' +import { + collectDeniedOperationIds, + isOperationAllowed, + MODEL_SUBBLOCK_ID, + NO_DENIED_OPERATIONS, + OPERATION_SUBBLOCK_ID, + type OperationGateBlock, + pickDefaultOperation, + type SeedValueGate, +} from '@/lib/permission-groups/operation-access' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +export interface OperationAccess { + /** + * Whether the permission config is still loading. Every list this module + * filters reads as unrestricted until it resolves, so a surface that + * *persists* a pick from one must not accept input while this is true. + */ + isPermissionLoading: boolean + /** + * The operation ids of `block` the caller may not run. Empty while the + * config loads, so pickers show everything rather than flashing a short list. + */ + getDeniedOperations: ( + block: OperationGateBlock | null | undefined, + operationIds: Iterable + ) => ReadonlySet + /** + * The operation to seed an unset field with: `preferred` when allowed, else + * the first allowed candidate. + */ + resolveDefaultOperation: ( + block: OperationGateBlock | null | undefined, + candidates: Iterable, + preferred?: string + ) => string | undefined + /** + * A predicate for deciding whether an operation of `block` may be *persisted* + * — or `undefined` while the permission config is still loading. + * + * The withholding is the point. The config resolves as "nothing denied" in + * flight, so a value written during that window would outlive the correction + * that arrives with it. Handing back `undefined` rather than an + * always-`true` predicate means a caller cannot persist without first + * deciding what to do when the answer is unknown. + */ + resolveOperationGate: ( + block: OperationGateBlock | null | undefined + ) => ((operationId: string) => boolean) | undefined + /** + * The veto `prepareBlockState` applies to a new block's declared defaults. + * + * Creation is one-shot, so unlike the pickers it cannot answer "unknown" by + * waiting — a value written there is never revisited. This gate therefore + * rejects both restricted fields until the config resolves, leaving them + * empty for the pickers to fill, and owns that rule so no caller re-derives + * it. Every other field passes through untouched. + */ + resolveSeedGate: (block: OperationGateBlock | null | undefined) => SeedValueGate +} + +/** + * Permission-group access to a block's operations. + * + * The single place the "which operations may this user run, and which one + * should an unset field land on" question is answered, so every surface that + * offers operations — the block editor's dropdown, the agent block's tool list, + * canvas search, block creation — agrees. + */ +export function useOperationAccess(): OperationAccess { + const { isToolAllowed, isModelUsable, isLoading } = usePermissionConfig() + + return useMemo(() => { + const isReady = !isLoading + return { + isPermissionLoading: isLoading, + getDeniedOperations: (block, operationIds) => + isReady + ? collectDeniedOperationIds(block, operationIds, isToolAllowed) + : NO_DENIED_OPERATIONS, + resolveDefaultOperation: (block, candidates, preferred) => + isReady ? pickDefaultOperation(block, candidates, isToolAllowed, preferred) : undefined, + resolveOperationGate: (block) => + isReady + ? (operationId: string) => isOperationAllowed(block, operationId, isToolAllowed) + : undefined, + resolveSeedGate: (block) => (subBlockId, value) => { + if (subBlockId !== OPERATION_SUBBLOCK_ID && subBlockId !== MODEL_SUBBLOCK_ID) return true + if (!isReady) return false + return subBlockId === OPERATION_SUBBLOCK_ID + ? isOperationAllowed(block, value, isToolAllowed) + : isModelUsable(value) + }, + } + }, [isToolAllowed, isModelUsable, isLoading]) +} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index a161bf9a924..0b1cb308568 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -20,9 +20,11 @@ import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, } from '@/lib/permission-groups/types' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { overlayVisibility } from '@/blocks/visibility/context' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' +import { findProviderFromModel } from '@/providers/utils' export interface PermissionConfigResult { config: PermissionGroupConfig @@ -31,8 +33,12 @@ export interface PermissionConfigResult { filterBlocks: (blocks: T[]) => T[] filterProviders: (providerIds: string[]) => string[] isBlockAllowed: (blockType: string) => boolean - isProviderAllowed: (providerId: string) => boolean - isModelAllowed: (model: string) => boolean + /** + * Whether a model is usable at all: allowed by the model denylist *and* by + * the provider allowlist. Both gates apply to every model field, so this is + * the only model predicate the interface exposes. + */ + isModelUsable: (model: string) => boolean isToolAllowed: (toolId: string) => boolean isInvitationsDisabled: boolean isPublicApiDisabled: boolean @@ -56,6 +62,7 @@ export function usePermissionConfig(): PermissionConfigResult { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined const blockOverlayVersion = useCustomBlockOverlayVersion() + const hostContext = useOptionalWorkspaceHostContext() const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig(workspaceId) @@ -94,6 +101,9 @@ export function usePermissionConfig(): PermissionConfigResult { const isBlockAllowed = useMemo(() => { return (blockType: string) => { const normalizedBlockType = blockType.toLowerCase() + if (normalizedBlockType === 'credential_group' && !hostContext?.features?.credentialGroups) { + return false + } const availability = integrationAvailability.get(normalizedBlockType) if ( isDeploymentGatedIntegrationType(normalizedBlockType) && @@ -106,7 +116,7 @@ export function usePermissionConfig(): PermissionConfigResult { if (mergedAllowedIntegrations === null) return true return mergedAllowedIntegrations.includes(normalizedBlockType) } - }, [integrationAvailability, mergedAllowedIntegrations]) + }, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations]) const isProviderAllowed = useMemo(() => { return (providerId: string) => { @@ -115,20 +125,35 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [config.allowedModelProviders]) + /** Indexed so the per-model check stays O(1) over a long denylist. */ + const deniedModelSet = useMemo( + () => new Set(config.deniedModels.map((denied) => denied.toLowerCase())), + [config.deniedModels] + ) + const isModelAllowed = useMemo(() => { + return (model: string) => !deniedModelSet.has(model.toLowerCase()) + }, [deniedModelSet]) + + const isModelUsable = useMemo(() => { return (model: string) => { - if (config.deniedModels.length === 0) return true - const normalized = model.toLowerCase() - return !config.deniedModels.some((denied) => denied.toLowerCase() === normalized) + if (!isModelAllowed(model)) return false + const providerId = findProviderFromModel(model) + /* Only chat models resolve to a provider. A `model` field holding an + embedding, speech, image or video id is not a provider choice, so the + provider allowlist has nothing to say about it — judging it anyway + would read every such id as Ollama and reject it. */ + if (!providerId) return true + return isProviderAllowed(providerId) } - }, [config.deniedModels]) + }, [isModelAllowed, isProviderAllowed]) + + /** Indexed so the per-tool check stays O(1) over a long denylist. */ + const deniedToolSet = useMemo(() => new Set(config.deniedTools), [config.deniedTools]) const isToolAllowed = useMemo(() => { - return (toolId: string) => { - if (config.deniedTools.length === 0) return true - return !config.deniedTools.includes(toolId) - } - }, [config.deniedTools]) + return (toolId: string) => !deniedToolSet.has(toolId) + }, [deniedToolSet]) const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { @@ -166,8 +191,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isProviderAllowed, - isModelAllowed, + isModelUsable, isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, @@ -180,8 +204,7 @@ export function usePermissionConfig(): PermissionConfigResult { filterBlocks, filterProviders, isBlockAllowed, - isProviderAllowed, - isModelAllowed, + isModelUsable, isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, diff --git a/apps/sim/hooks/use-trigger-config-aggregation.ts b/apps/sim/hooks/use-trigger-config-aggregation.ts index 655e011c7f5..a5250dd4963 100644 --- a/apps/sim/hooks/use-trigger-config-aggregation.ts +++ b/apps/sim/hooks/use-trigger-config-aggregation.ts @@ -1,10 +1,7 @@ -import { createLogger } from '@sim/logger' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { getTrigger, isTriggerValid } from '@/triggers' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' -const logger = createLogger('useTriggerConfigAggregation') - /** * Maps old trigger config field names to new subblock IDs for backward compatibility. * This handles field name changes during the migration from modal-based configuration @@ -25,71 +22,6 @@ function mapOldFieldNameToNewSubBlockId(oldFieldName: string): string { return fieldMapping[oldFieldName] || oldFieldName } -/** - * Aggregates individual trigger field subblocks into a triggerConfig object. - * This is called on-demand when saving, not continuously. - * - * @param blockId - The block ID that has the trigger fields - * @param triggerId - The trigger ID to get the config fields from - * @returns The aggregated config object, or null if no valid config - */ - -export function useTriggerConfigAggregation( - blockId: string, - triggerId: string | undefined -): Record | null { - if (!triggerId || !blockId) { - return null - } - - if (!isTriggerValid(triggerId)) { - logger.warn(`Trigger definition not found for ID: ${triggerId}`) - return null - } - - const triggerDef = getTrigger(triggerId) - - const subBlockStore = useSubBlockStore.getState() - - const aggregatedConfig: Record = {} - let hasAnyValue = false - - triggerDef.subBlocks - .filter( - (sb) => - (sb.mode === 'trigger' || sb.mode === 'trigger-advanced') && - !SYSTEM_SUBBLOCK_IDS.includes(sb.id) - ) - .forEach((subBlock) => { - const fieldValue = subBlockStore.getValue(blockId, subBlock.id) - - let valueToUse = fieldValue - if ( - (fieldValue === null || fieldValue === undefined || fieldValue === '') && - subBlock.defaultValue !== undefined - ) { - valueToUse = subBlock.defaultValue - } - - if (valueToUse !== null && valueToUse !== undefined && valueToUse !== '') { - aggregatedConfig[subBlock.id] = valueToUse - hasAnyValue = true - } - }) - - if (!hasAnyValue) { - return null - } - - logger.debug('Aggregated trigger config fields', { - blockId, - triggerId, - aggregatedConfig, - }) - - return aggregatedConfig -} - /** * Populates individual trigger field subblocks from a triggerConfig object. * Used for backward compatibility when loading existing workflows. diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts index 6f0ee065cc1..94e98072645 100644 --- a/apps/sim/lib/admin/dashboard.ts +++ b/apps/sim/lib/admin/dashboard.ts @@ -12,6 +12,7 @@ import { } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { and, count, countDistinct, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm' import { getOrganizationUsageLimitFallbackDollars, @@ -72,9 +73,7 @@ export interface AdminMutationActor { } function metadataRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} + return isRecordLike(value) ? (value as Record) : {} } function metadataNumber(metadata: Record, key: string): number | null { diff --git a/apps/sim/lib/api-key/auth.ts b/apps/sim/lib/api-key/auth.ts index 5a8e44ddcea..9afba959588 100644 --- a/apps/sim/lib/api-key/auth.ts +++ b/apps/sim/lib/api-key/auth.ts @@ -136,25 +136,6 @@ export function formatApiKeyForDisplay(apiKey: string): string { return `...${last4}` } -/** - * Gets the last 4 characters of an encrypted API key by decrypting it first - * @param encryptedKey - The encrypted API key from the database - * @returns Promise - The last 4 characters - */ -async function getEncryptedApiKeyLast4(encryptedKey: string): Promise { - try { - if (isEncryptedKey(encryptedKey)) { - const decryptedKey = await decryptApiKeyFromStorage(encryptedKey) - return getApiKeyLast4(decryptedKey) - } - // For plain text keys (legacy), return last 4 directly - return getApiKeyLast4(encryptedKey) - } catch (error) { - logger.error('Failed to get last 4 characters of API key:', { error }) - return '****' - } -} - /** * Validates API key format (basic validation) * @param apiKey - The API key to validate diff --git a/apps/sim/lib/api/contracts/admin.ts b/apps/sim/lib/api/contracts/admin.ts index a248b9dbe6c..2d4d57f0c55 100644 --- a/apps/sim/lib/api/contracts/admin.ts +++ b/apps/sim/lib/api/contracts/admin.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' import { workflowStateSchema } from '@/lib/api/contracts/workflows' import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces' @@ -103,17 +102,6 @@ export const adminWorkspaceImportQuerySchema = z.object({ rootFolderName: queryStringSchema, }) -export const adminWorkspaceImportBodySchema = z.object({ - workflows: z.array( - z.object({ - content: z.union([z.string(), z.record(z.string(), z.unknown())]), - name: z.string().optional(), - folderPath: z.array(z.string()).optional(), - }), - { error: 'Invalid JSON body. Expected { workflows: [...] }' } - ), -}) - export const adminPaginationMetaSchema = z.object({ total: z.number(), limit: z.number(), @@ -212,38 +200,6 @@ export const adminFolderExportPayloadSchema = z.object({ parentId: z.string().nullable(), }) -export const adminWorkspaceExportPayloadSchema = z.object({ - version: z.literal('1.0'), - exportedAt: z.string(), - workspace: z.object({ - id: z.string(), - name: z.string(), - }), - workflows: z.array( - z.object({ - workflow: adminWorkflowExportPayloadSchema.shape.workflow, - state: adminWorkflowExportStateSchema, - }) - ), - folders: z.array(adminFolderExportPayloadSchema), -}) - -export const adminFolderFullExportPayloadSchema = z.object({ - version: z.literal('1.0'), - exportedAt: z.string(), - folder: z.object({ - id: z.string(), - name: z.string(), - }), - workflows: z.array( - z.object({ - workflow: adminWorkflowExportPayloadSchema.shape.workflow.omit({ workspaceId: true }), - state: adminWorkflowExportStateSchema, - }) - ), - folders: z.array(adminFolderExportPayloadSchema), -}) - export const adminImportResultSchema = z.object({ workflowId: z.string(), name: z.string(), @@ -283,293 +239,3 @@ export const adminDeployResultSchema = z.object({ export const adminUndeployResultSchema = z.object({ isDeployed: z.literal(false), }) - -const adminSingleResponseSchema = (schema: TSchema) => - z.object({ data: schema }) - -const adminListResponseSchema = (schema: TSchema) => - z.object({ - data: z.array(schema), - pagination: adminPaginationMetaSchema, - }) - -export const adminListWorkflowsContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workflows', - query: adminPaginationQuerySchema, - response: { - mode: 'json', - schema: adminListResponseSchema(adminWorkflowSchema), - }, -}) - -export const adminGetWorkflowContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workflows/[id]', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema(adminWorkflowDetailSchema), - }, -}) - -export const adminDeleteWorkflowContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v1/admin/workflows/[id]', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - workflowId: z.string(), - }), - }, -}) - -export const adminDeployWorkflowContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/admin/workflows/[id]/deploy', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema(adminDeployResultSchema), - }, -}) - -export const adminUndeployWorkflowContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v1/admin/workflows/[id]/deploy', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema(adminUndeployResultSchema), - }, -}) - -export const adminListWorkflowVersionsContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workflows/[id]/versions', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema( - z.object({ - versions: z.array(adminDeploymentVersionSchema), - }) - ), - }, -}) - -export const adminActivateWorkflowVersionContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/admin/workflows/[id]/versions/[versionId]/activate', - params: adminWorkflowVersionParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema( - z.object({ - success: z.literal(true), - version: z.number(), - deployedAt: z.string(), - warnings: z.array(z.string()).optional(), - }) - ), - }, -}) - -export const adminExportWorkflowContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workflows/[id]/export', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema(adminWorkflowExportPayloadSchema), - }, -}) - -export const adminExportWorkflowsContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/admin/workflows/export', - query: adminExportFormatQuerySchema, - body: adminExportWorkflowsBodySchema, - response: { - mode: 'binary', - }, -}) - -export const adminImportWorkflowContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/admin/workflows/import', - body: adminWorkflowImportBodySchema, - response: { - mode: 'json', - schema: adminWorkflowImportResponseSchema, - }, -}) - -export const adminListWorkspacesContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workspaces', - query: adminPaginationQuerySchema, - response: { - mode: 'json', - schema: adminListResponseSchema(adminWorkspaceSchema), - }, -}) - -export const adminGetWorkspaceContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workspaces/[id]', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema(adminWorkspaceDetailSchema), - }, -}) - -export const adminListWorkspaceWorkflowsContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workspaces/[id]/workflows', - params: adminIdParamsSchema, - query: adminPaginationQuerySchema, - response: { - mode: 'json', - schema: adminListResponseSchema(adminWorkflowSchema), - }, -}) - -export const adminDeleteWorkspaceWorkflowsContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v1/admin/workspaces/[id]/workflows', - params: adminIdParamsSchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - deleted: z.number(), - }), - }, -}) - -export const adminListWorkspaceFoldersContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workspaces/[id]/folders', - params: adminIdParamsSchema, - query: adminPaginationQuerySchema, - response: { - mode: 'json', - schema: adminListResponseSchema(adminFolderSchema), - }, -}) - -export const adminExportWorkspaceContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workspaces/[id]/export', - params: adminIdParamsSchema, - query: adminExportFormatQuerySchema, - response: { - mode: 'binary', - }, -}) - -export const adminImportWorkspaceContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/admin/workspaces/[id]/import', - params: adminIdParamsSchema, - query: adminWorkspaceImportQuerySchema, - response: { - mode: 'json', - schema: adminWorkspaceImportResponseSchema, - }, -}) - -export const adminListWorkspaceMembersContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workspaces/[id]/members', - params: adminIdParamsSchema, - query: adminPaginationQuerySchema, - response: { - mode: 'json', - schema: adminListResponseSchema(adminWorkspaceMemberSchema), - }, -}) - -export const adminCreateWorkspaceMemberContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/admin/workspaces/[id]/members', - params: adminIdParamsSchema, - body: adminWorkspaceMemberBodySchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema( - adminWorkspaceMemberSchema.extend({ - action: z.enum(['created', 'updated', 'already_member']), - }) - ), - }, -}) - -export const adminDeleteWorkspaceMemberContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v1/admin/workspaces/[id]/members', - params: adminIdParamsSchema, - query: adminDeleteWorkspaceMemberQuerySchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema( - z.object({ - removed: z.literal(true), - userId: z.string(), - workspaceId: z.string(), - }) - ), - }, -}) - -export const adminGetWorkspaceMemberContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/workspaces/[id]/members/[memberId]', - params: adminWorkspaceMemberParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema(adminWorkspaceMemberSchema), - }, -}) - -export const adminUpdateWorkspaceMemberContract = defineRouteContract({ - method: 'PATCH', - path: '/api/v1/admin/workspaces/[id]/members/[memberId]', - params: adminWorkspaceMemberParamsSchema, - body: adminUpdateWorkspaceMemberBodySchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema(adminWorkspaceMemberSchema), - }, -}) - -export const adminRemoveWorkspaceMemberContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v1/admin/workspaces/[id]/members/[memberId]', - params: adminWorkspaceMemberParamsSchema, - response: { - mode: 'json', - schema: adminSingleResponseSchema( - z.object({ - removed: z.literal(true), - memberId: z.string(), - userId: z.string(), - workspaceId: z.string(), - }) - ), - }, -}) - -export const adminExportFolderContract = defineRouteContract({ - method: 'GET', - path: '/api/v1/admin/folders/[id]/export', - params: adminIdParamsSchema, - query: adminExportFormatQuerySchema, - response: { - mode: 'binary', - }, -}) diff --git a/apps/sim/lib/api/contracts/common.ts b/apps/sim/lib/api/contracts/common.ts index ffc7898ce4d..44cd3bc354e 100644 --- a/apps/sim/lib/api/contracts/common.ts +++ b/apps/sim/lib/api/contracts/common.ts @@ -105,20 +105,6 @@ export const getStarsContract = defineRouteContract({ }, }) -export const getStatusContract = defineRouteContract({ - method: 'GET', - path: '/api/status', - response: { - mode: 'json', - schema: z.object({ - status: z.enum(['operational', 'degraded', 'outage', 'maintenance', 'loading', 'error']), - message: z.string(), - url: z.string().url(), - lastUpdated: z.string(), - }), - }, -}) - const jobStatusSchema = z.enum(['pending', 'processing', 'completed', 'failed', 'cancelled']) const jobStatusResponseSchema = z diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index c851c6cff2c..bd26d8a4a45 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -725,22 +725,6 @@ export const revertCopilotCheckpointContract = defineRouteContract({ }, }) -export const copilotChatAbortContract = defineRouteContract({ - method: 'POST', - path: '/api/copilot/chat/abort', - body: copilotChatAbortBodySchema, - response: { - mode: 'json', - schema: z.object({ - aborted: z.boolean(), - settled: z.boolean().optional(), - // True when the stream did not settle within the grace window and the - // chat stream lock was force-broken so the chat is immediately usable. - forceReleased: z.boolean().optional(), - }), - }, -}) - export const copilotChatStreamContract = defineRouteContract({ method: 'GET', path: '/api/copilot/chat/stream', diff --git a/apps/sim/lib/api/contracts/credential-groups.test.ts b/apps/sim/lib/api/contracts/credential-groups.test.ts new file mode 100644 index 00000000000..edbea439b0f --- /dev/null +++ b/apps/sim/lib/api/contracts/credential-groups.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { + createCredentialGroupBodySchema, + credentialGroupEnrollmentDetailSchema, + credentialGroupEnrollmentListQuerySchema, + credentialGroupSchema, + inviteCredentialGroupEnrollmentsBodySchema, + updateCredentialGroupBodySchema, +} from '@/lib/api/contracts/credential-groups' + +describe('credential group contracts', () => { + it('accepts a group before account types are added', () => { + const parsed = createCredentialGroupBodySchema.parse({ + name: 'Support team', + options: [], + }) + + expect(parsed.options).toEqual([]) + }) + + it('accepts one option per provider after the group exists', () => { + const parsed = updateCredentialGroupBodySchema.parse({ + options: [ + { provider: 'gmail', label: 'Gmail', required: true }, + { provider: 'google-calendar', label: 'Google Calendar', required: true }, + { + provider: 'slack', + label: 'Slack', + required: true, + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }, + ], + }) + + expect(parsed.options).toHaveLength(3) + }) + + it('rejects the removed multiple-account option', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { + provider: 'gmail', + label: 'Primary inbox', + required: true, + allowMultiple: true, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects duplicate option labels case-insensitively', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { + provider: 'gmail', + label: 'Inbox', + required: true, + }, + { + provider: 'gmail', + label: 'inbox', + required: true, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects duplicate providers', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { provider: 'gmail', label: 'Primary inbox', required: true }, + { provider: 'gmail', label: 'Escalations', required: true }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('requires a custom bot for Slack option updates', () => { + const missingApp = updateCredentialGroupBodySchema.safeParse({ + options: [ + { + provider: 'slack', + label: 'Slack', + required: true, + }, + ], + }) + const withApp = updateCredentialGroupBodySchema.safeParse({ + options: [ + { + provider: 'slack', + label: 'Slack', + required: true, + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }, + ], + }) + + expect(missingApp.success).toBe(false) + expect(withApp.success).toBe(true) + }) + + it('rejects duplicate option IDs on update', () => { + const option = { + id: 'option-1', + provider: 'gmail' as const, + required: true, + } + const result = updateCredentialGroupBodySchema.safeParse({ + options: [ + { ...option, label: 'Inbox' }, + { ...option, label: 'Escalations' }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects the authorization-app identity from settings responses', () => { + const result = credentialGroupSchema.safeParse({ + id: 'group-1', + workspaceId: 'workspace-1', + name: 'Support team', + description: null, + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Inbox', + required: true, + status: 'active', + authorizationAppId: 'server-only', + }, + ], + status: 'active', + createdAt: '2026-08-10T12:00:00.000Z', + updatedAt: '2026-08-10T12:00:00.000Z', + }) + + expect(result.success).toBe(false) + }) + + it('accepts a batch of invitation emails', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.parse({ + emails: ['alex@example.com', 'sam@example.com'], + }) + + expect(result.emails).toEqual(['alex@example.com', 'sam@example.com']) + }) + + it('rejects invitation batches larger than 100 recipients', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({ + emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`), + }) + + expect(result.success).toBe(false) + }) + + it('rejects invalid invitation email addresses', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({ + emails: ['not-an-email'], + }) + + expect(result.success).toBe(false) + }) + + it('bounds enrollment pages and defaults them to 50 rows', () => { + expect(credentialGroupEnrollmentListQuerySchema.parse({})).toEqual({ limit: 50 }) + expect(credentialGroupEnrollmentListQuerySchema.safeParse({ limit: 101 }).success).toBe(false) + }) + + it('accepts aggregated provider connections on an enrollment', () => { + const result = credentialGroupEnrollmentDetailSchema.parse({ + id: 'enrollment-1', + credentialGroupId: 'group-1', + email: 'alex@example.com', + status: 'completed', + expiresAt: '2026-08-18T12:00:00.000Z', + invitedAt: '2026-08-11T12:00:00.000Z', + sentAt: '2026-08-11T12:00:01.000Z', + completedAt: '2026-08-11T12:05:00.000Z', + revokedAt: null, + expired: false, + createdAt: '2026-08-11T12:00:00.000Z', + updatedAt: '2026-08-11T12:05:00.000Z', + connections: [{ provider: 'gmail', status: 'active', count: 2 }], + }) + + expect(result.connections).toEqual([{ provider: 'gmail', status: 'active', count: 2 }]) + }) +}) diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts new file mode 100644 index 00000000000..37cc07a4fe9 --- /dev/null +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -0,0 +1,426 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, +} from '@/lib/credential-groups/providers' + +export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS) +export const credentialGroupStatusSchema = z.enum(['active', 'disabled']) +export const credentialGroupEnrollmentStatusSchema = z.enum([ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', +]) +export const credentialGroupOptionConfigurationStatusSchema = z.enum([ + 'not_configured', + 'ready', + 'needs_update', +]) + +const credentialGroupOptionFields = { + label: z.string().trim().min(1, 'Option label is required').max(100), + required: z.boolean(), +} as const + +const standardOAuthCredentialGroupOptionInputSchema = z + .object({ + provider: z.enum(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS), + ...credentialGroupOptionFields, + }) + .strict() + +const slackCredentialGroupOptionInputSchema = z + .object({ + provider: z.literal('slack'), + ...credentialGroupOptionFields, + slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + }) + .strict() + +export const credentialGroupOptionInputSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema, + slackCredentialGroupOptionInputSchema, +]) + +export const credentialGroupOptionSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1), + status: z.enum(['active', 'disabled']), + configurationStatus: credentialGroupOptionConfigurationStatusSchema, + }), + slackCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1), + status: z.enum(['active', 'disabled']), + configurationStatus: credentialGroupOptionConfigurationStatusSchema, + }), +]) + +export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1).max(128).optional(), + }), + slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }), +]) + +export const credentialGroupSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + name: z.string(), + description: z.string().nullable(), + options: z.array(credentialGroupOptionSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + status: credentialGroupStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type CredentialGroup = z.output +export type CredentialGroupOption = z.output +export type CredentialGroupOptionInput = z.input + +export const credentialGroupEnrollmentSchema = z.object({ + id: z.string(), + credentialGroupId: z.string(), + email: z.string().email(), + status: credentialGroupEnrollmentStatusSchema, + expiresAt: z.string(), + invitedAt: z.string(), + sentAt: z.string().nullable(), + completedAt: z.string().nullable(), + revokedAt: z.string().nullable(), + expired: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type CredentialGroupEnrollment = z.output + +export const credentialGroupEnrollmentConnectionSchema = z.object({ + provider: credentialGroupProviderSchema, + status: z.enum(['active', 'needs_reauth', 'revoked']), + count: z.number().int().positive(), +}) + +export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({ + connections: z + .array(credentialGroupEnrollmentConnectionSchema) + .max(CREDENTIAL_GROUP_PROVIDER_IDS.length * 3), +}) + +export type CredentialGroupEnrollmentConnection = z.output< + typeof credentialGroupEnrollmentConnectionSchema +> +export type CredentialGroupEnrollmentDetail = z.output + +export const credentialGroupWorkspaceParamsSchema = z.object({ + id: workspaceIdSchema, +}) + +export const credentialGroupDetailParamsSchema = credentialGroupWorkspaceParamsSchema.extend({ + groupId: z.string().min(1, 'Credential group ID is required').max(128), +}) + +export const credentialGroupEnrollmentParamsSchema = credentialGroupDetailParamsSchema.extend({ + enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128), +}) + +export const publicCredentialGroupEnrollmentParamsSchema = z.object({ + token: z.string().min(1, 'Invitation token is required').max(128), +}) + +export const startCredentialGroupOAuthParamsSchema = + publicCredentialGroupEnrollmentParamsSchema.extend({ + optionId: z.string().min(1, 'Credential option ID is required').max(128), + }) + +export const credentialGroupOAuthCallbackQuerySchema = z + .object({ + state: z.string().min(1, 'OAuth state is required').max(512), + code: z.string().min(1).max(2048).optional(), + error: z.string().min(1).max(256).optional(), + error_description: z.string().max(1000).optional(), + }) + .superRefine((query, ctx) => { + if (!query.code && !query.error) { + ctx.addIssue({ + code: 'custom', + path: ['code'], + message: 'OAuth callback must include a code or error', + }) + } + }) + +export const credentialGroupOAuthCallbackParamsSchema = z.object({ + provider: credentialGroupProviderSchema, +}) + +export const startSlackCredentialGroupConfigurationBodySchema = z + .object({ + slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + clientId: z.string().trim().min(1, 'Slack Client ID is required').max(256), + clientSecret: z.string().trim().min(1, 'Slack Client Secret is required').max(512), + }) + .strict() + +export const slackCredentialGroupConfigurationCallbackQuerySchema = + credentialGroupOAuthCallbackQuerySchema + +export const credentialGroupEnrollmentListQuerySchema = z.object({ + cursor: z.string().min(1, 'Enrollment cursor cannot be empty').max(128).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}) + +export const inviteCredentialGroupEnrollmentsBodySchema = z + .object({ + emails: z + .array(z.string().trim().email('Enter a valid email address').max(320)) + .min(1, 'At least one email address is required') + .max(100, 'You can invite at most 100 people at once'), + }) + .strict() + +export type InviteCredentialGroupEnrollmentsBody = z.input< + typeof inviteCredentialGroupEnrollmentsBodySchema +> + +export const credentialGroupEnrollmentInviteResultSchema = z.discriminatedUnion('success', [ + z.object({ + email: z.string().email(), + success: z.literal(true), + enrollment: credentialGroupEnrollmentSchema, + }), + z.object({ + email: z.string().email(), + success: z.literal(false), + error: z.string(), + }), +]) + +export const createCredentialGroupBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100), + description: z.string().trim().max(500).optional(), + options: z.array(credentialGroupOptionInputSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + }) + .strict() + .superRefine((body, ctx) => { + const labels = new Set() + const providers = new Set() + for (const [index, option] of body.options.entries()) { + if (option.provider === 'slack') { + ctx.addIssue({ + code: 'custom', + path: ['options', index], + message: 'Create the Credential Group before configuring Slack', + }) + } + const normalized = option.label.toLocaleLowerCase() + if (labels.has(normalized)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'label'], + message: 'Credential option labels must be unique within a group', + }) + } + labels.add(normalized) + if (providers.has(option.provider)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'provider'], + message: 'Each provider can only be added once', + }) + } + providers.add(option.provider) + } + }) + +export type CreateCredentialGroupBody = z.input + +export const updateCredentialGroupBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100).optional(), + description: z.string().trim().max(500).nullable().optional(), + options: z + .array(credentialGroupOptionUpdateInputSchema) + .max(CREDENTIAL_GROUP_PROVIDER_IDS.length) + .optional(), + status: credentialGroupStatusSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (Object.keys(body).length === 0) { + ctx.addIssue({ code: 'custom', message: 'At least one field must be updated' }) + } + if (!body.options) return + const labels = new Set() + const optionIds = new Set() + const providers = new Set() + for (const [index, option] of body.options.entries()) { + const normalized = option.label.toLowerCase() + if (labels.has(normalized)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'label'], + message: 'Credential option labels must be unique within a group', + }) + } + labels.add(normalized) + if (providers.has(option.provider)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'provider'], + message: 'Each provider can only be added once', + }) + } + providers.add(option.provider) + if (option.id && optionIds.has(option.id)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'id'], + message: 'Credential option IDs must be unique within a group', + }) + } + if (option.id) optionIds.add(option.id) + } + }) + +export type UpdateCredentialGroupBody = z.input + +export const listCredentialGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/credential-groups', + params: credentialGroupWorkspaceParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }), + }, +}) + +export const createCredentialGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups', + params: credentialGroupWorkspaceParamsSchema, + body: createCredentialGroupBodySchema, + response: { + mode: 'json', + status: 201, + schema: z.object({ credentialGroup: credentialGroupSchema }), + }, +}) + +export const getCredentialGroupContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + query: credentialGroupEnrollmentListQuerySchema, + response: { + mode: 'json', + schema: z.object({ + credentialGroup: credentialGroupSchema, + enrollments: z.array(credentialGroupEnrollmentDetailSchema), + nextCursor: z.string().nullable(), + }), + }, +}) + +export const inviteCredentialGroupEnrollmentsContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments', + params: credentialGroupDetailParamsSchema, + body: inviteCredentialGroupEnrollmentsBodySchema, + response: { + mode: 'json', + schema: z.object({ + results: z.array(credentialGroupEnrollmentInviteResultSchema).min(1).max(100), + sentCount: z.number().int().nonnegative(), + failedCount: z.number().int().nonnegative(), + }), + }, +}) + +export const resendCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend', + params: credentialGroupEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) + +export const revokeCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]', + params: credentialGroupEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) + +export const deleteCredentialGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) + +export const updateCredentialGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + body: updateCredentialGroupBodySchema, + response: { + mode: 'json', + schema: z.object({ credentialGroup: credentialGroupSchema }), + }, +}) + +export const startSlackCredentialGroupConfigurationContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users', + params: credentialGroupDetailParamsSchema, + body: startSlackCredentialGroupConfigurationBodySchema, + response: { + mode: 'json', + schema: z.object({ + authorizationUrl: z.string().url(), + state: z.string().min(1), + }), + }, +}) + +export const slackCredentialGroupConfigurationCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/slack-managed-users/callback', + query: slackCredentialGroupConfigurationCallbackQuerySchema, + response: { mode: 'text' }, +}) + +export const startCredentialGroupOAuthContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/enroll/[token]/oauth/[optionId]', + params: startCredentialGroupOAuthParamsSchema, + response: { mode: 'empty' }, +}) + +export const completeCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'POST', + path: '/api/credential-groups/enroll/[token]/complete', + params: publicCredentialGroupEnrollmentParamsSchema, + response: { mode: 'empty' }, +}) + +export const credentialGroupOAuthCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/oauth/[provider]/callback', + params: credentialGroupOAuthCallbackParamsSchema, + query: credentialGroupOAuthCallbackQuerySchema, + response: { mode: 'empty' }, +}) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index e3c00abec1f..944cbd54980 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -17,9 +17,14 @@ export const workspaceCredentialTypeSchema = z.enum([ 'env_personal', 'service_account', ]) +const creatableWorkspaceCredentialTypeSchema = z.enum([ + 'oauth', + 'env_workspace', + 'env_personal', + 'service_account', +]) export const workspaceCredentialRoleSchema = z.enum(['admin', 'member']) export const workspaceCredentialMemberStatusSchema = z.enum(['active', 'pending', 'revoked']) - export const workspaceCredentialSchema = z.object({ id: z.string(), workspaceId: z.string(), @@ -112,7 +117,7 @@ export const serviceAccountJsonSchema = z export const createCredentialBodySchema = z .object({ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema, + type: creatableWorkspaceCredentialTypeSchema, displayName: z.string().trim().min(1).max(255).optional(), description: z.string().trim().max(500).optional(), providerId: z.string().trim().min(1).optional(), diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index 343af5e82a1..e710d9e9ae5 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -59,11 +59,6 @@ export const deploymentVersionOrActiveParamsSchema = z.object({ version: z.union([deploymentVersionPathSchema, z.literal('active')]), }) -export const deploymentVersionRouteParamsSchema = z.object({ - id: z.string().min(1, 'Invalid workflow ID'), - version: z.string().min(1, 'Invalid version'), -}) - export const updatePublicApiBodySchema = z.object({ isPublicApi: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/mcp-oauth.ts b/apps/sim/lib/api/contracts/mcp-oauth.ts index be07e026330..0661c725154 100644 --- a/apps/sim/lib/api/contracts/mcp-oauth.ts +++ b/apps/sim/lib/api/contracts/mcp-oauth.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' const mcpOauthMetadataQuerySchema = z.record(z.string(), z.string()) export type McpOauthMetadataQuery = z.input @@ -33,23 +32,3 @@ const mcpProtectedResourceMetadataSchema = z.object({ x_sim_auth: xSimAuthSchema, }) export type McpProtectedResourceMetadata = z.output - -export const mcpOauthAuthorizationServerMetadataContract = defineRouteContract({ - method: 'GET', - path: '/api/mcp/copilot/.well-known/oauth-authorization-server', - query: mcpOauthMetadataQuerySchema, - response: { - mode: 'json', - schema: mcpAuthorizationServerMetadataSchema, - }, -}) - -export const mcpOauthProtectedResourceMetadataContract = defineRouteContract({ - method: 'GET', - path: '/api/mcp/copilot/.well-known/oauth-protected-resource', - query: mcpOauthMetadataQuerySchema, - response: { - mode: 'json', - schema: mcpProtectedResourceMetadataSchema, - }, -}) diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts index 53ca4e6226a..4c071657695 100644 --- a/apps/sim/lib/api/contracts/mcp.ts +++ b/apps/sim/lib/api/contracts/mcp.ts @@ -267,11 +267,6 @@ export const mcpJsonRpcMessageSchema = z }) .passthrough() -export const mcpRequestBodySchema = z.union([ - mcpJsonRpcMessageSchema, - z.array(mcpJsonRpcMessageSchema), -]) - export const mcpToolCallParamsSchema = z .object({ name: z.string().min(1), @@ -415,27 +410,6 @@ export const discoverMcpToolsContract = defineRouteContract({ }) export type DiscoverMcpToolsResponse = ContractJsonResponse -export const refreshMcpToolsContract = defineRouteContract({ - method: 'POST', - path: '/api/mcp/tools/discover', - query: mcpWorkspaceQuerySchema, - body: refreshMcpToolsBodySchema, - response: { - mode: 'json', - schema: mcpSuccessResponseSchema( - z.object({ - refreshed: z.array(z.object({ serverId: z.string(), toolCount: z.number() })), - failed: z.array(z.object({ serverId: z.string(), error: z.string() })), - summary: z.object({ - total: z.number(), - successful: z.number(), - failed: z.number(), - }), - }) - ), - }, -}) - export const listStoredMcpToolsContract = defineRouteContract({ method: 'GET', path: '/api/mcp/tools/stored', diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c9c4951efd2..234f86419ea 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -7,6 +7,8 @@ import type { } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' +export const MANAGED_OAUTH_DELEGATION_HEADER = 'x-sim-managed-oauth-delegation' + export const oauthAccountSummarySchema = z.object({ id: z.string(), name: z.string(), @@ -70,6 +72,7 @@ export const oauthTokenRequestBodySchema = z credentialId: z.string().min(1).optional(), credentialAccountUserId: z.string().min(1).optional(), providerId: z.string().min(1).optional(), + toolId: z.string().min(1).optional(), workflowId: z.string().min(1).nullish(), scopes: z.array(z.string()).optional(), impersonateEmail: impersonateEmailSchema.optional(), @@ -91,6 +94,10 @@ export const oauthTokenPostQuerySchema = z.object({ userId: z.string().min(1).optional(), }) +export const oauthTokenPostHeadersSchema = z.object({ + [MANAGED_OAUTH_DELEGATION_HEADER]: z.string().min(1).optional(), +}) + const oauthTokenResponseSchema = z.object({ accessToken: z.string(), idToken: z.string().optional(), @@ -119,6 +126,7 @@ export const oauthTokenPostContract = defineRouteContract({ method: 'POST', path: '/api/auth/oauth/token', query: oauthTokenPostQuerySchema, + headers: oauthTokenPostHeadersSchema, body: oauthTokenRequestBodySchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 4e8a605a98f..b9a2d791976 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -2,14 +2,17 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { customPatternSchema, isCanonicalBase64, + MAX_ID_LENGTH, organizationIdSchema, piiStagePolicySchema, piiStagesSchema, privateSecretProvenanceBundleSchema, resolvedSecretTraceProvenanceSchema, + withMissingFieldMessage, workflowIdSchema, workspaceFileIdSchema, workspaceFileNameSchema, @@ -257,4 +260,77 @@ describe('shared id schemas name the field when it is missing', () => { expect(result.error?.issues[0]?.message).not.toContain('received undefined') }) + + /** + * A schema-level `error` string replaces the message for *every* issue, so the + * required-field wording used to answer a wrong-typed value too: `{"name": 123}` + * came back "Name is required" when a name had in fact been supplied. Missing and + * wrong-typed are different mistakes and must read differently. + */ + for (const [name, schema, message] of cases) { + it(`${name}: a wrong-typed value reports the type, not "${message}"`, () => { + const result = schema.safeParse(123) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.message).toBe( + 'Invalid input: expected string, received number' + ) + }) + } + + it('workspaceFileNameSchema separates a missing name from a wrong-typed one', () => { + expect(workspaceFileNameSchema.safeParse(undefined).error?.issues[0]?.message).toBe( + 'Name is required' + ) + expect(workspaceFileNameSchema.safeParse(123).error?.issues[0]?.message).toBe( + 'Invalid input: expected string, received number' + ) + }) +}) + +/** + * An unbounded `workspaceId` reached the workspace lookup at whatever length the + * caller chose. No workspace id this repo mints approaches the bound, so it only + * rejects values that could never have resolved. + */ +describe('workspaceIdSchema length bound', () => { + it('accepts an id at the bound', () => { + expect(workspaceIdSchema.safeParse('a'.repeat(MAX_ID_LENGTH)).success).toBe(true) + }) + + it('rejects an id one character past the bound, naming the field', () => { + const result = workspaceIdSchema.safeParse('a'.repeat(MAX_ID_LENGTH + 1)) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.message).toBe('Workspace ID is too long') + }) + + it('still accepts a UUID workspace id', () => { + expect(workspaceIdSchema.safeParse('7a6cce2b-78b8-40bc-b8d3-0a2a6dfd9023').success).toBe(true) + }) +}) + +describe('withMissingFieldMessage', () => { + const base = z.string().min(1, 'Description is required').max(8, 'Description is too long') + const retrofitted = withMissingFieldMessage(base, 'Description is required') + + it('names the field when the value is omitted', () => { + expect(retrofitted.safeParse(undefined).error?.issues[0]?.message).toBe( + 'Description is required' + ) + }) + + it('keeps Zod default wording for a wrong-typed value', () => { + expect(retrofitted.safeParse(5).error?.issues[0]?.message).toBe( + 'Invalid input: expected string, received number' + ) + }) + + it('preserves the checks the source schema carried', () => { + expect(retrofitted.safeParse('').error?.issues[0]?.message).toBe('Description is required') + expect(retrofitted.safeParse('a'.repeat(9)).error?.issues[0]?.message).toBe( + 'Description is too long' + ) + expect(retrofitted.safeParse('ok').success).toBe(true) + }) }) diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 39b7d6492a8..90306e53060 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -192,34 +192,74 @@ export const jobIdParamsSchema = z.object({ */ export const nonEmptyIdSchema = z.string().min(1) +/** + * Schema-level error customizer that applies a message **only when the value is + * absent**, and defers to Zod's default wording for everything else. + * + * A plain `z.string({ error: message })` replaces the message for *every* issue + * the schema raises, including `invalid_type`. A caller who sent `{"name": 123}` + * then reads `Name is required` — a name was supplied, it was the wrong type, and + * the message sends them looking for the wrong bug. Returning `undefined` for a + * present-but-wrong-typed value lets Zod render `Invalid input: expected string, + * received number` instead. + */ +export function missingFieldError(message: string) { + return (issue: z.core.$ZodRawIssue): string | undefined => + issue.input === undefined ? message : undefined +} + +/** + * Re-issues an existing string schema with a missing-value message, keeping every + * check (bounds, regex, trim) it already carries. + * + * Use this when the field's bounds are owned by a shared schema elsewhere and only + * the omitted-field wording needs to be added at this boundary — re-declaring the + * bounds locally would let the two copies drift. + */ +export function withMissingFieldMessage( + schema: TSchema, + message: string +): TSchema { + return schema.clone({ ...schema._zod.def, error: missingFieldError(message) }) +} + +/** + * Bound shared by the id primitives below. Every identifier this repo mints — + * UUID v4, `wf_`, and the legacy free-form `text` keys — is far shorter, + * so the bound rejects only values that were never going to resolve while keeping + * an unbounded string from reaching a lookup. + */ +export const MAX_ID_LENGTH = 128 + /** * Builds a required, non-empty string schema whose message covers **both** * failure modes. * * `.min(1, message)` alone only fires for a present-but-empty string; an omitted * field falls through to Zod's default `Invalid input: expected string, received - * undefined`, which never names the field the caller left out. Passing the same - * message to the `z.string({ error })` constructor closes that gap. + * undefined`, which never names the field the caller left out. + * {@link missingFieldError} closes that gap without also swallowing the + * wrong-type message. * * Prefer this over a bare `z.string().min(1, '...')` for any required request * field. When a named primitive below already carries the right wording, import * that instead of rebuilding it here. */ export function requiredFieldSchema(message: string) { - return z.string({ error: message }).min(1, message) + return z.string({ error: missingFieldError(message) }).min(1, message) } /** Non-empty `workspaceId` field with a stable, human-readable message. */ -export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required').describe( - 'Unique workspace identifier.' -) +export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required') + .max(MAX_ID_LENGTH, 'Workspace ID is too long') + .describe('Unique workspace identifier.') /** * A single workspace-file name, not a path. Folder placement is carried by a * separate folder id or path field, so separators and dot segments are invalid. */ export const workspaceFileNameSchema = z - .string({ error: 'Name is required' }) + .string({ error: missingFieldError('Name is required') }) .trim() .min(1, 'Name is required') .max(255, 'Name is too long') @@ -257,7 +297,7 @@ export const runIdSchema = z * two-state and three-state spellings stay explicit at each call site. */ export const folderIdSchema = requiredFieldSchema('Folder ID is required').max( - 128, + MAX_ID_LENGTH, 'Folder ID is too long' ) @@ -269,7 +309,7 @@ export const folderIdSchema = requiredFieldSchema('Folder ID is required').max( * UUID-only schema — a `.uuid()` constraint here silently 400s every `wf_` file. */ export const workspaceFileIdSchema = requiredFieldSchema('File ID is required') - .max(128, 'File ID is too long') + .max(MAX_ID_LENGTH, 'File ID is too long') .regex(/^[A-Za-z0-9_-]+$/, 'Invalid file id') /** diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index 6fd4921f673..aeafa475ed3 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -203,12 +203,3 @@ export const updateScheduleContract = defineRouteContract({ schema: messageResponseSchema, }, }) - -export const executeSchedulesContract = defineRouteContract({ - method: 'GET', - path: '/api/schedules/execute', - response: { - mode: 'json', - schema: executeSchedulesResponseSchema, - }, -}) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 2eb9e3b6a50..3bfe28e8794 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -319,13 +319,6 @@ export const wordpressUploadContract = defineRouteContract({ response: { mode: 'json', schema: jsonResponseSchema }, }) -export const sftpListContract = defineRouteContract({ - method: 'POST', - path: '/api/tools/sftp/list', - body: sftpListBodySchema, - response: { mode: 'json', schema: jsonResponseSchema }, -}) - export const sftpDeleteContract = defineRouteContract({ method: 'POST', path: '/api/tools/sftp/delete', diff --git a/apps/sim/lib/api/contracts/tables.test.ts b/apps/sim/lib/api/contracts/tables.test.ts index 739d934acc8..7bdba27d81d 100644 --- a/apps/sim/lib/api/contracts/tables.test.ts +++ b/apps/sim/lib/api/contracts/tables.test.ts @@ -43,6 +43,20 @@ describe('tableRowsQuerySchema includeTotal', () => { }) }) +describe('tableRowsQuerySchema limit', () => { + it('leaves an omitted or empty limit unbounded', () => { + expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1' }).limit).toBeUndefined() + expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', limit: '' }).limit).toBeUndefined() + }) + + it('still parses and validates an explicit limit', () => { + expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', limit: '25' }).limit).toBe(25) + expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', limit: '1000000' }).limit).toBe( + 1000000 + ) + }) +}) + describe('tableEventStreamQuerySchema', () => { it('parses an explicit cursor', () => { expect(tableEventStreamQuerySchema.parse({ from: '7' })).toEqual({ from: 7 }) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 1f98a6cbfaa..f93a6d5438a 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -820,11 +820,21 @@ export const tableRowsQueryBaseSchema = z.object({ .default(true), }) -export const tableRowsQuerySchema = tableRowsQueryBaseSchema.refine( - (data) => !(data.after && data.sort), - { message: 'after cursor cannot be combined with sort — cursors paginate the default order' } +const unboundedTableRowsLimitSchema = z.preprocess( + (value) => (value === null || value === undefined || value === '' ? undefined : Number(value)), + z + .number({ error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(1, 'Limit must be at least 1') + .optional() ) +export const tableRowsQuerySchema = tableRowsQueryBaseSchema + .extend({ limit: unboundedTableRowsLimitSchema }) + .refine((data) => !(data.after && data.sort), { + message: 'after cursor cannot be combined with sort — cursors paginate the default order', + }) + export const updateRowsByFilterBodySchema = z.object({ workspaceId: workspaceIdSchema, filter: bulkFilterSchema, @@ -1063,14 +1073,7 @@ export const rowQueryBodySchema = z.object({ // Omitted limit returns the ENTIRE matching result, failing fast (400) when // it exceeds the response byte budget. An explicit limit caps the page row // count; the byte budget may still end a page early with nextCursor set. - limit: z.preprocess( - (value) => (value === null || value === undefined || value === '' ? undefined : Number(value)), - z - .number({ error: 'Limit must be a number' }) - .int('Limit must be an integer') - .min(1, 'Limit must be at least 1') - .optional() - ), + limit: unboundedTableRowsLimitSchema, cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), }) diff --git a/apps/sim/lib/api/contracts/tools/azure_data_explorer.ts b/apps/sim/lib/api/contracts/tools/azure_data_explorer.ts new file mode 100644 index 00000000000..2abfa8c32fe --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/azure_data_explorer.ts @@ -0,0 +1,168 @@ +import { isPrivateIpHost } from '@sim/security/ssrf' +import { z } from 'zod' +import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** + * Kusto service domains Sim will talk to, each paired with the Microsoft Entra + * authority that issues tokens for it. + * + * A cluster URI is user-supplied, so the proxy is pinned to the documented Azure + * Data Explorer and Fabric Eventhouse domains rather than trusting any HTTPS + * host. Host and authority are declared together on purpose: a sovereign cloud + * authenticates against its own isolated Entra instance, so accepting a cluster + * host without its authority would pass validation and then fail to get a token. + * + * Apex hosts match as well as subdomains, because the documented token + * audiences (`https://api.kusto.windows.net`, + * `https://kusto.fabric.microsoft.com`) sit at the apex. + * + * Every entry is a domain Microsoft documents: the Kusto connection-string + * reference for `kusto.windows.net`, the national-cloud endpoint tables for the + * two sovereign domains, and the Fabric KQL-database REST reference for + * `kusto.fabric.microsoft.com` (both its `queryServiceUri` and + * `ingestionServiceUri` sit under it). Do not add a host without one. + */ +const KUSTO_CLOUDS = [ + { hostSuffix: 'kusto.windows.net', authority: 'https://login.microsoftonline.com' }, + { hostSuffix: 'kusto.fabric.microsoft.com', authority: 'https://login.microsoftonline.com' }, + { hostSuffix: 'kusto.usgovcloudapi.net', authority: 'https://login.microsoftonline.us' }, + { hostSuffix: 'kusto.chinacloudapi.cn', authority: 'https://login.partner.microsoftonline.cn' }, +] as const + +const ALLOWED_CLUSTER_HOSTS = KUSTO_CLOUDS.map((cloud) => cloud.hostSuffix).join(', ') + +function matchKustoCloud(host: string): (typeof KUSTO_CLOUDS)[number] | null { + return ( + KUSTO_CLOUDS.find( + (cloud) => host === cloud.hostSuffix || host.endsWith(`.${cloud.hostSuffix}`) + ) ?? null + ) +} + +/** + * Resolves the Entra authority that issues tokens for a cluster host. Callers + * pass a host already accepted by {@link checkAzureDataExplorerClusterUri}, so + * an unmatched host here means the two fell out of sync and is a bug, not input. + */ +export function resolveEntraAuthority(clusterHost: string): string { + const cloud = matchKustoCloud(clusterHost.toLowerCase()) + if (!cloud) { + throw new Error(`No Microsoft Entra authority is configured for cluster host ${clusterHost}`) + } + return cloud.authority +} + +export function checkAzureDataExplorerClusterUri( + rawUrl: string, + label = 'clusterUri' +): { ok: true; url: URL } | { ok: false; message: string } { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + return { + ok: false, + message: `${label} must be a full URL (e.g., https://mycluster.eastus.kusto.windows.net)`, + } + } + if (parsed.protocol !== 'https:') { + return { ok: false, message: `${label} must use https://` } + } + const host = parsed.hostname.toLowerCase() + if (isPrivateIpHost(host)) { + return { ok: false, message: `${label} host is not allowed (private/loopback range)` } + } + if (!matchKustoCloud(host)) { + return { + ok: false, + message: `${label} host must be an Azure Data Explorer or Fabric Eventhouse endpoint (${ALLOWED_CLUSTER_HOSTS})`, + } + } + return { ok: true, url: parsed } +} + +export function assertSafeAzureDataExplorerClusterUri(rawUrl: string, label?: string): URL { + const result = checkAzureDataExplorerClusterUri(rawUrl, label) + if (!result.ok) throw new Error(result.message) + return result.url +} + +/** + * The exact character set Kusto documents for an identifier: letters, digits, + * underscores, spaces, dots, and dashes, 1-1024 characters. An allowlist rather + * than a denylist, so nothing that could terminate `["..."]` name quoting — or + * that Kusto would reject anyway — reaches a command string. + */ +const entityNameSchema = z + .string() + .trim() + .min(1, 'name is required') + .max(1024, 'name must be at most 1024 characters') + .regex( + /^[\p{L}\p{N}_ .-]+$/u, + 'name may contain only letters, digits, underscores, spaces, dots, and dashes' + ) + +/** A Microsoft Entra tenant is addressed by GUID or by verified domain name. */ +const tenantIdSchema = z + .string() + .trim() + .min(1, 'tenantId is required') + .max(253, 'tenantId is too long') + .regex( + /^[A-Za-z0-9][A-Za-z0-9.-]*$/, + 'tenantId must be a GUID or a domain name (e.g., contoso.onmicrosoft.com)' + ) + +export const azureDataExplorerEndpointSchema = z.enum(['query', 'mgmt']) + +export const azureDataExplorerProxyBodySchema = z + .object({ + clusterUri: z.string().min(1, 'clusterUri is required'), + tenantId: tenantIdSchema, + clientId: z.string().min(1, 'clientId is required'), + clientSecret: z.string().min(1, 'clientSecret is required'), + /** + * Microsoft Entra token audience. Defaults to the cluster's own origin, which + * is the form the Kusto REST reference uses for client-credential tokens. + */ + resource: z.string().optional(), + endpoint: azureDataExplorerEndpointSchema, + database: entityNameSchema.optional(), + csl: z.string().min(1, 'csl is required').max(1_000_000, 'csl is too long'), + properties: z.record(z.string(), z.unknown()).optional(), + /** Sends `x-ms-readonly`, which makes the cluster reject data-changing requests. */ + readOnly: z.boolean().optional(), + }) + .superRefine((req, ctx) => { + const clusterCheck = checkAzureDataExplorerClusterUri(req.clusterUri) + if (!clusterCheck.ok) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clusterUri'], + message: clusterCheck.message, + }) + } + if (req.resource === undefined) return + const resourceCheck = checkAzureDataExplorerClusterUri(req.resource, 'resource') + if (!resourceCheck.ok) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['resource'], + message: resourceCheck.message, + }) + } + }) + +export type AzureDataExplorerProxyRequest = z.infer + +export const azureDataExplorerProxyContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/azure_data_explorer/proxy', + body: azureDataExplorerProxyBodySchema, + response: { + mode: 'json', + schema: genericToolResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/tools/grafana.ts b/apps/sim/lib/api/contracts/tools/grafana.ts index 515a25f06bf..227353dabf5 100644 --- a/apps/sim/lib/api/contracts/tools/grafana.ts +++ b/apps/sim/lib/api/contracts/tools/grafana.ts @@ -28,8 +28,11 @@ const grafanaUpdateDashboardOutputSchema = z.object({ export const grafanaUpdateDashboardResponseSchema = z.object({ success: z.boolean(), - output: grafanaUpdateDashboardOutputSchema, + /** Absent on the auth short-circuit, `{}` on handled failures. */ + output: grafanaUpdateDashboardOutputSchema.partial().optional(), error: z.string().optional(), + /** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */ + details: z.array(z.unknown()).optional(), }) const grafanaUpdateAlertRuleBodySchema = z.object({ @@ -60,6 +63,7 @@ const grafanaUpdateAlertRuleOutputSchema = z.object({ uid: z.string().nullable(), title: z.string().nullable(), condition: z.string().nullable(), + /** untyped-response: alert query stages are opaque, data-source-specific payloads. */ data: z.array(z.unknown()), updated: z.string().nullable(), noDataState: z.string().nullable(), @@ -74,14 +78,19 @@ const grafanaUpdateAlertRuleOutputSchema = z.object({ ruleGroup: z.string().nullable(), orgID: z.number().nullable(), provenance: z.string(), + /** untyped-response: Grafana's notification settings shape is undocumented. */ notification_settings: z.record(z.string(), z.unknown()).nullable(), + /** untyped-response: recording-rule config is passed through opaquely. */ record: z.record(z.string(), z.unknown()).nullable(), }) export const grafanaUpdateAlertRuleResponseSchema = z.object({ success: z.boolean(), - output: z.union([grafanaUpdateAlertRuleOutputSchema, z.object({})]), + /** Absent on the auth short-circuit, `{}` on handled failures. */ + output: z.union([grafanaUpdateAlertRuleOutputSchema, z.object({})]).optional(), error: z.string().optional(), + /** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */ + details: z.array(z.unknown()).optional(), }) const grafanaUpdateFolderBodySchema = z.object({ @@ -112,8 +121,40 @@ const grafanaUpdateFolderOutputSchema = z.object({ export const grafanaUpdateFolderResponseSchema = z.object({ success: z.boolean(), - output: z.union([grafanaUpdateFolderOutputSchema, z.object({})]), + /** Absent on the auth short-circuit, `{}` on handled failures. */ + output: z.union([grafanaUpdateFolderOutputSchema, z.object({})]).optional(), error: z.string().optional(), + /** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */ + details: z.array(z.unknown()).optional(), +}) + +const grafanaCheckDataSourceHealthBodySchema = z.object({ + apiKey: z.string().min(1, 'Grafana Service Account Token is required'), + baseUrl: z.string().min(1, 'Grafana instance URL is required'), + organizationId: z.string().optional(), + dataSourceUid: z.string().min(1, 'Data source UID is required').max(40, 'UID is too long'), +}) + +const grafanaCheckDataSourceHealthOutputSchema = z.object({ + status: z.string(), + message: z.string().nullable(), + /** untyped-response: health detail is whatever the data source plugin chooses to attach. */ + details: z.unknown().optional(), +}) + +export const grafanaCheckDataSourceHealthResponseSchema = z.object({ + success: z.boolean(), + output: grafanaCheckDataSourceHealthOutputSchema.optional(), + error: z.string().optional(), + /** untyped-response: Zod issue objects, whose shape is Zod's, not ours to pin. */ + details: z.array(z.unknown()).optional(), +}) + +export const grafanaCheckDataSourceHealthContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/grafana/check_data_source_health', + body: grafanaCheckDataSourceHealthBodySchema, + response: { mode: 'json', schema: grafanaCheckDataSourceHealthResponseSchema }, }) export const grafanaUpdateDashboardContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tools/media/image.ts b/apps/sim/lib/api/contracts/tools/media/image.ts index 81ac09927e7..3429b861b15 100644 --- a/apps/sim/lib/api/contracts/tools/media/image.ts +++ b/apps/sim/lib/api/contracts/tools/media/image.ts @@ -43,13 +43,6 @@ export const imageToolBodySchema = z export type ImageToolBody = z.infer -export const imageProxyContract = defineRouteContract({ - method: 'GET', - path: '/api/tools/image', - query: imageProxyQuerySchema, - response: { mode: 'binary' }, -}) - export const imageToolContract = defineRouteContract({ method: 'POST', path: '/api/tools/image', diff --git a/apps/sim/lib/api/contracts/v1/copilot.ts b/apps/sim/lib/api/contracts/v1/copilot.ts index f09626e41a2..4752c61762f 100644 --- a/apps/sim/lib/api/contracts/v1/copilot.ts +++ b/apps/sim/lib/api/contracts/v1/copilot.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' export const v1CopilotChatBodySchema = z.object({ @@ -14,30 +13,3 @@ export const v1CopilotChatBodySchema = z.object({ }) export type V1CopilotChatBody = z.output - -const v1CopilotChatToolCallSchema = z.object({ - id: z.string(), - name: z.string(), - status: z.string(), - params: z.record(z.string(), z.unknown()).optional(), - // untyped-response: copilot tool result is the user-defined output of an arbitrary tool invocation - result: z.unknown().optional(), - error: z.string().optional(), - durationMs: z.number().optional(), -}) - -export const v1CopilotChatContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/copilot/chat', - body: v1CopilotChatBodySchema, - response: { - mode: 'json', - schema: z.object({ - success: z.boolean(), - content: z.string().optional(), - toolCalls: z.array(v1CopilotChatToolCallSchema).optional(), - chatId: z.string().optional(), - error: z.string().optional(), - }), - }, -}) diff --git a/apps/sim/lib/api/contracts/v1/files.ts b/apps/sim/lib/api/contracts/v1/files.ts index c196e0953cf..33ba4ba22c9 100644 --- a/apps/sim/lib/api/contracts/v1/files.ts +++ b/apps/sim/lib/api/contracts/v1/files.ts @@ -35,15 +35,6 @@ export const v1ListFilesContract = defineRouteContract({ }, }) -export const v1UploadFileContract = defineRouteContract({ - method: 'POST', - path: '/api/v1/files', - response: { - mode: 'json', - schema: v1FilesResponseSchema, - }, -}) - export const v1DownloadFileContract = defineRouteContract({ method: 'GET', path: '/api/v1/files/[fileId]', diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 30fdfcc42cc..b422d7792c0 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -334,6 +334,29 @@ export const v2FileWorkspaceQuerySchema = z export type V2FileWorkspaceQuery = z.output +/** + * Metadata read: the workspace scope plus the same `scope` lifecycle selector the + * list endpoint uses, so a caller that found a file under `GET /files?scope=archived` + * can read it back with the identical spelling. + * + * The default stays `active`, which keeps the read on the live set and continues to + * answer `404` for a soft-deleted file. `scope` only relaxes the `deleted_at` predicate + * on the row lookup — the workspace the file belongs to, the asserted-workspace check, + * and the operation's authorization are unchanged, so it cannot widen who may read. + */ +export const v2GetFileMetadataQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), + scope: v2FileScopeSchema + .default('active') + .describe( + 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.' + ), + }) + .strict() + +export type V2GetFileMetadataQuery = z.output + export const v2RenameFileBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the file.'), @@ -610,7 +633,7 @@ export const v2GetFileContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/[fileId]/metadata', params: v2FileParamsSchema, - query: v2FileWorkspaceQuerySchema, + query: v2GetFileMetadataQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2FileMetadataSchema), diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 52cdf284756..b30e5900a1a 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -836,8 +836,16 @@ export const MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS = 10 */ export const MAX_V2_KNOWLEDGE_SEARCH_QUERY_LENGTH = 8192 * 4 -export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema - .safeExtend({ +/** + * Rebuilt from the v1 shape rather than extended from the v1 schema: v1 carries + * the "query or tagFilters" rule as a bare `.refine`, which reports at path `[]`, + * so no client could attach the failure to a field. The rule is restated below as + * a `superRefine` with a `path` — extending v1 would inherit the pathless issue + * alongside it and report the same violation twice. + */ +export const v2KnowledgeSearchBodySchema = z + .object({ + ...v1KnowledgeSearchBodySchema.shape, workspaceId: v1KnowledgeSearchBodySchema.shape.workspaceId.describe( 'Workspace that owns the knowledge bases.' ), @@ -855,9 +863,14 @@ export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema `Natural-language query; required when tag filters are omitted. At most ${MAX_V2_KNOWLEDGE_SEARCH_QUERY_LENGTH} characters — longer text exceeds the embedding model's per-input token ceiling and would be truncated before the billed search ran.` ) .meta({ examples: ['How do I reset my password?'] }), - topK: v1KnowledgeSearchBodySchema.shape.topK.describe( - 'Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.' - ), + topK: z + .number() + .min(1, 'topK must be at least 1') + .max(100, 'topK cannot exceed 100') + .default(10) + .describe( + 'Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search.' + ), tagFilters: z .array(v2KnowledgeSearchTagFilterSchema) .max( @@ -909,6 +922,22 @@ export const v2KnowledgeSearchBodySchema = v1KnowledgeSearchBodySchema * parameters never arrived. */ .strict() + /** + * A search with neither a query nor a tag filter has nothing to retrieve on. + * Reported on `query`, the field a caller who sent neither is most likely to be + * missing, so the failure lands on an input instead of on the request as a whole. + */ + .superRefine((body, ctx) => { + const hasQuery = Boolean(body.query && body.query.trim().length > 0) + const hasTagFilters = Boolean(body.tagFilters && body.tagFilters.length > 0) + if (!hasQuery && !hasTagFilters) { + ctx.addIssue({ + code: 'custom', + path: ['query'], + message: 'Either query or tagFilters must be provided', + }) + } + }) export type V2KnowledgeSearchBody = z.input export const v2SearchKnowledgeContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/required-field-messages.test.ts b/apps/sim/lib/api/contracts/v2/required-field-messages.test.ts new file mode 100644 index 00000000000..e72f4e2a57d --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/required-field-messages.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { v2KnowledgeSearchBodySchema } from '@/lib/api/contracts/v2/knowledge' +import { v2CreateSkillBodySchema } from '@/lib/api/contracts/v2/skills' +import { v2CreateWorkflowBodySchema } from '@/lib/api/contracts/v2/workflows' + +const workspaceId = '7a6cce2b-78b8-40bc-b8d3-0a2a6dfd9023' +const knowledgeBaseIds = ['ae814592-a730-4ad6-8741-b51e48843300'] + +function messageAt( + result: { success: boolean; error?: { issues: { path: PropertyKey[]; message: string }[] } }, + field: string +) { + return result.error?.issues.find((issue) => issue.path[0] === field)?.message +} + +/** + * A required field that is *omitted* and one that is *wrong-typed* are different + * mistakes. Both used to answer with wording that pointed at the other: the + * create bodies leaked Zod's default "expected string, received undefined" for an + * omitted field, and the shared string primitives answered "… is required" for a + * value that had in fact been supplied. + */ +describe('v2 create bodies name a missing required field', () => { + it('POST /v2/workflows names an omitted name and types a wrong-typed one', () => { + const missing = v2CreateWorkflowBodySchema.safeParse({ workspaceId }) + expect(messageAt(missing, 'name')).toBe('name is required') + + const wrongType = v2CreateWorkflowBodySchema.safeParse({ workspaceId, name: 123 }) + expect(messageAt(wrongType, 'name')).toBe('Invalid input: expected string, received number') + + const empty = v2CreateWorkflowBodySchema.safeParse({ workspaceId, name: ' ' }) + expect(messageAt(empty, 'name')).toBe('name is required') + }) + + it('POST /v2/workflows still names an omitted workspaceId', () => { + const result = v2CreateWorkflowBodySchema.safeParse({ name: 'Triage' }) + expect(messageAt(result, 'workspaceId')).toBe('Workspace ID is required') + }) + + it('POST /v2/skills names each omitted required field', () => { + const result = v2CreateSkillBodySchema.safeParse({ workspaceId }) + + expect(messageAt(result, 'name')).toBe('Skill name is required') + expect(messageAt(result, 'description')).toBe('Description is required') + expect(messageAt(result, 'content')).toBe('Content is required') + }) + + it('POST /v2/skills types a wrong-typed field instead of calling it missing', () => { + const result = v2CreateSkillBodySchema.safeParse({ + workspaceId, + name: 'my-skill', + description: 123, + content: 'Body', + }) + + expect(messageAt(result, 'description')).toBe('Invalid input: expected string, received number') + }) + + it('POST /v2/skills keeps the bounds the shared field schemas carry', () => { + const badName = v2CreateSkillBodySchema.safeParse({ + workspaceId, + name: 'Not Kebab', + description: 'A summary', + content: 'Body', + }) + expect(messageAt(badName, 'name')).toBe('Name must be kebab-case (e.g. my-skill)') + + const longContent = v2CreateSkillBodySchema.safeParse({ + workspaceId, + name: 'my-skill', + description: 'A summary', + content: 'a'.repeat(50_001), + }) + expect(messageAt(longContent, 'content')).toBe('Content is too large') + + expect( + v2CreateSkillBodySchema.safeParse({ + workspaceId, + name: 'my-skill', + description: 'A summary', + content: 'Body', + }).success + ).toBe(true) + }) +}) + +/** + * `topK` is the one search field whose range violations answered with Zod's + * default phrasing while every sibling — `limit`, `rerankerInputCount`, + * `tagFilters`, `query` — named itself. + */ +describe('v2 knowledge search topK messages name the field', () => { + it('names topK when it exceeds the maximum', () => { + const result = v2KnowledgeSearchBodySchema.safeParse({ + workspaceId, + knowledgeBaseIds, + query: 'hello', + topK: 101, + }) + + expect(messageAt(result, 'topK')).toBe('topK cannot exceed 100') + }) + + it('names topK when it is below the minimum', () => { + const result = v2KnowledgeSearchBodySchema.safeParse({ + workspaceId, + knowledgeBaseIds, + query: 'hello', + topK: 0, + }) + + expect(messageAt(result, 'topK')).toBe('topK must be at least 1') + }) + + it('keeps admitting a fractional topK for the use case to reject, and defaults to 10', () => { + const fractional = v2KnowledgeSearchBodySchema.safeParse({ + workspaceId, + knowledgeBaseIds, + query: 'hello', + topK: 2.5, + }) + expect(fractional.success).toBe(true) + + const defaulted = v2KnowledgeSearchBodySchema.safeParse({ + workspaceId, + knowledgeBaseIds, + query: 'hello', + }) + expect(defaulted.success && defaulted.data.topK).toBe(10) + }) +}) + +/** + * The "query or tagFilters" rule arrived from v1 as a bare `.refine`, which + * reports at path `[]` — a message no client could attach to a field. + */ +describe('v2 knowledge search reports the missing-input rule on a field', () => { + it('attaches the failure to query', () => { + const result = v2KnowledgeSearchBodySchema.safeParse({ workspaceId, knowledgeBaseIds }) + + expect(result.success).toBe(false) + expect(result.error?.issues).toHaveLength(1) + expect(result.error?.issues[0]?.path).toEqual(['query']) + expect(result.error?.issues[0]?.message).toBe('Either query or tagFilters must be provided') + }) + + it('still accepts a tag-only search and a query-only search', () => { + expect( + v2KnowledgeSearchBodySchema.safeParse({ + workspaceId, + knowledgeBaseIds, + tagFilters: [{ tagName: 'From', operator: 'contains', value: 'brex' }], + }).success + ).toBe(true) + + expect( + v2KnowledgeSearchBodySchema.safeParse({ workspaceId, knowledgeBaseIds, query: 'hello' }) + .success + ).toBe(true) + }) + + it('rejects a whitespace-only query with no tag filters, as v1 did', () => { + const result = v2KnowledgeSearchBodySchema.safeParse({ + workspaceId, + knowledgeBaseIds, + query: ' ', + }) + + expect(result.success).toBe(false) + expect(messageAt(result, 'query')).toBe('Either query or tagFilters must be provided') + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index fe2a8cd026d..03dd5a0f230 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + noInputSchema, + nonEmptyIdSchema, + withMissingFieldMessage, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { skillContentSchema, skillDescriptionSchema, @@ -113,14 +118,23 @@ export const v2ListSkillsQuerySchema = v2SkillWorkspaceQuerySchema export type V2ListSkillsQuery = z.output +/** + * Create body. Every field is required, so each one carries the missing-value + * wording the shared field primitives cannot: those are also spelled `.optional()` + * on the update body, where an omitted field is legal, so the message belongs + * here rather than on the shared schema. + */ export const v2CreateSkillBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace in which to create the skill.'), - name: skillNameSchema.describe( + name: withMissingFieldMessage(skillNameSchema, 'Skill name is required').describe( 'Kebab-case name, unique within the workspace and not reserved by a built-in skill.' ), - description: skillDescriptionSchema.describe('One-line summary of when the skill applies.'), - content: skillContentSchema.describe( + description: withMissingFieldMessage( + skillDescriptionSchema, + 'Description is required' + ).describe('One-line summary of when the skill applies.'), + content: withMissingFieldMessage(skillContentSchema, 'Content is required').describe( 'Skill body containing the instructions given to the agent.' ), }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 8d0aa30a245..4432437b508 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -9,6 +9,7 @@ import { } from '@/lib/api/contracts/deployments' import { booleanQueryFlagSchema, + missingFieldError, noInputSchema, runIdSchema, workspaceIdSchema, @@ -398,7 +399,7 @@ export const v2CreateWorkflowBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace in which to create the workflow.'), name: z - .string() + .string({ error: missingFieldError('name is required') }) .trim() .min(1, 'name is required') .max(255, 'name is too long') @@ -1348,25 +1349,29 @@ export const v2CancelWorkflowRunDataSchema = z redisAvailable: z .boolean() .describe('Whether the distributed cancellation channel was available.'), - durablyRecorded: z.boolean().describe('Whether cancellation was recorded durably.'), + durablyRecorded: z + .boolean() + .describe( + 'Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written.' + ), locallyAborted: z.boolean().describe('Whether an in-process execution was aborted.'), pausedCancelled: z.boolean().describe('Whether a paused execution was cancelled.'), /** * Always emitted by the cancellation service — it is not a partial-failure - * marker. `recorded` is the full-success value; the other four name the step - * that degraded. + * marker. `recorded` is the full-success value; the `already_*` values name + * a terminal no-op; the rest name the step that degraded. */ reason: cancelWorkflowExecutionReasonSchema .optional() .describe( - 'Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.' + 'Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.' ), }) .meta({ id: 'CancelWorkflowRunResult', title: 'Cancel workflow run result', description: - 'Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, so poll the run to observe its final state.', + 'Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed.', }) export type V2CancelWorkflowRunData = z.output diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index 720a49ad3b4..5363c77f9c1 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -295,15 +295,3 @@ export const tiktokWebhookResponseSchema = z.union([ z.object({ ok: z.literal(true) }), z.object({ error: z.string().min(1) }), ]) - -export const tiktokWebhookContract = defineRouteContract({ - method: 'POST', - path: '/api/webhooks/tiktok', - headers: tiktokWebhookHeadersSchema, - // Body is validated after HMAC verification against the raw payload. - body: tiktokWebhookEnvelopeSchema, - response: { - mode: 'json', - schema: tiktokWebhookResponseSchema, - }, -}) diff --git a/apps/sim/lib/api/contracts/workflows.test.ts b/apps/sim/lib/api/contracts/workflows.test.ts index ad4a68c59da..5f026cfa9b6 100644 --- a/apps/sim/lib/api/contracts/workflows.test.ts +++ b/apps/sim/lib/api/contracts/workflows.test.ts @@ -135,13 +135,12 @@ describe('workflow contracts', () => { * The v2 cancel endpoint presents `cancelWorkflowRun`'s result unchanged, and * that use case delegates wholly to the cancellation service — so it cannot * emit the outcomes the internal route resolves for itself. Folding those into - * the service enum would publish four reasons v2 never returns, because the - * v2 contract documents this enum value by value. + * the service enum would publish reasons v2 never returns, because the v2 + * contract documents this enum value by value. */ it('keeps internal-only cancellation reasons out of the enum v2 publishes', () => { for (const reason of [ 'queue_cancelled', - 'already_cancelled', 'active_resume_signal_failed', 'cancellation_not_finalized', ]) { @@ -150,6 +149,19 @@ describe('workflow contracts', () => { } }) + /** + * Both surfaces answer a cancel against an already-terminal run, so both name + * it with the same member. The service observes the terminal status itself now + * — the internal route no longer owns `already_cancelled` privately — and + * without these the v2 contract rejects the very body v2 emits. + */ + it('shares the terminal no-op vocabulary between both cancel surfaces', () => { + for (const reason of ['already_cancelled', 'already_completed', 'already_failed']) { + expect(cancelWorkflowExecutionReasonSchema.options).toContain(reason) + expect(internalCancelWorkflowExecutionReasonSchema.options).toContain(reason) + } + }) + /** * `workflowStateSchema` is the PUT `/api/workflows/[id]/state` body and also * the `state` slot of the GET response. A stored value outside these bounds diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 45a29933d25..e6580a60954 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -509,13 +509,6 @@ export const importWorkflowAsSuperuserBodySchema = z.object({ export type ImportWorkflowAsSuperuserBody = z.input -export const importWorkflowAsSuperuserPermissiveBodySchema = z - .object({ - workflowId: z.string().optional(), - targetWorkspaceId: z.string().optional(), - }) - .passthrough() - export const importWorkflowAsSuperuserResponseSchema = z.object({ success: z.literal(true), newWorkflowId: z.string(), @@ -669,9 +662,16 @@ export const workflowExecutionStatusQuerySchema = z.object({ * `lib/execution/cancel-workflow-execution` (contracts stay import-clean of * server modules). Keeping the internal route's extra outcomes out of here is * what stops the published v2 schema advertising reasons v2 cannot emit. + * + * `already_cancelled`/`already_completed`/`already_failed` report a run that was + * already terminal when the request arrived: the request is satisfied, but no + * durable write happened, so they always pair with `durablyRecorded: false`. */ export const cancelWorkflowExecutionReasonSchema = z.enum([ 'recorded', + 'already_cancelled', + 'already_completed', + 'already_failed', 'redis_unavailable', 'redis_write_failed', 'paused_event_publish_failed', @@ -679,17 +679,20 @@ export const cancelWorkflowExecutionReasonSchema = z.enum([ ]) /** - * The internal route's vocabulary. It resolves four outcomes before the service - * is ever reached: `queue_cancelled` (the run was still queued, so no execution - * log row existed), `already_cancelled` (reconciling a run already cancelled), - * and the two stop-signal failures. Several ride on `success: true` responses, - * so validating them against the service enum makes `requestJson` reject - * cancellations that genuinely applied. + * The internal route's vocabulary. It reimplements cancellation rather than + * calling the service, so it resolves three further outcomes of its own: + * `queue_cancelled` (the run was still queued, so no execution log row existed), + * `active_resume_signal_failed`, and `cancellation_not_finalized`. Several ride + * on `success: true` responses, so validating them against the service enum + * makes `requestJson` reject cancellations that genuinely applied. + * + * The `already_*` outcomes are no longer route-local: the service now observes + * the run's terminal status itself, so both surfaces name a terminal no-op with + * the same member. */ export const internalCancelWorkflowExecutionReasonSchema = z.enum([ ...cancelWorkflowExecutionReasonSchema.options, 'queue_cancelled', - 'already_cancelled', 'active_resume_signal_failed', 'cancellation_not_finalized', ]) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 5210e774c75..0485b7abfae 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -161,14 +161,6 @@ export const workspaceMemberSchema = z.object({ export type WorkspaceMember = z.output -export const workspacePreviewBodySchema = z - .object({ - code: z - .string({ error: 'code is required' }) - .refine((code) => code.trim().length > 0, { message: 'code is required' }), - }) - .passthrough() - export const workspaceMetricsExecutionsQuerySchema = z.object({ startTime: z.string().optional(), endTime: z.string().optional(), @@ -264,6 +256,11 @@ export const workspaceHostContextSchema = z.object({ isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), }), + features: z + .object({ + credentialGroups: z.boolean(), + }) + .optional(), }) export type WorkspaceHostContext = z.output diff --git a/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts index 6885f3d1a4d..bfb078e8c2f 100644 --- a/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts @@ -190,6 +190,31 @@ describe('defineV2BodyLifecycleRoute', () => { expect(response.headers.get('x-request-id')).toBeTruthy() }) + /** + * `v2InvalidBodyResponse` answers `415` when an unreadable body declared a + * non-JSON media type, and `multipart/form-data` is exactly such a type. That + * change is argued safe for this builder in prose — its contract must omit the + * body schema, so `parseRequest` never attempts a JSON read and the + * classification is unreachable — but nothing executed it. A multipart upload + * is the shape this builder exists for, so it gets a test rather than a + * paragraph. + */ + it('accepts a multipart body, which the JSON builder would classify as 415', async () => { + const form = new FormData() + form.append('file', new Blob([new Uint8Array([1, 2, 3])]), 'data.bin') + const request = new NextRequest( + 'http://localhost/api/v2/body-lifecycle/item-1?workspaceId=workspace-1', + { method: 'POST', headers: { 'x-api-key': 'secret' }, body: form } + ) + expect(request.headers.get('content-type')).toContain('multipart/form-data') + + const response = await buildHandler()(request, context()) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ data: { id: 'item-1' } }) + expect(mocks.order).toContain('body') + }) + it('rejects at the IP abuse limit before authentication', async () => { v2RouteMocks.preauthRate.mockImplementation(async () => { mocks.order.push('ip-limit') diff --git a/apps/sim/lib/api/server/routes/v2-json-route.test.ts b/apps/sim/lib/api/server/routes/v2-json-route.test.ts index 45dfd51000a..54755d94317 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -495,6 +495,123 @@ describe('defineV2JsonRoute', () => { }) }) +/** + * A body that cannot be read as JSON has two very different causes, and the + * single `400 "Request body must be valid JSON"` describes only one of them: a + * caller who sent a form-encoded body is told to go hunting for a syntax error + * in a body that has none. + * + * These pin the split to the *classification* of an already-failing read. The + * final two are the regression guard that keeps it from becoming a media-type + * gate: a body that parses as JSON still succeeds no matter what the caller + * declared, which is what keeps `curl -d '{…}'` (form-urlencoded by default) + * and a headerless browser `fetch` (`text/plain`) working. + */ +describe('defineV2JsonRoute unreadable body classification', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + /** + * A `string` body makes undici *derive* `content-type: text/plain;charset=UTF-8`, + * so omitting the header from `headers` is not enough to produce the + * absent-media-type request — the `null` case has to send pre-encoded bytes. + * The assertion is the guard that keeps that from silently drifting back: + * without it the two `contentType === null` cases secretly re-test `text/plain` + * and the `if (!header) return false` branch never runs. + */ + function bodyRequest(contentType: string | null, body: string): NextRequest { + const request = new NextRequest('http://localhost/api/v2/widgets', { + method: 'POST', + headers: { + 'x-api-key': 'secret', + ...(contentType === null ? {} : { 'content-type': contentType }), + }, + body: contentType === null ? new TextEncoder().encode(body) : body, + }) + if (contentType === null) expect(request.headers.get('content-type')).toBeNull() + return request + } + + it('answers 415 when an unreadable body declared a non-JSON media type', async () => { + const response = await createHandler()( + bodyRequest('application/x-www-form-urlencoded', 'value=ok') + ) + + expect(response.status).toBe(415) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Request body must be sent as application/json', + }, + }) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('keeps 400 for a truncated JSON body, whose media type was right', async () => { + const response = await createHandler()(bodyRequest('application/json', '{"value":')) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + }) + }) + + it('keeps 400 when the media type is absent rather than wrong', async () => { + const response = await createHandler()(bodyRequest(null, '{"value":')) + + expect(response.status).toBe(400) + }) + + it('keeps 400 for text/plain, the default of a headerless browser fetch', async () => { + const response = await createHandler()(bodyRequest('text/plain;charset=UTF-8', '{"value":')) + + expect(response.status).toBe(400) + }) + + it('accepts a JSON body sent under a non-JSON media type, as it does today', async () => { + const response = await createHandler()( + bodyRequest('application/x-www-form-urlencoded', JSON.stringify({ value: 'ok' })) + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ data: { value: 'ok' } }) + }) + + it('accepts a JSON body sent with no media type at all', async () => { + const response = await createHandler()(bodyRequest(null, JSON.stringify({ value: 'ok' }))) + + expect(response.status).toBe(201) + }) + + it('keeps 400 for a structured JSON suffix media type', async () => { + const response = await createHandler()(bodyRequest('application/merge-patch+json', '{"value":')) + + expect(response.status).toBe(400) + }) + + it('lets a route override the classification entirely', async () => { + const response = await createHandler({ + parseOptions: { + invalidJsonResponse: () => + NextResponse.json( + { error: { code: 'BAD_REQUEST', message: 'Import archive is not JSON' } }, + { status: 400 } + ), + }, + })(bodyRequest('application/x-www-form-urlencoded', 'value=ok')) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: { code: 'BAD_REQUEST', message: 'Import archive is not JSON' }, + }) + }) +}) + /** * A `HEAD` on a route whose `GET` is not safe must answer the question the `GET` * would answer, minus the effect — not merely the question admission can answer. diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 1c284358dd1..f13eb0416b5 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -125,12 +125,75 @@ const v2PayloadTooLargeResponse = () => v2Error('PAYLOAD_TOO_LARGE', 'Request bo */ export const v2InvalidJsonResponse = () => v2Error('BAD_REQUEST', 'Request body must be valid JSON') +/** + * Whether the request declared a media type that is not a JSON body at all. + * + * Only consulted once a body has already **failed** to parse as JSON — see + * {@link v2InvalidBodyResponse} — so this decides how to describe a request + * that is failing either way, never whether one is accepted. + * + * An absent `Content-Type` is not a mismatch. A body sent without one is + * indistinguishable from a client that simply omits the header, and today's + * callers include ones that do; treating absence as a refusal is the likeliest + * way to turn a working client into a 415. + * + * `text/plain` is not a mismatch either, and that carve-out is load-bearing: + * `fetch(url, { method: 'POST', body: JSON.stringify(x) })` with no explicit + * headers sends `text/plain;charset=UTF-8`, so it is the default media type of + * a hand-written JSON body from a browser rather than a declaration that the + * body is not JSON. + * + * Anything else — `application/x-www-form-urlencoded`, `multipart/form-data`, + * `application/xml` — is a positive statement that the body is in some other + * format, which is exactly what 415 names. + */ +function declaresNonJsonBody(request: Request): boolean { + const header = request.headers.get('content-type') + if (!header) return false + const mediaType = header.split(';', 1)[0].trim().toLowerCase() + if (!mediaType || mediaType === 'text/plain') return false + const subtype = mediaType.slice(mediaType.indexOf('/') + 1) + return subtype !== 'json' && !subtype.endsWith('+json') +} + +/** + * The v2 answer to a body that could not be read as JSON: `415` when the caller + * declared a non-JSON media type, `400` otherwise. + * + * `400 "Request body must be valid JSON"` is the same answer for a truncated + * JSON body and for a form-encoded one, which leaves a caller who sent + * `application/x-www-form-urlencoded` hunting a syntax error in a body that has + * none. `UNSUPPORTED_MEDIA_TYPE` was already a declared `V2ErrorCode` with no + * path that reached it; this is that path. + * + * Deliberately a **re-classification of an existing failure**, not a new gate. + * It runs only after the JSON read has already failed, so no request that + * succeeds today can start failing: `curl -d '{"a":1}'` without `-H` sends + * form-urlencoded around a body that parses as JSON perfectly well, and that + * caller keeps working exactly as before. A pre-parse content-type gate would + * have broken them — and would also have to special-case the multipart bodies + * `defineV2BodyLifecycleRoute` legitimately accepts. Only the status and + * `error.code` of an already-4xx request change. + */ +export function v2InvalidBodyResponse(request: Request): NextResponse { + return declaresNonJsonBody(request) + ? v2Error('UNSUPPORTED_MEDIA_TYPE', 'Request body must be sent as application/json') + : v2InvalidJsonResponse() +} + /** * The parse failures every v2 route renders the same way. * * The builders spread this, and so must the handful of raw `withRouteHandler` * v2 routes that call `parseRequest` directly — they are exactly the routes a * builder default cannot reach. + * + * `invalidJsonResponse` is the request-unaware 400. `parseRequest` invokes it + * with no arguments, so the media-type-aware {@link v2InvalidBodyResponse} can + * only be installed by a caller that still holds the request — which + * {@link defineV2JsonRoute} does, overriding this entry. A raw route wanting the + * same 415 passes `invalidJsonResponse: () => v2InvalidBodyResponse(request)` + * after spreading this. */ export const V2_PARSE_DEFAULTS = { payloadTooLargeResponse: v2PayloadTooLargeResponse, @@ -360,6 +423,7 @@ export function defineV2JsonRoute< const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, + invalidJsonResponse: () => v2InvalidBodyResponse(request), ...options.parseOptions, validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/lib/audio/extractor.ts b/apps/sim/lib/audio/extractor.ts index 47f461fc46b..6573c6f2ce7 100644 --- a/apps/sim/lib/audio/extractor.ts +++ b/apps/sim/lib/audio/extractor.ts @@ -273,42 +273,3 @@ export function isVideoFile(mimeType: string): boolean { export function isAudioFile(mimeType: string): boolean { return mimeType.startsWith('audio/') } - -/** - * Get optimal audio format for STT provider - */ -export function getOptimalFormat(provider: 'whisper' | 'deepgram' | 'elevenlabs'): { - format: 'mp3' | 'wav' | 'flac' - sampleRate: number - channels: 1 | 2 -} { - switch (provider) { - case 'whisper': - // Whisper prefers 16kHz mono - return { - format: 'mp3', - sampleRate: 16000, - channels: 1, - } - case 'deepgram': - // Deepgram works well with various formats - return { - format: 'mp3', - sampleRate: 16000, - channels: 1, - } - case 'elevenlabs': - // ElevenLabs format preferences - return { - format: 'mp3', - sampleRate: 16000, - channels: 1, - } - default: - return { - format: 'mp3', - sampleRate: 16000, - channels: 1, - } - } -} diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts new file mode 100644 index 00000000000..ed2d8d364c0 --- /dev/null +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -0,0 +1,133 @@ +import { createHash } from 'node:crypto' +import type { OAuth2Tokens } from '@better-auth/core/oauth2' +import type { GenericOAuthConfig } from 'better-auth/plugins' +import { OAuth2Client, type TokenPayload } from 'google-auth-library' +import { buildConnectorProviders } from '@/lib/auth/connectors/providers' + +const GOOGLE_OPENID_SCOPE = 'openid' +const GOOGLE_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email' +const GOOGLE_PROFILE_SCOPE = 'https://www.googleapis.com/auth/userinfo.profile' +const GMAIL_READONLY_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly' +const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify' +const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send' +const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels' + +export interface ManagedOAuthConnectorIdentity { + providerSubjectId: string + providerTenantId: string | null + email: string + emailVerified: boolean + displayName?: string + avatarUrl?: string + nonce?: string + grantedScopes: string[] +} + +export interface ManagedOAuthConnectorConfig { + additionalScopes: string[] + requiresRefreshToken: boolean + pkce: boolean + prompt?: string + authorizationUrlParams?: Record + getAuthorizationAppId(clientId: string): string + verifyIdentity(params: { + tokens: OAuth2Tokens + clientId: string + }): Promise + hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean + isTerminalRefreshError(errorCode: string | undefined): boolean +} + +export interface ConnectorProviderConfig extends GenericOAuthConfig { + managedOAuth: ManagedOAuthConnectorConfig +} + +function canonicalGoogleScope(scope: string): string { + if (scope === 'email') return GOOGLE_EMAIL_SCOPE + if (scope === 'profile') return GOOGLE_PROFILE_SCOPE + return scope +} + +function hasRequiredGoogleScopes( + providerId: string, + grantedScopes: string[], + requiredScopes: string[] +): boolean { + const granted = new Set(grantedScopes.map(canonicalGoogleScope)) + return requiredScopes.every((requestedScope) => { + const required = canonicalGoogleScope(requestedScope) + if (granted.has(required)) return true + return ( + providerId === 'google-email' && + granted.has(GMAIL_MODIFY_SCOPE) && + (required === GMAIL_READONLY_SCOPE || + required === GMAIL_SEND_SCOPE || + required === GMAIL_LABELS_SCOPE) + ) + }) +} + +function requireVerifiedGooglePayload(payload: TokenPayload | undefined): TokenPayload & { + sub: string + email: string +} { + if (!payload?.sub || !payload.email || payload.email_verified !== true) { + throw new Error('Google returned an invalid identity token') + } + return payload as TokenPayload & { sub: string; email: string } +} + +export function createGoogleManagedOAuthConnector(providerId: string): ManagedOAuthConnectorConfig { + return { + additionalScopes: [GOOGLE_OPENID_SCOPE], + requiresRefreshToken: true, + pkce: true, + prompt: 'consent select_account', + authorizationUrlParams: { include_granted_scopes: 'false' }, + getAuthorizationAppId(clientId) { + return `google:${createHash('sha256').update(clientId).digest('hex')}` + }, + async verifyIdentity({ tokens, clientId }) { + if (!tokens.idToken || !tokens.accessToken) { + throw new Error('Google returned an incomplete authorization') + } + const client = new OAuth2Client({ clientId }) + const ticket = await client.verifyIdToken({ idToken: tokens.idToken, audience: clientId }) + const payload = requireVerifiedGooglePayload(ticket.getPayload()) + const tokenInfo = await client.getTokenInfo(tokens.accessToken) + if (tokenInfo.aud !== clientId || tokenInfo.sub !== payload.sub) { + throw new Error('Google returned an access token for another identity') + } + return { + providerSubjectId: payload.sub, + providerTenantId: payload.hd ?? null, + email: payload.email, + emailVerified: true, + ...(payload.name ? { displayName: payload.name } : {}), + ...(payload.picture ? { avatarUrl: payload.picture } : {}), + ...(payload.nonce ? { nonce: payload.nonce } : {}), + grantedScopes: [...new Set(tokenInfo.scopes)], + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + return hasRequiredGoogleScopes(providerId, grantedScopes, requiredScopes) + }, + isTerminalRefreshError(errorCode) { + return errorCode === 'invalid_grant' + }, + } +} + +export function getManagedOAuthConnectorProviderConfig( + providerId: string +): ConnectorProviderConfig | undefined { + if (providerId !== 'google-email' && providerId !== 'google-calendar') return undefined + const connector = buildConnectorProviders().find( + (candidate) => candidate.providerId === providerId + ) + if (!connector) return undefined + return { + ...connector, + managedOAuth: createGoogleManagedOAuthConnector(providerId), + } +} diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts index 0119da1e4ac..041b0c35ec8 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -3,6 +3,7 @@ import { getOAuth2Tokens } from '@better-auth/core/oauth2' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import type { GenericOAuthConfig } from 'better-auth/plugins' import { syntheticConnectorEmail } from '@/lib/auth/connector-email' import { env } from '@/lib/core/config/env' @@ -1044,10 +1045,9 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { // error_description: '...' }. The status-only guard therefore never // fires, so surface the actual error/description instead of collapsing // every failure into one opaque "no access token" string. - const errorObj = - data && typeof data === 'object' && !Array.isArray(data) - ? (data as { error?: unknown; error_description?: unknown }) - : {} + const errorObj = isRecordLike(data) + ? (data as { error?: unknown; error_description?: unknown }) + : {} const zohoError = typeof errorObj.error === 'string' ? errorObj.error : undefined const zohoErrorDescription = typeof errorObj.error_description === 'string' ? errorObj.error_description : undefined diff --git a/apps/sim/lib/auth/credential-access.ts b/apps/sim/lib/auth/credential-access.ts index 719cd30f8ca..2671bf1f0de 100644 --- a/apps/sim/lib/auth/credential-access.ts +++ b/apps/sim/lib/auth/credential-access.ts @@ -18,7 +18,7 @@ export interface CredentialAccessResult { credentialOwnerUserId?: string workspaceId?: string resolvedCredentialId?: string - credentialType?: 'oauth' | 'service_account' + credentialType?: 'oauth' | 'managed_oauth' | 'service_account' } const NO_CREDENTIAL_ACCESS = @@ -123,6 +123,13 @@ export async function authorizeCredentialUseForAuth( const accessError = credentialAccessError(platformAccess) if (accessError) return { ok: false, error: accessError } + if (platformCredential.type === 'managed_oauth') { + return { + ok: false, + error: 'Managed credential access requires scoped workflow delegation', + } + } + if (platformCredential.type === 'service_account') { return { ok: true, diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index d440b02be05..e686b076f2e 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -49,6 +49,19 @@ describe('principal subject users', () => { }) ).toThrow(PrincipalSubjectUserRequiredError) }) + + it('fails fast instead of fabricating a Sim user for an external enrollment', () => { + expect(() => + requirePrincipalSubjectUserId({ + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', + }) + ).toThrow(PrincipalSubjectUserRequiredError) + }) }) describe('principal actors', () => { @@ -86,6 +99,26 @@ describe('principal actors', () => { actorId: null, actorName: 'Workspace API key', }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', + }) + ).toEqual({ + actor: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + }, + actorId: null, + actorName: 'person@example.com', + }) }) it('projects principals into their shared actor identity', () => { @@ -158,4 +191,17 @@ describe('principal actors', () => { }) ).toThrow('Workspace API key attribution requires a workspace billing owner') }) + + it('fails fast when external enrollment identity is used for user attribution', () => { + expect(() => + resolvePrincipalAttribution({ + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', + }) + ).toThrow(PrincipalSubjectUserRequiredError) + }) }) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 14a2cd47db5..0683b82a166 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -243,53 +243,6 @@ function buildUsageData(params: { } } -/** - * Displays a notification to the user when they're approaching their usage limit - * Can be called on app startup or before executing actions that might incur costs - */ -async function checkAndNotifyUsage(userId: string): Promise { - try { - if (!isBillingEnabled) { - return - } - - const usageData = await checkUsageStatus(userId) - - if (usageData.isExceeded) { - logger.warn('User has exceeded usage limits', { - userId, - usage: usageData.currentUsage, - limit: usageData.limit, - }) - - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('usage-exceeded', { - detail: { usageData }, - }) - ) - } - } else if (usageData.isWarning) { - logger.info('User approaching usage limits', { - userId, - usage: usageData.currentUsage, - limit: usageData.limit, - percent: usageData.percentUsed, - }) - - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('usage-warning', { - detail: { usageData }, - }) - ) - } - } - } catch (error) { - logger.error('Error in usage notification system', { error, userId }) - } -} - /** * Whether the exact hosted user account is billing-blocked. Organization * memberships are deliberately ignored; workspace payer checks are separate. diff --git a/apps/sim/lib/billing/client/consts.ts b/apps/sim/lib/billing/client/consts.ts index 32cbec80601..0809e8e836d 100644 --- a/apps/sim/lib/billing/client/consts.ts +++ b/apps/sim/lib/billing/client/consts.ts @@ -1,8 +1,3 @@ -/** - * Number of pills to display in usage indicators. - */ -export const USAGE_PILL_COUNT = 8 - /** * Usage percentage thresholds for visual states. */ diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index 7290bf1d9dc..dc1d59a7d18 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -349,71 +349,6 @@ export async function updateOrganizationUsageLimit( } } -/** - * Get organization billing summary for admin dashboard - */ -async function getOrganizationBillingSummary(organizationId: string) { - try { - const billingData = await getOrganizationBillingData(organizationId) - - if (!billingData) { - return null - } - - // Calculate additional metrics - const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length - const membersNearLimit = billingData.members.filter( - (m) => !m.isOverLimit && m.percentUsed >= 80 - ).length - - const topUsers = billingData.members.slice(0, 5).map((m) => ({ - name: m.userName, - usage: m.currentUsage, - limit: m.usageLimit, - percentUsed: m.percentUsed, - })) - - return { - organization: { - id: billingData.organizationId, - name: billingData.organizationName, - plan: billingData.subscriptionPlan, - status: billingData.subscriptionStatus, - }, - usage: { - total: billingData.totalCurrentUsage, - limit: billingData.totalUsageLimit, - average: billingData.averageUsagePerMember, - percentUsed: - billingData.totalUsageLimit > 0 - ? (billingData.totalCurrentUsage / billingData.totalUsageLimit) * 100 - : 0, - }, - seats: { - total: billingData.totalSeats, - used: billingData.usedSeats, - /** - * Clamped: Team seats track the member count rather than a ceiling, so - * any outstanding invitation would otherwise report negative headroom. - */ - available: Math.max(0, billingData.totalSeats - billingData.usedSeats), - }, - alerts: { - membersOverLimit, - membersNearLimit, - }, - billingPeriod: { - start: billingData.billingPeriodStart, - end: billingData.billingPeriodEnd, - }, - topUsers, - } - } catch (error) { - logger.error('Failed to get organization billing summary', { organizationId, error }) - throw error - } -} - /** * Error-tolerant wrapper around {@link isOrganizationAdminOrOwner} for billing * gates: on a DB error it logs and returns false instead of throwing, so a diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index b68d1982623..28f091016fa 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -30,7 +30,7 @@ import { hasUsableSubscriptionAccess, isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' -import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' +import type { UsageData, UsageLimitInfo } from '@/lib/billing/types' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { isBillingEnabled } from '@/lib/core/config/env-flags' @@ -400,38 +400,6 @@ export async function getUserUsageLimitInfo(userId: string): Promise { - // Check if user already has usage stats - const existingStats = await db - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - if (existingStats.length > 0) { - return - } - - const subscription = await getHighestPrioritySubscription(userId) - const orgScoped = isOrgScopedSubscription(subscription, userId) - - await db.insert(userStats).values({ - id: generateId(), - userId, - currentUsageLimit: orgScoped ? null : getFreeTierLimit().toString(), - usageLimitUpdatedAt: new Date(), - }) - - logger.info('Initialized user stats', { - userId, - plan: subscription?.plan || 'free', - hasIndividualLimit: !orgScoped, - }) -} - /** * Update a user's custom usage limit */ @@ -782,52 +750,6 @@ export async function getEffectiveCurrentPeriodCost( return Math.max(0, rawCost - refreshConsumed) } -/** - * Calculate billing projection based on current usage - */ -async function calculateBillingProjection(userId: string): Promise { - try { - const usageData = await getUserUsageData(userId) - - if (!usageData.billingPeriodStart || !usageData.billingPeriodEnd) { - return { - currentPeriodCost: usageData.currentUsage, - projectedCost: usageData.currentUsage, - limit: usageData.limit, - billingPeriodStart: null, - billingPeriodEnd: null, - daysRemaining: 0, - } - } - - const now = new Date() - const periodStart = new Date(usageData.billingPeriodStart) - const periodEnd = new Date(usageData.billingPeriodEnd) - - const totalDays = Math.ceil( - (periodEnd.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24) - ) - const daysElapsed = Math.ceil((now.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24)) - const daysRemaining = Math.max(0, totalDays - daysElapsed) - - // Project cost based on daily usage rate - const dailyRate = daysElapsed > 0 ? usageData.currentUsage / daysElapsed : 0 - const projectedCost = dailyRate * totalDays - - return { - currentPeriodCost: usageData.currentUsage, - projectedCost: Math.min(projectedCost, usageData.limit), // Cap at limit - limit: usageData.limit, - billingPeriodStart: usageData.billingPeriodStart, - billingPeriodEnd: usageData.billingPeriodEnd, - daysRemaining, - } - } catch (error) { - logger.error('Failed to calculate billing projection', { userId, error }) - throw error - } -} - /** * Send usage threshold notification when crossing from <80% to ≥80%. * - Skips when billing is disabled. diff --git a/apps/sim/lib/billing/credits/balance.ts b/apps/sim/lib/billing/credits/balance.ts index cdf3dc784f5..d702056d809 100644 --- a/apps/sim/lib/billing/credits/balance.ts +++ b/apps/sim/lib/billing/credits/balance.ts @@ -9,7 +9,7 @@ import { hasUsableSubscriptionAccess, isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' -import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal' +import { toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal' import type { DbClient } from '@/lib/db/types' const logger = createLogger('CreditBalance') @@ -92,28 +92,6 @@ export async function addCredits( } } -async function removeCredits( - entityType: 'user' | 'organization', - entityId: string, - amount: number -): Promise { - if (entityType === 'organization') { - await db - .update(organization) - .set({ creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${amount})` }) - .where(eq(organization.id, entityId)) - - logger.info('Removed credits from organization', { organizationId: entityId, amount }) - } else { - await db - .update(userStats) - .set({ creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${amount})` }) - .where(eq(userStats.userId, entityId)) - - logger.info('Removed credits from user', { userId: entityId, amount }) - } -} - interface DeductResult { creditsUsed: number overflow: number @@ -173,36 +151,6 @@ async function atomicDeductOrgCredits(orgId: string, cost: number): Promise { - if (cost <= 0) { - return { creditsUsed: 0, overflow: 0 } - } - - const subscription = await getHighestPrioritySubscription(userId) - const orgScoped = isOrgScopedSubscription(subscription, userId) - - let creditsUsed: number - - if (orgScoped && subscription?.referenceId) { - creditsUsed = await atomicDeductOrgCredits(subscription.referenceId, cost) - } else { - creditsUsed = await atomicDeductUserCredits(userId, cost) - } - - const overflow = toNumber(Decimal.max(0, toDecimal(cost).minus(creditsUsed))) - - if (creditsUsed > 0) { - logger.info('Deducted credits atomically', { - userId, - creditsUsed, - overflow, - entityType: orgScoped ? 'organization' : 'user', - }) - } - - return { creditsUsed, overflow } -} - export async function canPurchaseCredits(userId: string): Promise { const subscription = await getHighestPrioritySubscription(userId) if (!subscription) { diff --git a/apps/sim/lib/billing/enterprise-outbox.ts b/apps/sim/lib/billing/enterprise-outbox.ts index 283f4330a38..b5c8576cd3f 100644 --- a/apps/sim/lib/billing/enterprise-outbox.ts +++ b/apps/sim/lib/billing/enterprise-outbox.ts @@ -1,4 +1,5 @@ import { outboxEvent } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, sql } from 'drizzle-orm' import type Stripe from 'stripe' import { z } from 'zod' @@ -201,9 +202,7 @@ export async function assertNoCompetingEnterpriseIssuance( } function metadataRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} + return isRecordLike(value) ? (value as Record) : {} } function positiveInteger(value: unknown): number | null { diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index 613b206070a..ed838f9c2c4 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, outboxEvent, subscription, user } from '@sim/db/schema' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm' import type Stripe from 'stripe' import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults' @@ -33,9 +34,7 @@ import { enqueueOutboxEvent, type OutboxHandler } from '@/lib/core/outbox/servic const TERMINAL_STATUSES = new Set(TERMINAL_SUBSCRIPTION_STATUSES) function metadataRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} + return isRecordLike(value) ? (value as Record) : {} } function isNonterminalSubscriptionStatus(status: string | null | undefined): boolean { diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts index 99ef6382d92..120924b3561 100644 --- a/apps/sim/lib/billing/plan-helpers.ts +++ b/apps/sim/lib/billing/plan-helpers.ts @@ -132,13 +132,6 @@ export function buildPlanName(type: 'pro' | 'team', credits: number): string { return `${type}_${credits}` } -/** - * Get the list of valid plan names for a given category. - */ -export function getValidPlanNames(type: 'pro' | 'team'): string[] { - return CREDIT_TIERS.map((t) => buildPlanName(type, t.credits)) -} - /** * Get the user-facing display name for a plan. * @example getDisplayPlanName('pro_25000') => 'Max' diff --git a/apps/sim/lib/billing/plans.ts b/apps/sim/lib/billing/plans.ts index 184093a6e7b..257e1a45a5e 100644 --- a/apps/sim/lib/billing/plans.ts +++ b/apps/sim/lib/billing/plans.ts @@ -109,14 +109,6 @@ export function getPlanByPriceId(priceId: string): BillingPlan | undefined { ) } -/** - * Get plan limits for a given plan name - */ -export function getPlanLimits(planName: string): number { - const plan = getPlanByName(planName) - return plan?.limits.cost ?? getFreeTierLimit() -} - export interface StripePlanResolution { priceId: string | undefined planFromStripe: string | null diff --git a/apps/sim/lib/blog/registry.ts b/apps/sim/lib/blog/registry.ts index 1841140a717..71235ae66b4 100644 --- a/apps/sim/lib/blog/registry.ts +++ b/apps/sim/lib/blog/registry.ts @@ -21,5 +21,3 @@ export const getAllPostMeta = blogRegistry.getAllPostMeta export const getPostBySlug = blogRegistry.getPostBySlug export const getAllTags = blogRegistry.getAllTags export const getRelatedPosts = blogRegistry.getRelatedPosts -export const getNavBlogPosts = blogRegistry.getNavPosts -export const invalidateBlogCaches = blogRegistry.invalidateCaches diff --git a/apps/sim/lib/compare/data/types.ts b/apps/sim/lib/compare/data/types.ts index 1153af7f227..03725a9a1ee 100644 --- a/apps/sim/lib/compare/data/types.ts +++ b/apps/sim/lib/compare/data/types.ts @@ -237,16 +237,6 @@ export interface CompetitorProfile { facts: ComparisonFacts } -/** A fact awaiting verification. Used as an intermediate research artifact, never shipped. */ -export function unknownFact(reason?: string): Fact { - return { - value: 'Unknown', - detail: reason, - confidence: 'unknown', - sources: [], - } -} - /** * Broad grouping for {@link SimFeature} entries. A single feature catalog * entry belongs to exactly one category, but can carry additional diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 1cff09c4a63..58efd23875b 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -5,7 +5,6 @@ import { type CopilotRunStatus, type CopilotToolPermissionDecision, copilotAsyncToolCalls, - copilotRunCheckpoints, copilotRuns, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -158,24 +157,6 @@ export async function updateRunStatus( ) } -async function getLatestRunForExecution(executionId: string) { - return await withDbSpan( - TraceSpan.CopilotAsyncRunsGetLatestForExecution, - 'SELECT', - 'copilot_runs', - { [TraceAttr.CopilotExecutionId]: executionId }, - async () => { - const [run] = await db - .select() - .from(copilotRuns) - .where(eq(copilotRuns.executionId, executionId)) - .orderBy(desc(copilotRuns.startedAt)) - .limit(1) - return run ?? null - } - ) -} - // Un-instrumented: called from a 4 Hz resume poll; per-call spans // swamped traces. Use Prom histograms if latency visibility is needed. export async function getLatestRunForStream(streamId: string, userId?: string) { @@ -215,38 +196,6 @@ export async function getRunSegment(runId: string) { ) } -async function createRunCheckpoint(input: { - runId: string - pendingToolCallId: string - conversationSnapshot: Record - agentState: Record - providerRequest: Record -}) { - return await withDbSpan( - TraceSpan.CopilotAsyncRunsCreateRunCheckpoint, - 'INSERT', - 'copilot_run_checkpoints', - { - [TraceAttr.RunId]: input.runId, - [TraceAttr.CopilotCheckpointPendingToolCallId]: input.pendingToolCallId, - }, - async () => { - const [checkpoint] = await db - .insert(copilotRunCheckpoints) - .values({ - runId: input.runId, - pendingToolCallId: input.pendingToolCallId, - conversationSnapshot: input.conversationSnapshot, - agentState: input.agentState, - providerRequest: input.providerRequest, - }) - .returning() - - return checkpoint - } - ) -} - export async function upsertAsyncToolCall(input: { runId?: string | null checkpointId?: string | null @@ -627,21 +576,6 @@ export async function recordToolPermissionDecision( ) } -async function listAsyncToolCallsForRun(runId: string) { - return await withDbSpan( - TraceSpan.CopilotAsyncRunsListForRun, - 'SELECT', - 'copilot_async_tool_calls', - { [TraceAttr.RunId]: runId }, - async () => - db - .select() - .from(copilotAsyncToolCalls) - .where(eq(copilotAsyncToolCalls.runId, runId)) - .orderBy(desc(copilotAsyncToolCalls.createdAt)) - ) -} - export async function getAsyncToolCalls(toolCallIds: string[]) { if (toolCallIds.length === 0) return [] return await withDbSpan( @@ -686,33 +620,3 @@ export async function claimCompletedAsyncToolCall(toolCallId: string, workerId: } ) } - -async function releaseCompletedAsyncToolClaim(toolCallId: string, workerId: string) { - return await withDbSpan( - TraceSpan.CopilotAsyncRunsReleaseClaim, - 'UPDATE', - 'copilot_async_tool_calls', - { - [TraceAttr.ToolCallId]: toolCallId, - [TraceAttr.CopilotAsyncToolWorkerId]: workerId, - }, - async () => { - const [row] = await db - .update(copilotAsyncToolCalls) - .set({ - claimedBy: null, - claimedAt: null, - updatedAt: new Date(), - }) - .where( - and( - eq(copilotAsyncToolCalls.toolCallId, toolCallId), - inArray(copilotAsyncToolCalls.status, ['completed', 'failed', 'cancelled']), - eq(copilotAsyncToolCalls.claimedBy, workerId) - ) - ) - .returning() - return row ?? null - } - ) -} diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index c21214d8367..8765e334f16 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -576,9 +576,6 @@ async function processPastChat(chatId: string, tagOverride?: string): Promise => - (data && typeof data === 'object' && !Array.isArray(data) ? data : {}) as Record - /** * In-memory tool event dedupe with bounded size. * diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/copilot/request/tool-call-state.ts index fd4cdcf060d..d0c636e502b 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.ts +++ b/apps/sim/lib/copilot/request/tool-call-state.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' import { MothershipStreamV1ToolOutcome, @@ -88,7 +89,7 @@ function getToolCallTerminalDataRaw( typeof toolCall.error === 'string' && toolCall.error.length > 0 ? toolCall.error : 'Tool failed without an error message' - if (output && typeof output === 'object' && !Array.isArray(output)) { + if (isRecordLike(output)) { return 'error' in output ? output : { ...output, error } } return { output, error } diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 035ca0b85d0..e1248c03814 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -84,7 +85,7 @@ export function extractTabularData(output: unknown): Record[] | const obj = output as Record // user_table query_rows shape: { data: { rows: [{ data: {...} }], totalCount } } - if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) { + if (isRecordLike(obj.data)) { const data = obj.data as Record if (Array.isArray(data.rows) && data.rows.length > 0) { const rows = data.rows as Record[] @@ -107,10 +108,6 @@ export function escapeCsvValue(value: unknown): string { return str } -export function convertRowsToCsv(rows: Record[]): string { - return convertRowsToCsvWithProvenance(rows).content -} - export function normalizeOutputWorkspaceFileName(outputPath: string): string { const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, '')) const fileName = segments.at(-1) @@ -356,10 +353,9 @@ export async function maybeWriteOutputToFile( } const { userId, workspaceId } = context - const outputObject = - result.output && typeof result.output === 'object' && !Array.isArray(result.output) - ? (result.output as Record) - : undefined + const outputObject = isRecordLike(result.output) + ? (result.output as Record) + : undefined const resultObject = outputObject?.result && typeof outputObject.result === 'object' && diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 2a614d944b6..fc2fd57e963 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,3 +1,4 @@ +import { toRecord } from '@sim/utils/object' import { CreateFile, CreateWorkflow, @@ -39,19 +40,15 @@ export function isResourceToolName(toolName: string): boolean { return RESOURCE_TOOL_NAMES.has(toolName) } -function asRecord(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {} -} - function getOperation(params: Record | undefined): string | undefined { - const args = asRecord(params?.args) + const args = toRecord(params?.args) return (args.operation ?? params?.operation) as string | undefined } function getWorkspaceFileTarget( params: Record | undefined ): Record { - return asRecord(params?.target) + return toRecord(params?.target) } const READ_ONLY_TABLE_OPS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) @@ -70,8 +67,8 @@ export function extractResourcesFromToolResult( ): ChatResource[] { if (!isResourceToolName(toolName)) return [] - const result = asRecord(output) - const data = asRecord(result.data) + const result = toRecord(output) + const data = toRecord(result.data) switch (toolName) { case UserTable.id: { @@ -95,11 +92,11 @@ export function extractResourcesFromToolResult( }, ] } - const table = asRecord(data.table) + const table = toRecord(data.table) if (table.id) { return [{ type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }] } - const args = asRecord(params?.args) + const args = toRecord(params?.args) const tableId = (data.tableId as string) ?? (args.tableId as string) ?? (params?.tableId as string) if (tableId) { @@ -112,7 +109,7 @@ export function extractResourcesFromToolResult( case CreateFile.id: case WorkspaceFile.id: { - const file = asRecord(data.file) + const file = toRecord(data.file) if (file.id) { return [{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' }] } @@ -184,7 +181,7 @@ export function extractResourcesFromToolResult( case KnowledgeBase.id: { if (READ_ONLY_KB_OPS.has(getOperation(params) ?? '')) return [] - const args = asRecord(params?.args) + const args = toRecord(params?.args) const kbId = (args.knowledgeBaseId as string) ?? (params?.knowledgeBaseId as string) ?? @@ -261,16 +258,16 @@ export function extractDeletedResourcesFromToolResult( const resourceType = DELETE_CAPABLE_TOOL_RESOURCE_TYPE[toolName] if (!resourceType) return [] - const result = asRecord(output) - const data = asRecord(result.data) - const args = asRecord(params?.args) + const result = toRecord(output) + const data = toRecord(result.data) + const args = toRecord(params?.args) const operation = (args.operation ?? params?.operation) as string | undefined switch (toolName) { case Rm.id: { const outcomes = Array.isArray(result.results) ? result.results : [] return outcomes.flatMap((entry): ChatResource[] => { - const outcome = asRecord(entry) + const outcome = toRecord(entry) if (outcome.error) return [] const { id, kind, from } = outcome if (typeof id !== 'string' || !id || typeof kind !== 'string') return [] @@ -310,7 +307,7 @@ export function extractDeletedResourcesFromToolResult( if (operation !== 'delete') return [] const deleted = Array.isArray(data.deleted) ? data.deleted : [] const resources = deleted.flatMap((entry): ChatResource[] => { - const deletedKnowledgeBase = asRecord(entry) + const deletedKnowledgeBase = toRecord(entry) const knowledgeBaseId = deletedKnowledgeBase.id if (typeof knowledgeBaseId !== 'string' || !knowledgeBaseId) return [] return [ diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 6184d682204..dc2489efe61 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -5,12 +5,7 @@ import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/r import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' -import type { - ToolCallDescriptor, - ToolExecutionContext, - ToolExecutionResult, - ToolHandler, -} from './types' +import type { ToolExecutionContext, ToolExecutionResult, ToolHandler } from './types' const logger = createLogger('ToolExecutor') const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' @@ -29,10 +24,6 @@ export function registerHandlers(entries: Record): void { } } -export function getRegisteredToolIds(): string[] { - return Array.from(handlerRegistry.keys()) -} - export function hasHandler(toolId: string): boolean { return handlerRegistry.has(toolId) } @@ -144,31 +135,6 @@ function normalizeToolParams( } } -async function executeToolBatch( - toolCalls: ToolCallDescriptor[], - context: ToolExecutionContext -): Promise> { - const results = new Map() - - const executions = toolCalls.map(async ({ toolCallId, toolId, params }) => { - const result = await executeTool(toolId, params, context) - results.set(toolCallId, result) - }) - - await Promise.allSettled(executions) - - for (const { toolCallId } of toolCalls) { - if (!results.has(toolCallId)) { - results.set(toolCallId, { - success: false, - error: 'Tool execution did not produce a result', - }) - } - } - - return results -} - function buildAppToolParams( params: Record, context: ToolExecutionContext diff --git a/apps/sim/lib/copilot/tool-executor/router.ts b/apps/sim/lib/copilot/tool-executor/router.ts index 13ea484300a..fede5c200f9 100644 --- a/apps/sim/lib/copilot/tool-executor/router.ts +++ b/apps/sim/lib/copilot/tool-executor/router.ts @@ -1,5 +1,4 @@ import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' -import type { ToolCallDescriptor } from './types' export type ToolRouteTarget = ToolCatalogEntry['route'] @@ -27,10 +26,6 @@ export function isSimExecuted(toolId: string): boolean { return getToolEntry(toolId)?.route === 'sim' } -export function isGoExecuted(toolId: string): boolean { - return getToolEntry(toolId)?.route === 'go' -} - export function isClientExecuted(toolId: string): boolean { return getToolEntry(toolId)?.route === 'client' } @@ -43,26 +38,3 @@ export function isKnownTool(toolId: string): boolean { export function toolRequiresApproval(toolId: string): boolean { return getToolEntry(toolId)?.requiresApproval === true } - -interface PartitionedBatch { - sim: ToolCallDescriptor[] - go: ToolCallDescriptor[] - subagent: ToolCallDescriptor[] - client: ToolCallDescriptor[] - unknown: ToolCallDescriptor[] -} - -export function partitionToolBatch(toolCalls: ToolCallDescriptor[]): PartitionedBatch { - const result: PartitionedBatch = { sim: [], go: [], subagent: [], client: [], unknown: [] } - - for (const tc of toolCalls) { - const route = routeToolCall(tc.toolId) - if (!route) { - result.unknown.push(tc) - continue - } - result[route.route].push(tc) - } - - return result -} diff --git a/apps/sim/lib/copilot/tools/client/local-filesystem.ts b/apps/sim/lib/copilot/tools/client/local-filesystem.ts index 04091d484b4..ba6798eab85 100644 --- a/apps/sim/lib/copilot/tools/client/local-filesystem.ts +++ b/apps/sim/lib/copilot/tools/client/local-filesystem.ts @@ -152,24 +152,6 @@ function mountForPath(mounts: LocalFilesystemMount[], path: string): LocalFilesy return match } -function omitHostPaths(data: LocalFilesystemData): LocalFilesystemData { - if ('mount' in data) { - if (!data.mount) return data - const { path: _path, ...mount } = data.mount as LocalFilesystemMount & { path?: unknown } - return { ...data, mount } - } - if ('mounts' in data) { - return { - ...data, - mounts: data.mounts.map((rawMount) => { - const { path: _path, ...mount } = rawMount as LocalFilesystemMount & { path?: unknown } - return mount - }), - } - } - return data -} - async function executeUserLocalGlob( toolCallId: string, args: Record, @@ -401,9 +383,3 @@ export function executeLocalFilesystemTool( } ) } - -export const userLocalVfsTestHelpers = { - mountVfsRoot, - vfsPathForUri, - localUriForVfsPath, -} diff --git a/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts b/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts index 9779eeae1ee..378f56fd133 100644 --- a/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts @@ -7,6 +7,7 @@ * and reports the outcome via the confirm endpoint, which wakes the * server-side waiter. */ + import { createLogger } from '@sim/logger' import { isTerminalOperation, @@ -14,6 +15,7 @@ import { type TerminalToolArgs, } from '@sim/terminal-protocol' import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' @@ -89,8 +91,7 @@ function parseCall(params: Record): { const args = params.args return { operation, - args: - args && typeof args === 'object' && !Array.isArray(args) ? (args as TerminalToolArgs) : {}, + args: isRecordLike(args) ? (args as TerminalToolArgs) : {}, } } diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 76e001fe4f5..b853e6ae492 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ToolExecutionResult, ToolHandler } from '@/lib/copilot/tool-executor/types' @@ -30,10 +31,7 @@ export function createServerToolHandler(toolId: string): ToolHandler { resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, }) - const rec = - result && typeof result === 'object' && !Array.isArray(result) - ? (result as Record) - : null + const rec = isRecordLike(result) ? (result as Record) : null if (rec?.success === false) { const message = (typeof rec.error === 'string' && rec.error) || diff --git a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts index 82f7977b17d..726693b7b59 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts @@ -40,7 +40,7 @@ export type FileIntentScope = { messageId?: string // When set, consumeLatestFileIntent only considers intents from this subagent // channel — the key to isolating concurrent file subagents. Omitted by callers - // that intentionally span the whole message (e.g. clearIntentsForWorkspace). + // that intentionally span the whole message. channelId?: string } @@ -66,8 +66,8 @@ function scopeMatches(intent: PendingFileIntent, scope?: FileIntentScope): boole // Channel filter for consume: when a scope carries a channelId, only the // matching file subagent's intent qualifies. No channelId => message-wide -// (legacy / main-agent) behavior. Deliberately separate from scopeMatches so -// clearIntentsForWorkspace keeps clearing every channel in a message. +// (legacy / main-agent) behavior. Deliberately separate from scopeMatches, which +// spans every channel in a message. function channelMatches(intent: PendingFileIntent, scope?: FileIntentScope): boolean { return !scope?.channelId || intent.channelId === scope.channelId } @@ -156,33 +156,6 @@ export async function storeFileIntent( }) } -async function consumeFileIntent( - workspaceId: string, - fileId: string, - scope?: FileIntentScope -): Promise { - const redis = getRedisClient() - if (!redis) { - const key = buildKey(workspaceId, buildScopedField(fileId, scope)) - const intent = memoryStore.get(key) - if (intent) { - memoryStore.delete(key) - } - return intent - } - - const raw = await withRedisRetry('consume_file_intent', workspaceId, async (client) => { - const key = getWorkspaceRedisKey(workspaceId) - const field = buildScopedField(fileId, scope) - const value = await client.hget(key, field) - if (value !== null) { - await client.hdel(key, field) - } - return value - }) - return parseIntent(raw) -} - export async function peekFileIntent( workspaceId: string, fileId: string, @@ -263,50 +236,3 @@ export async function consumeLatestFileIntent( } return latest } - -export async function clearIntentsForWorkspace( - workspaceId: string, - scope?: FileIntentScope -): Promise { - const redis = getRedisClient() - if (!redis) { - let cleared = 0 - for (const [key, intent] of memoryStore) { - if (intent.workspaceId === workspaceId && (!scope || scopeMatches(intent, scope))) { - memoryStore.delete(key) - cleared++ - } - } - return cleared - } - - const key = getWorkspaceRedisKey(workspaceId) - if (!scope) { - const count = await withRedisRetry( - 'count_workspace_file_intents', - workspaceId, - async (client) => client.hlen(key) - ) - await withRedisRetry('clear_workspace_file_intents', workspaceId, async (client) => { - await client.del(key) - }) - return count - } - - const entries = await withRedisRetry('read_workspace_file_intents', workspaceId, async (client) => - client.hgetall(key) - ) - const fieldsToDelete: string[] = [] - for (const [field, raw] of Object.entries(entries)) { - const parsed = parseIntent(raw) - if (parsed && scopeMatches(parsed, scope)) { - fieldsToDelete.push(field) - } - } - if (fieldsToDelete.length > 0) { - await withRedisRetry('clear_scoped_file_intents', workspaceId, async (client) => { - await client.hdel(key, ...fieldsToDelete) - }) - } - return fieldsToDelete.length -} 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 2e97eaf547d..90115a49ce0 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -863,14 +863,6 @@ export const knowledgeBaseServerTool: BaseServerTool = { ...(args.sourceConfig ?? {}) } if (args.disabledTagIds?.length) { sourceConfig.disabledTagIds = args.disabledTagIds diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 2eb7b1d681a..d53a43748ad 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { @@ -233,11 +234,7 @@ export async function routeExecution( // nested "args" object. Unwrap that before validation so the generated // JSON Schema sees the flat tool contract shape. let normalizedPayload = payload ?? {} - if ( - normalizedPayload && - typeof normalizedPayload === 'object' && - !Array.isArray(normalizedPayload) - ) { + if (isRecordLike(normalizedPayload)) { const raw = normalizedPayload as Record if (raw.args && typeof raw.args === 'object' && !raw.operation) { const nested = raw.args as Record diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts index 07c1d8f54c8..c82e0f60bdf 100644 --- a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts +++ b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts @@ -31,15 +31,3 @@ export function formatNormalizedWorkflowForCopilot( if (!normalized) return null return formatWorkflowStateForCopilot(normalized, options) } - -export function normalizeWorkflowName(name?: string | null): string { - return String(name || '') - .trim() - .toLowerCase() -} - -export function extractWorkflowNames(workflows: Array<{ name?: string | null }>): string[] { - return workflows - .map((workflow) => (typeof workflow?.name === 'string' ? workflow.name : null)) - .filter((name): name is string => Boolean(name)) -} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 9d3e30c37c6..c62db5ce326 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { stripVersionSuffix } from '@sim/utils/string' /** @@ -45,9 +46,7 @@ function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]): function recordArg(args: ToolArgs, key: string): Record | undefined { const value = args?.[key] - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined + return isRecordLike(value) ? (value as Record) : undefined } function stringOrNumberArg(args: ToolArgs, key: string): string { @@ -369,9 +368,8 @@ function setGlobalWorkflowVariablesTitle(args: ToolArgs): string { const operations = args?.operations if (!Array.isArray(operations) || operations.length === 0) return 'Setting workflow variables' - const parsed = operations.filter( - (operation): operation is Record => - Boolean(operation) && typeof operation === 'object' && !Array.isArray(operation) + const parsed = operations.filter((operation): operation is Record => + isRecordLike(operation) ) const operationNames = parsed.map((operation) => stringArg(operation, 'operation')) const firstOperation = operationNames[0] @@ -645,10 +643,7 @@ const TERMINAL_OPERATION_TITLES: Record = { function terminalTitle(args: ToolArgs): string { const operation = stringArg(args, 'operation') const nested = args?.args - const inner: ToolArgs = - nested && typeof nested === 'object' && !Array.isArray(nested) - ? (nested as Record) - : undefined + const inner: ToolArgs = isRecordLike(nested) ? (nested as Record) : undefined if (operation === 'run') return runningCommandTitle(stringArg(inner, 'command')) if (operation === 'handoff') { // Matches the browser takeover row: the reason is the whole point of the diff --git a/apps/sim/lib/copilot/validation/selector-validator.ts b/apps/sim/lib/copilot/validation/selector-validator.ts index 30490f583cc..183d63a7de4 100644 --- a/apps/sim/lib/copilot/validation/selector-validator.ts +++ b/apps/sim/lib/copilot/validation/selector-validator.ts @@ -277,28 +277,3 @@ export async function validateSelectorIds( invalid: idsArray.filter((id) => !existingSet.has(id)), } } - -/** - * Batch validate multiple selector fields - * Returns a map of field name to validation result - */ -async function validateAllSelectorFields( - fields: Array<{ fieldName: string; selectorType: string; value: string | string[] }>, - context: { userId: string; workspaceId?: string } -): Promise> { - const results = new Map() - - // Run validations in parallel for better performance - const validationPromises = fields.map(async ({ fieldName, selectorType, value }) => { - const result = await validateSelectorIds(selectorType, value, context) - return { fieldName, result } - }) - - const validationResults = await Promise.all(validationPromises) - - for (const { fieldName, result } of validationResults) { - results.set(fieldName, result) - } - - return results -} diff --git a/apps/sim/lib/core/admission/gate.ts b/apps/sim/lib/core/admission/gate.ts index f3c4866a246..af024e1c782 100644 --- a/apps/sim/lib/core/admission/gate.ts +++ b/apps/sim/lib/core/admission/gate.ts @@ -57,10 +57,3 @@ export function admissionRejectedResponse(): NextResponse { } ) } - -/** - * Returns the current gate metrics for observability. - */ -export function getAdmissionGateStatus(): { inflight: number; maxInflight: number } { - return { inflight, maxInflight: MAX_INFLIGHT } -} diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index aebf094713e..fea7f27b091 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -1,14 +1,22 @@ -import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal' +import type { + CredentialGroupEnrollmentPrincipal, + DelegatedPrincipal, + DelegatedServiceId, + Principal, +} from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import type { ApplicationOperation } from '@/lib/core/application/operation' type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' -export type PrincipalKind = Principal['kind'] +export type PrincipalKind = Exclude type NonDelegatedPrincipalForOperation< O extends { readonly principalKinds: readonly PrincipalKind[] }, -> = Exclude, DelegatedPrincipal> +> = Exclude< + Extract, + DelegatedPrincipal | CredentialGroupEnrollmentPrincipal +> type DelegatedPrincipalForOperation< O extends { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index baf2e9deffc..4ef32b4279f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -528,6 +528,7 @@ export const env = createEnv({ TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu + CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 7f0d0dbea50..ca87caf815d 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -14,6 +14,8 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ FORKING_ENABLED: undefined as boolean | undefined, DEPLOY_AS_BLOCK: undefined as boolean | undefined, TABLES_V2_API: undefined as boolean | undefined, + TABLE_VIEWS: undefined as boolean | undefined, + CREDENTIAL_GROUPS: undefined as boolean | undefined, }, })) @@ -122,6 +124,8 @@ describe('isFeatureEnabled', () => { setEnvFlags({ isAppConfigEnabled: false }) envRef.FORKING_ENABLED = undefined envRef.DEPLOY_AS_BLOCK = undefined + envRef.CREDENTIAL_GROUPS = undefined + envRef.TABLE_VIEWS = undefined }) describe('workspace-forking flag', () => { @@ -160,6 +164,20 @@ describe('isFeatureEnabled', () => { }) }) + describe('credential-groups flag', () => { + it('uses a global fallback switch off AppConfig', async () => { + expect(await isFeatureEnabled('credential-groups')).toBe(false) + + envRef.CREDENTIAL_GROUPS = true + expect(await isFeatureEnabled('credential-groups')).toBe(true) + }) + + it('uses only the global AppConfig clause', async () => { + withAppConfig({ 'credential-groups': { enabled: true } }) + expect(await isFeatureEnabled('credential-groups')).toBe(true) + }) + }) + describe('table-views flag', () => { it('falls back to TABLE_VIEWS when AppConfig is disabled', async () => { envRef.TABLE_VIEWS = undefined diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index cef6ab2e66b..c068326a648 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -144,6 +144,12 @@ const FEATURE_FLAGS = { 'Off-AppConfig falls back to TABLE_VIEWS.', fallback: 'TABLE_VIEWS', }, + 'credential-groups': { + description: + 'Workspace-owned collections that gather managed OAuth credentials from external users. ' + + 'Global on/off only; hosted workspaces must also have an Enterprise subscription.', + fallback: 'CREDENTIAL_GROUPS', + }, } satisfies Record /** diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts index 011bd7e4cf9..b81c817e531 100644 --- a/apps/sim/lib/core/execution-limits/types.ts +++ b/apps/sim/lib/core/execution-limits/types.ts @@ -238,13 +238,6 @@ export function getExecutionDeadlineAt(signal?: AbortSignal): Date | undefined { return deadline === undefined ? undefined : new Date(deadline) } -/** Copies a known execution deadline onto a derived signal. */ -export function preserveExecutionDeadline(source: AbortSignal, target: AbortSignal): AbortSignal { - const deadline = signalDeadlines.get(source) - if (deadline !== undefined) signalDeadlines.set(target, deadline) - return target -} - /** Combines cancellation sources and carries their earliest known execution deadline. */ export function combineExecutionAbortSignals(signals: readonly AbortSignal[]): AbortSignal { if (signals.length === 0) return new AbortController().signal diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index 43fa6cf9feb..25333ca9087 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -718,22 +718,6 @@ export const pollingIdempotency = new IdempotencyService({ storeResultBody: false, }) -/** - * Used by the internal `/api/billing/update-cost` endpoint (copilot, - * workspace-chat, MCP, mothership) to dedupe cost-recording calls. Storage - * is forced to Postgres: the operation writes AI cost to `user_stats`, - * and if Redis evicts the dedup key under memory pressure (high call - * volume) or drops it on restart, a retry would double-record usage — - * real money. DB storage fate-shares with `user_stats` and is - * eviction-proof; ~1-5ms added latency is invisible against LLM call - * latency. - */ -export const billingIdempotency = new IdempotencyService({ - namespace: 'billing', - ttlSeconds: 60 * 60, // 1 hour - forceStorage: 'database', -}) - /** * Dedupes a chat send by its client-generated `userMessageId`, so re-sending * one is safe. diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts b/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts index a0803d1ae61..bab7dfc7ca3 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts @@ -203,7 +203,3 @@ export function getHostedKeyQueue(): HostedKeyQueue { } return cachedQueue } - -export function resetHostedKeyQueue(): void { - cachedQueue = null -} diff --git a/apps/sim/lib/core/utils/response-format.ts b/apps/sim/lib/core/utils/response-format.ts index 97a57d0e72c..a5d4a38f30b 100644 --- a/apps/sim/lib/core/utils/response-format.ts +++ b/apps/sim/lib/core/utils/response-format.ts @@ -113,21 +113,6 @@ export function extractFieldValues( return extractedValues } -/** - * Format extracted field values for display - * Returns formatted string representation of field values - */ -export function formatFieldValues(extractedValues: Record): string { - const formattedValues: string[] = [] - - for (const [fieldName, value] of Object.entries(extractedValues)) { - const formattedValue = typeof value === 'string' ? value : JSON.stringify(value) - formattedValues.push(formattedValue) - } - - return formattedValues.join('\n') -} - /** * Extract block ID from output ID * Handles both formats: "blockId" and "blockId_path" or "blockId.path" @@ -174,18 +159,6 @@ export function hasResponseFormatSelection(selectedOutputs: string[], blockId: s }) } -/** - * Get selected field names for a specific block from output IDs - */ -export function getSelectedFieldNames(selectedOutputs: string[], blockId: string): string[] { - return selectedOutputs - .filter((outputId) => { - const blockIdForOutput = extractBlockIdFromOutputId(outputId) - return blockIdForOutput === blockId && outputId.includes('_') - }) - .map((outputId) => extractPathFromOutputId(outputId, blockId)) -} - /** * Internal helper to traverse an object path without parsing * @param obj The object to traverse diff --git a/apps/sim/lib/core/utils/theme.ts b/apps/sim/lib/core/utils/theme.ts index 5d7101ca7ec..46035f4ce53 100644 --- a/apps/sim/lib/core/utils/theme.ts +++ b/apps/sim/lib/core/utils/theme.ts @@ -33,11 +33,3 @@ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') { root.classList.add(theme) } } - -/** - * Gets the current theme from next-themes localStorage - */ -export function getThemeFromNextThemes(): 'system' | 'light' | 'dark' { - if (typeof window === 'undefined') return 'system' - return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'system' -} diff --git a/apps/sim/lib/core/utils/user-file.ts b/apps/sim/lib/core/utils/user-file.ts index abcac62da98..546c9dbc4cb 100644 --- a/apps/sim/lib/core/utils/user-file.ts +++ b/apps/sim/lib/core/utils/user-file.ts @@ -112,20 +112,3 @@ export function filterUserFileForDisplay(data: Record): Record< } return filtered } - -/** - * Extracts base64 content from either a raw base64 string or a UserFile object. - * Useful for tools that accept file input in either format. - * @returns The base64 string, or undefined if not found - */ -export function extractBase64FromFileInput( - input: string | UserFileLike | null | undefined -): string | undefined { - if (typeof input === 'string') { - return input - } - if (input?.base64) { - return input.base64 - } - return undefined -} diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts new file mode 100644 index 00000000000..f61a27f8ee3 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -0,0 +1,26 @@ +import type { Principal } from '@sim/auth/principal' +import type { + WorkspaceAuthorizationContext, + WorkspaceDelegationPolicy, +} from '@/lib/core/application' +import type { CredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' + +export const CREDENTIAL_GROUP_DELEGATION_AUDIENCE = 'sim:credential-groups' + +export interface CredentialGroupApplicationContext + extends WorkspaceAuthorizationContext, + CredentialGroupCredentialListContext {} + +export const credentialGroupDelegationPolicy = { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: CredentialGroupApplicationContext + ) => principal.resourceScope?.credentialGroupId === context.credentialGroupId, +} satisfies WorkspaceDelegationPolicy + +export const credentialGroupWorkspaceDelegationPolicy = { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: (principal: Extract) => + principal.resourceScope?.credentialGroupId === undefined, +} satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credential-groups/application/context.ts b/apps/sim/lib/credential-groups/application/context.ts new file mode 100644 index 00000000000..3329e7a3b9d --- /dev/null +++ b/apps/sim/lib/credential-groups/application/context.ts @@ -0,0 +1,53 @@ +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization' +import { + isCredentialGroupsAvailable, + resolveCredentialGroupsAvailability, +} from '@/lib/credential-groups/availability' +import { loadCredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export async function requireCredentialGroupsAvailable(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + const availability = await resolveCredentialGroupsAvailability(ownerBilling) + if (!availability.available) { + const message = + availability.reason === 'enterprise_plan_required' + ? 'Credential Groups are not available. Enterprise plan required.' + : 'Credential Groups are not available' + throw new OrchestrationError('forbidden', message) + } +} + +export async function requireCredentialGroupSettingsAvailable(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new OrchestrationError('not_found', 'Credential Groups are not available') + } +} + +export async function resolveCredentialGroupWorkspaceContext(workspaceId: string) { + const workspace = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace +} + +export async function resolveCredentialGroupContext( + credentialGroupId: string +): Promise { + const group = await loadCredentialGroupCredentialListContext(credentialGroupId) + if (!group) throw new OrchestrationError('not_found', 'Credential group not found') + return { ...(await resolveCredentialGroupWorkspaceContext(group.workspaceId)), ...group } +} + +export async function resolveCredentialGroupSettingsContext( + credentialGroupId: string, + assertedWorkspaceId: string +): Promise { + const context = await resolveCredentialGroupContext(credentialGroupId) + if (context.workspaceId !== assertedWorkspaceId) { + throw new OrchestrationError('not_found', 'Credential group not found') + } + return context +} diff --git a/apps/sim/lib/credential-groups/application/delegation.ts b/apps/sim/lib/credential-groups/application/delegation.ts new file mode 100644 index 00000000000..56ad6895c0a --- /dev/null +++ b/apps/sim/lib/credential-groups/application/delegation.ts @@ -0,0 +1,41 @@ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' + +export class InvalidCredentialGroupDelegationError extends Error { + constructor() { + super('Credential Group execution requires valid workflow delegation') + this.name = 'InvalidCredentialGroupDelegationError' + } +} + +/** Authenticates and binds executor claims to Credential Group application scope. */ +export async function authenticateCredentialGroupDelegation( + authorization: string, + credentialGroupId?: string +): Promise { + if (!authorization.startsWith('Bearer ')) throw new InvalidCredentialGroupDelegationError() + + try { + const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + return await bindInternalExecutorDelegation(claims, { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + ...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}), + }) + } catch (error) { + if ( + error instanceof InvalidInternalDelegationTokenError || + error instanceof InvalidInternalDelegationBindingError + ) { + throw new InvalidCredentialGroupDelegationError() + } + throw error + } +} diff --git a/apps/sim/lib/credential-groups/application/enrollment-auth.ts b/apps/sim/lib/credential-groups/application/enrollment-auth.ts new file mode 100644 index 00000000000..25df4bf0cf3 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/enrollment-auth.ts @@ -0,0 +1,12 @@ +import type { CredentialGroupEnrollmentPrincipal } from '@sim/auth/principal' +import { authenticatePublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' + +/** Exchanges a valid invitation bearer for its bounded external enrollment principal. */ +export async function authenticateCredentialGroupEnrollment( + invitationToken: string +): Promise { + if (!invitationToken.trim() || invitationToken.length > 128) return null + const identity = await authenticatePublicCredentialGroupEnrollment(invitationToken) + if (!identity) return null + return Object.freeze({ kind: 'credential_group_enrollment' as const, ...identity }) +} diff --git a/apps/sim/lib/credential-groups/application/enrollment-operations.ts b/apps/sim/lib/credential-groups/application/enrollment-operations.ts new file mode 100644 index 00000000000..b53788bda45 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/enrollment-operations.ts @@ -0,0 +1,32 @@ +import type { ApplicationOperation } from '@/lib/core/application' + +export interface CredentialGroupEnrollmentOperation + extends ApplicationOperation { + readonly principalKind: 'credential_group_enrollment' +} + +function defineCredentialGroupEnrollmentOperation( + operation: CredentialGroupEnrollmentOperation +): CredentialGroupEnrollmentOperation { + if (!operation.id.trim()) throw new Error('Credential Group enrollment operation ID is required') + return Object.freeze(operation) +} + +export const credentialGroupEnrollmentOperations = { + read: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.read', + principalKind: 'credential_group_enrollment', + }), + startOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.oauth.start', + principalKind: 'credential_group_enrollment', + }), + completeOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.oauth.complete', + principalKind: 'credential_group_enrollment', + }), + complete: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.complete', + principalKind: 'credential_group_enrollment', + }), +} as const diff --git a/apps/sim/lib/credential-groups/application/list-credentials.test.ts b/apps/sim/lib/credential-groups/application/list-credentials.test.ts new file mode 100644 index 00000000000..62254941165 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-credentials.test.ts @@ -0,0 +1,276 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getWorkspaceOwnerSubscriptionAccess: vi.fn(), + listCredentials: vi.fn(), + loadGroup: vi.fn(), + loadWorkspace: vi.fn(), + resolveCredentialGroupsAvailability: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability, +})) + +vi.mock('@/lib/credential-groups/credentials', () => ({ + CredentialGroupCredentialCursorNotFoundError: class extends Error { + constructor() { + super('Credential group credential cursor not found') + this.name = 'CredentialGroupCredentialCursorNotFoundError' + } + }, + listCredentialGroupCredentialReferences: mocks.listCredentials, + loadCredentialGroupCredentialListContext: mocks.loadGroup, + MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE: 100, +})) + +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, +})) + +import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' +import { CredentialGroupCredentialCursorNotFoundError } from '@/lib/credential-groups/credentials' + +const groupContext = { + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + name: 'Credential Group', + status: 'active' as const, + options: [ + { + id: 'option-1', + provider: 'gmail' as const, + label: 'Work Gmail', + authorizationAppId: 'google:client-1', + requiredScopes: ['gmail.readonly'], + scopeVersion: 1, + required: true, + status: 'active' as const, + }, + { + id: 'option-disabled', + provider: 'gmail' as const, + label: 'Old Gmail', + authorizationAppId: 'google:client-1', + requiredScopes: ['gmail.readonly'], + scopeVersion: 1, + required: false, + status: 'disabled' as const, + }, + ], +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const input = { credentialGroupId: 'group-1', limit: 50 } + +function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credential-groups', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialGroupId }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + } +} + +describe('listCredentialGroupCredentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadGroup.mockResolvedValue(groupContext) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true }) + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ available: true }) + mocks.listCredentials.mockResolvedValue({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'person@example.com', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + nextCursor: 'credential-1', + }) + }) + + it('rejects unsupported principals before loading the group', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialGroupCredentials.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadGroup).not.toHaveBeenCalled() + }) + + it('rejects executor delegation scoped to another group', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal('group-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('returns a bounded page after current workspace and entitlement checks', async () => { + const result = await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.listCredentials).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + cursor: undefined, + email: undefined, + credentialProviderIds: undefined, + credentialGroupOptionIds: ['option-1'], + }) + expect(result).toEqual({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'person@example.com', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + count: 1, + hasMore: true, + nextCursor: 'credential-1', + }) + }) + + it('filters by canonical providers active in the group', async () => { + await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['google-email', 'google-email'] }, + }) + + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ credentialProviderIds: ['google-email'] }) + ) + }) + + it('normalizes an exact enrollment email filter', async () => { + await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, email: ' Person@Example.COM ' }, + }) + + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ email: 'person@example.com' }) + ) + }) + + it('rejects providers that are not active in the group before credential access', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['slack'] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('fails before listing when the group is disabled', async () => { + mocks.loadGroup.mockResolvedValue({ ...groupContext, status: 'disabled' }) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('fails before listing when Credential Groups are unavailable', async () => { + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ + available: false, + reason: 'feature_disabled', + }) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: 'Credential Groups are not available', + }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('identifies the Enterprise requirement for unavailable hosted workspaces', async () => { + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: false }) + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ + available: false, + reason: 'enterprise_plan_required', + }) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: 'Credential Groups are not available. Enterprise plan required.', + }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('rejects limits outside the bounded page size', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, limit: 101 }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('classifies a stale or cross-group cursor as invalid input', async () => { + mocks.listCredentials.mockRejectedValueOnce(new CredentialGroupCredentialCursorNotFoundError()) + + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, cursor: 'credential-other' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/list-credentials.ts b/apps/sim/lib/credential-groups/application/list-credentials.ts new file mode 100644 index 00000000000..3dec5d6e958 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-credentials.ts @@ -0,0 +1,111 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupCredentialCursorNotFoundError, + type CredentialGroupCredentialReference, + listCredentialGroupCredentialReferences, + MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE, +} from '@/lib/credential-groups/credentials' +import { + getCredentialGroupProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +export interface ListCredentialGroupCredentialsInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + credentialProviderIds?: string[] +} + +export interface ListCredentialGroupCredentialsResult { + credentials: CredentialGroupCredentialReference[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listCredentials, + resolveContext: ({ input }: { input: ListCredentialGroupCredentialsInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}` + ) + } + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + + const credentialProviderIds = [...new Set(input.credentialProviderIds ?? [])] + if (credentialProviderIds.some((providerId) => !providerId.trim())) { + throw new OrchestrationError('validation', 'Credential provider IDs must not be empty') + } + const activeOptions = context.options.filter((option) => option.status === 'active') + const activeProviderIds = new Set( + activeOptions.map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Credential Group provider is not registered: ${option.provider}`) + } + return getCredentialGroupProviderId(option.provider) + }) + ) + const invalidProviderIds = credentialProviderIds.filter( + (providerId) => !activeProviderIds.has(providerId) + ) + if (invalidProviderIds.length > 0) { + throw new OrchestrationError( + 'validation', + `Credential providers are not active in this group: ${invalidProviderIds.join(', ')}` + ) + } + + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupCredentialReferences({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + credentialGroupOptionIds: activeOptions.map((option) => option.id), + limit: input.limit, + cursor: input.cursor, + email, + credentialProviderIds: credentialProviderIds.length > 0 ? credentialProviderIds : undefined, + }) + } catch (error) { + if (error instanceof CredentialGroupCredentialCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + credentials: page.credentials, + count: page.credentials.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/list-groups.ts b/apps/sim/lib/credential-groups/application/list-groups.ts new file mode 100644 index 00000000000..3a89e4fca2d --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-groups.ts @@ -0,0 +1,68 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupWorkspaceDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupWorkspaceContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupCursorNotFoundError, + type CredentialGroupSummary, + listCredentialGroupSummaries, + MAX_CREDENTIAL_GROUP_PAGE_SIZE, +} from '@/lib/credential-groups/groups' + +export interface ListCredentialGroupsInput { + workspaceId: string + limit: number + cursor?: string +} + +export interface ListCredentialGroupsResult { + credentialGroups: CredentialGroupSummary[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupsForWorkflow = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listGroups, + resolveContext: ({ input }: { input: ListCredentialGroupsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: { delegation: credentialGroupWorkspaceDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_PAGE_SIZE}` + ) + } + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupSummaries({ + workspaceId: context.workspaceId, + limit: input.limit, + cursor: input.cursor, + }) + } catch (error) { + if (error instanceof CredentialGroupCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + credentialGroups: page.credentialGroups, + count: page.credentialGroups.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts new file mode 100644 index 00000000000..418ee854c6b --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-people.ts @@ -0,0 +1,79 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupEnrollmentError, + type CredentialGroupEnrollmentStatus, + listCredentialGroupEnrollments, +} from '@/lib/credential-groups/enrollments' + +export const CREDENTIAL_GROUP_PEOPLE_STATUSES = [ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', +] as const satisfies readonly CredentialGroupEnrollmentStatus[] + +export interface ListCredentialGroupPeopleInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + statuses?: CredentialGroupEnrollmentStatus[] +} + +export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listPeople, + resolveContext: ({ input }: { input: ListCredentialGroupPeopleInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }) => { + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) { + throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100') + } + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + const statuses = [...new Set(input.statuses ?? [])] + const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) + if (statuses.some((status) => !allowedStatuses.has(status))) { + throw new OrchestrationError('validation', 'People status filter is invalid') + } + await requireCredentialGroupsAvailable(context.workspaceId) + + try { + const page = await listCredentialGroupEnrollments( + context.workspaceId, + context.credentialGroupId, + input.limit, + input.cursor, + { email, statuses: statuses.length > 0 ? statuses : undefined } + ) + return { + people: page.enrollments, + count: page.enrollments.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError) { + throw new OrchestrationError( + error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal', + error.message + ) + } + throw error + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts new file mode 100644 index 00000000000..10c044b9634 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + invite: vi.fn(), + loadInviter: vi.fn(), + requireAvailable: vi.fn(), + resolveGroup: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/context', () => ({ + requireCredentialGroupSettingsAvailable: mocks.requireAvailable, + resolveCredentialGroupSettingsContext: mocks.resolveGroup, +})) + +vi.mock('@/lib/credential-groups/enrollments', () => ({ + CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: 404 | 409 | 502 + ) { + super(message) + } + }, + inviteCredentialGroupEnrollments: mocks.invite, + loadCredentialGroupInviterIdentity: mocks.loadInviter, + resendCredentialGroupEnrollment: vi.fn(), + revokeCredentialGroupEnrollment: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + credentialGroupId: 'group-1', + name: 'Support', + status: 'active' as const, + options: [], +} +const principal: SessionPrincipal = { + kind: 'session', + userId: 'admin-1', + sessionId: 'session-1', +} +const input = { + assertedWorkspaceId: 'workspace-1', + credentialGroupId: 'group-1', + emails: [' Person@Example.com ', 'person@example.com'], +} + +describe('Credential Group enrollment Settings operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveGroup.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.requireAvailable.mockResolvedValue(undefined) + mocks.loadInviter.mockResolvedValue({ name: 'Admin', email: 'admin@example.com' }) + mocks.invite.mockResolvedValue({ results: [], sentCount: 0, failedCount: 0 }) + }) + + it('requires current workspace-admin permission before delivery', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + await expect( + inviteCredentialGroupEnrollmentsSettings.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.invite).not.toHaveBeenCalled() + }) + + it('derives the inviter and normalizes recipients inside the application command', async () => { + await inviteCredentialGroupEnrollmentsSettings.execute({ principal, input }) + + expect(mocks.loadInviter).toHaveBeenCalledWith('admin-1') + expect(mocks.invite).toHaveBeenCalledWith('workspace-1', 'group-1', 'admin-1', 'Admin', { + emails: ['person@example.com'], + }) + }) + + it('rejects an unbounded batch even outside the HTTP adapter', async () => { + await expect( + inviteCredentialGroupEnrollmentsSettings.execute({ + principal, + input: { + ...input, + emails: Array.from({ length: 101 }, (_, index) => `person-${index}@example.com`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.invite).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.ts new file mode 100644 index 00000000000..8896aa495c3 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.ts @@ -0,0 +1,143 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + requireCredentialGroupSettingsAvailable, + resolveCredentialGroupSettingsContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation' +import { + CredentialGroupEnrollmentError, + inviteCredentialGroupEnrollments, + loadCredentialGroupInviterIdentity, + resendCredentialGroupEnrollment, + revokeCredentialGroupEnrollment, +} from '@/lib/credential-groups/enrollments' + +interface CredentialGroupEnrollmentSettingsInput { + assertedWorkspaceId: string + credentialGroupId: string +} + +function normalizeEnrollmentError(error: unknown): never { + if (error instanceof CredentialGroupEnrollmentError) { + if (error.status === 404) throw new OrchestrationError('not_found', error.message) + if (error.status === 409) throw new OrchestrationError('conflict', error.message) + } + throw error +} + +async function requireInviterIdentity(userId: string): Promise { + const inviter = await loadCredentialGroupInviterIdentity(userId) + const inviterName = inviter?.name?.trim() || inviter?.email + if (!inviterName) { + throw new OrchestrationError('conflict', 'Inviting user has no display identity') + } + return inviterName +} + +export interface InviteCredentialGroupEnrollmentsSettingsInput + extends CredentialGroupEnrollmentSettingsInput { + emails: string[] +} + +export const inviteCredentialGroupEnrollmentsSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.inviteBatch, + resolveContext: ({ input }: { input: InviteCredentialGroupEnrollmentsSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + const inviterName = await requireInviterIdentity(principal.userId) + const emails = validateCredentialGroupInvitationEmails(input.emails) + try { + return await inviteCredentialGroupEnrollments( + context.workspaceId, + context.credentialGroupId, + principal.userId, + inviterName, + { emails } + ) + } catch (error) { + normalizeEnrollmentError(error) + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Sent ${result.sentCount} Credential Group invitation${result.sentCount === 1 ? '' : 's'}`, + metadata: { sentCount: result.sentCount, failedCount: result.failedCount }, + }), +}) + +export interface ResendCredentialGroupEnrollmentSettingsInput + extends CredentialGroupEnrollmentSettingsInput { + enrollmentId: string +} + +export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.resendEnrollment, + resolveContext: ({ input }: { input: ResendCredentialGroupEnrollmentSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + const inviterName = await requireInviterIdentity(principal.userId) + try { + const credentialGroupEnrollment = await resendCredentialGroupEnrollment( + context.workspaceId, + context.credentialGroupId, + input.enrollmentId, + principal.userId, + inviterName + ) + return { credentialGroupEnrollment } + } catch (error) { + normalizeEnrollmentError(error) + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Resent a Credential Group invitation to ${result.credentialGroupEnrollment.email}`, + metadata: { enrollmentId: result.credentialGroupEnrollment.id }, + }), +}) + +export interface RevokeCredentialGroupEnrollmentSettingsInput + extends CredentialGroupEnrollmentSettingsInput { + enrollmentId: string +} + +export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.revokeEnrollment, + resolveContext: ({ input }: { input: RevokeCredentialGroupEnrollmentSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + const credentialGroupEnrollment = await revokeCredentialGroupEnrollment( + context.workspaceId, + context.credentialGroupId, + input.enrollmentId + ) + return { credentialGroupEnrollment } + } catch (error) { + normalizeEnrollmentError(error) + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Revoked Credential Group access for ${result.credentialGroupEnrollment.email}`, + 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 new file mode 100644 index 00000000000..3a889b91aa0 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + list: vi.fn(), + requireAvailable: vi.fn(), + resolveGroup: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspace: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/context', () => ({ + requireCredentialGroupSettingsAvailable: mocks.requireAvailable, + resolveCredentialGroupSettingsContext: mocks.resolveGroup, + resolveCredentialGroupWorkspaceContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/credential-groups/service', () => ({ + createCredentialGroup: mocks.create, + deleteCredentialGroup: vi.fn(), + getCredentialGroup: vi.fn(), + listCredentialGroups: mocks.list, + updateCredentialGroup: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { + createCredentialGroupSettings, + listCredentialGroupSettings, +} from '@/lib/credential-groups/application/manage-groups' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'admin-1', + sessionId: 'session-1', +} +const enrollmentPrincipal: CredentialGroupEnrollmentPrincipal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', +} + +describe('Credential Group Settings application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.requireAvailable.mockResolvedValue(undefined) + mocks.list.mockResolvedValue([]) + mocks.create.mockResolvedValue({ id: 'group-1', name: 'Support' }) + }) + + it('rejects an enrollment bearer before loading workspace settings', async () => { + await expect( + listCredentialGroupSettings.execute({ + principal: enrollmentPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + }) + + it('requires current workspace-admin permission before listing', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + listCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('lists settings only after authorization and entitlement checks', async () => { + const result = await listCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + + expect(mocks.requireAvailable).toHaveBeenCalledWith('workspace-1') + expect(mocks.list).toHaveBeenCalledWith('workspace-1') + expect(result).toEqual({ credentialGroups: [] }) + }) + + it('derives created-by identity from the authenticated session principal', async () => { + await createCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { + workspaceId: 'workspace-1', + credentialGroup: { name: 'Support', options: [] }, + }, + }) + + expect(mocks.create).toHaveBeenCalledWith('workspace-1', 'admin-1', { + name: 'Support', + options: [], + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts new file mode 100644 index 00000000000..f8e9f420bf2 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -0,0 +1,175 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + requireCredentialGroupSettingsAvailable, + resolveCredentialGroupSettingsContext, + resolveCredentialGroupWorkspaceContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + validateCreateCredentialGroupInput, + validateCredentialGroupEnrollmentPage, + validateUpdateCredentialGroupInput, +} from '@/lib/credential-groups/application/validation' +import { + CredentialGroupEnrollmentError, + listCredentialGroupEnrollments, +} from '@/lib/credential-groups/enrollments' +import { + createCredentialGroup, + deleteCredentialGroup, + getCredentialGroup, + listCredentialGroups, + updateCredentialGroup, +} from '@/lib/credential-groups/service' +import type { + CreateCredentialGroupInput, + UpdateCredentialGroupInput, +} from '@/lib/credential-groups/types' + +function throwCredentialGroupConflict(error: unknown): never { + if (getPostgresErrorCode(error) === '23505') { + throw new OrchestrationError('conflict', 'A credential group with this name already exists') + } + throw error +} + +export interface ListCredentialGroupSettingsInput { + workspaceId: string +} + +export const listCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listSettings, + resolveContext: ({ input }: { input: ListCredentialGroupSettingsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + async execute({ context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + return { credentialGroups: await listCredentialGroups(context.workspaceId) } + }, +}) + +export interface CreateCredentialGroupSettingsInput { + workspaceId: string + credentialGroup: CreateCredentialGroupInput +} + +export const createCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.create, + resolveContext: ({ input }: { input: CreateCredentialGroupSettingsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + const credentialGroup = await createCredentialGroup( + context.workspaceId, + principal.userId, + validateCreateCredentialGroupInput(input.credentialGroup) + ) + return { credentialGroup } + } catch (error) { + throwCredentialGroupConflict(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: result.credentialGroup.id, + resourceName: result.credentialGroup.name, + description: 'Created a Credential Group', + }), +}) + +interface CredentialGroupSettingsTargetInput { + assertedWorkspaceId: string + credentialGroupId: string +} + +export interface GetCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput { + limit: number + cursor?: string +} + +export const getCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.readSettings, + resolveContext: ({ input }: { input: GetCredentialGroupSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + validateCredentialGroupEnrollmentPage(input.limit) + const credentialGroup = await getCredentialGroup(context.workspaceId, context.credentialGroupId) + if (!credentialGroup) throw new OrchestrationError('not_found', 'Credential group not found') + try { + const enrollmentPage = await listCredentialGroupEnrollments( + context.workspaceId, + context.credentialGroupId, + input.limit, + input.cursor + ) + return { credentialGroup, ...enrollmentPage } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError && error.status === 404) { + throw new OrchestrationError('not_found', error.message) + } + throw error + } + }, +}) + +export interface UpdateCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput { + update: UpdateCredentialGroupInput +} + +export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.update, + resolveContext: ({ input }: { input: UpdateCredentialGroupSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + const credentialGroup = await updateCredentialGroup( + context.workspaceId, + context.credentialGroupId, + validateUpdateCredentialGroupInput(input.update) + ) + if (!credentialGroup) { + throw new OrchestrationError('not_found', 'Credential group not found') + } + return { credentialGroup } + } catch (error) { + throwCredentialGroupConflict(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: result.credentialGroup.id, + resourceName: result.credentialGroup.name, + description: 'Updated a Credential Group', + }), +}) + +export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.delete, + resolveContext: ({ input }: { input: CredentialGroupSettingsTargetInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + const deleted = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId) + if (!deleted) throw new OrchestrationError('not_found', 'Credential group not found') + return { success: true as const } + }, + projectAudit: ({ context }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: 'Deleted a Credential Group', + }), +}) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts new file mode 100644 index 00000000000..ecaa65a92fe --- /dev/null +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -0,0 +1,92 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const credentialGroupOperations = { + listSettings: defineWorkspaceOperation({ + id: 'credential_groups.settings.list', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + create: defineWorkspaceOperation({ + id: 'credential_groups.create', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + readSettings: defineWorkspaceOperation({ + id: 'credential_groups.settings.read', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + update: defineWorkspaceOperation({ + id: 'credential_groups.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + delete: defineWorkspaceOperation({ + id: 'credential_groups.delete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + inviteBatch: defineWorkspaceOperation({ + id: 'credential_groups.invites.send_batch', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + resendEnrollment: defineWorkspaceOperation({ + id: 'credential_groups.enrollments.resend', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + revokeEnrollment: defineWorkspaceOperation({ + id: 'credential_groups.enrollments.revoke', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listCredentials: defineWorkspaceOperation({ + id: 'credential_groups.credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + listGroups: defineWorkspaceOperation({ + id: 'credential_groups.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + listPeople: defineWorkspaceOperation({ + id: 'credential_groups.people.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + sendInvite: defineWorkspaceOperation({ + id: 'credential_groups.invites.send', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + startSlackConfiguration: defineWorkspaceOperation({ + id: 'credential_groups.slack_configuration.start', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + completeSlackConfiguration: defineWorkspaceOperation({ + id: 'credential_groups.slack_configuration.complete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), +} as const diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts new file mode 100644 index 00000000000..9fae003f2de --- /dev/null +++ b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal' +import { sha256Hex } from '@sim/security/hash' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + completeEnrollment: vi.fn(), + getEnrollment: vi.fn(), + getOAuthContext: vi.fn(), + startOAuth: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/enrollments', () => ({ + completeAuthorizedCredentialGroupEnrollment: mocks.completeEnrollment, + getAuthorizedCredentialGroupOAuthContext: mocks.getOAuthContext, + getAuthorizedPublicCredentialGroupEnrollment: mocks.getEnrollment, +})) + +vi.mock('@/lib/credential-groups/oauth', () => ({ + completeCredentialGroupOAuth: vi.fn(), + startCredentialGroupOAuth: mocks.startOAuth, +})) + +import { + readPublicCredentialGroupEnrollment, + startPublicCredentialGroupOAuth, +} from '@/lib/credential-groups/application/public-enrollment' + +const invitationToken = 'invitation-token' +const principal: CredentialGroupEnrollmentPrincipal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: sha256Hex(invitationToken), +} +const identity = { + workspaceId: principal.workspaceId, + credentialGroupId: principal.credentialGroupId, + enrollmentId: principal.enrollmentId, + email: principal.email, + invitationTokenHash: principal.invitationTokenHash, +} + +describe('public Credential Group enrollment application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getEnrollment.mockResolvedValue({ status: 'invited', options: [] }) + mocks.getOAuthContext.mockResolvedValue({ + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + option: { id: 'option-1' }, + }) + mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize') + }) + + it('rejects a workspace session before resolving invitation data', async () => { + const session: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + readPublicCredentialGroupEnrollment.execute({ principal: session, input: {} }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.getEnrollment).not.toHaveBeenCalled() + }) + + it('revalidates the invitation identity before returning enrollment metadata', async () => { + const result = await readPublicCredentialGroupEnrollment.execute({ principal, input: {} }) + + expect(mocks.getEnrollment).toHaveBeenCalledWith(identity) + expect(result).toEqual({ enrollment: { status: 'invited', options: [] } }) + }) + + it('fails closed when the current invitation no longer resolves', async () => { + mocks.getEnrollment.mockResolvedValue(null) + + await expect( + readPublicCredentialGroupEnrollment.execute({ principal, input: {} }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('rejects a substituted bearer before creating provider state', async () => { + await expect( + startPublicCredentialGroupOAuth.execute({ + principal, + input: { invitationToken: 'different-token', optionId: 'option-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + + it('starts OAuth only for the option bound to the current enrollment principal', async () => { + const result = await startPublicCredentialGroupOAuth.execute({ + principal, + input: { invitationToken, optionId: 'option-1' }, + }) + + expect(mocks.getOAuthContext).toHaveBeenCalledWith(identity, 'option-1') + expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts new file mode 100644 index 00000000000..ccda73d5984 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -0,0 +1,188 @@ +import type { CredentialGroupEnrollmentPrincipal, Principal } from '@sim/auth/principal' +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations' +import { + completeAuthorizedCredentialGroupEnrollment, + getAuthorizedCredentialGroupOAuthContext, + getAuthorizedPublicCredentialGroupEnrollment, + type PublicCredentialGroupEnrollmentIdentity, +} from '@/lib/credential-groups/enrollments' +import { + completeCredentialGroupOAuth, + startCredentialGroupOAuth, +} from '@/lib/credential-groups/oauth' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' + +interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition { + operation: O + resolveContext(args: { principal: CredentialGroupEnrollmentPrincipal; input: I }): Promise + execute(args: { principal: CredentialGroupEnrollmentPrincipal; input: I; context: C }): Promise +} + +function requireCredentialGroupEnrollmentPrincipal( + principal: Principal +): asserts principal is CredentialGroupEnrollmentPrincipal { + if (principal.kind !== 'credential_group_enrollment') { + throw new OrchestrationError( + 'forbidden', + 'This operation requires a Credential Group invitation' + ) + } +} + +function requireMatchingContext( + principal: CredentialGroupEnrollmentPrincipal, + context: PublicCredentialGroupEnrollmentIdentity +): void { + if ( + context.workspaceId !== principal.workspaceId || + context.credentialGroupId !== principal.credentialGroupId || + context.enrollmentId !== principal.enrollmentId || + context.email !== principal.email || + !safeCompare(context.invitationTokenHash, principal.invitationTokenHash) + ) { + throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + } +} + +function defineAuthorizedCredentialGroupEnrollmentUseCase< + const O extends + (typeof credentialGroupEnrollmentOperations)[keyof typeof credentialGroupEnrollmentOperations], + I, + C extends PublicCredentialGroupEnrollmentIdentity, + R, +>( + definition: AuthorizedCredentialGroupEnrollmentUseCaseDefinition +): OperationUseCase { + async function authorize(principal: Principal, input: I) { + requireCredentialGroupEnrollmentPrincipal(principal) + const context = await definition.resolveContext({ principal, input }) + requireMatchingContext(principal, context) + return { principal, input, context } + } + + return { + operation: definition.operation, + async authorize({ principal, input }) { + await authorize(principal, input) + }, + async execute({ principal, input }) { + const authorized = await authorize(principal, input) + return definition.execute(authorized) + }, + } +} + +function identityFromPrincipal( + principal: CredentialGroupEnrollmentPrincipal +): PublicCredentialGroupEnrollmentIdentity { + return { + workspaceId: principal.workspaceId, + credentialGroupId: principal.credentialGroupId, + enrollmentId: principal.enrollmentId, + email: principal.email, + invitationTokenHash: principal.invitationTokenHash, + } +} + +function requireInvitationToken( + principal: CredentialGroupEnrollmentPrincipal, + invitationToken: string +): void { + if (!safeCompare(sha256Hex(invitationToken), principal.invitationTokenHash)) { + throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + } +} + +interface PublicEnrollmentContext extends PublicCredentialGroupEnrollmentIdentity { + enrollment: NonNullable>> +} + +async function resolvePublicEnrollmentContext( + principal: CredentialGroupEnrollmentPrincipal +): Promise { + const identity = identityFromPrincipal(principal) + const enrollment = await getAuthorizedPublicCredentialGroupEnrollment(identity) + if (!enrollment) throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + return { ...identity, enrollment } +} + +export const readPublicCredentialGroupEnrollment = defineAuthorizedCredentialGroupEnrollmentUseCase( + { + operation: credentialGroupEnrollmentOperations.read, + resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal), + async execute({ context }) { + return { enrollment: context.enrollment } + }, + } +) + +export const completePublicCredentialGroupEnrollment = + defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.complete, + resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal), + async execute({ context }) { + const completed = await completeAuthorizedCredentialGroupEnrollment(context) + return { completed } + }, + }) + +interface PublicCredentialGroupOAuthInput { + invitationToken: string + optionId: string +} + +interface PublicCredentialGroupOAuthContext extends PublicCredentialGroupEnrollmentIdentity { + oauth: NonNullable>> +} + +async function resolvePublicOAuthContext( + principal: CredentialGroupEnrollmentPrincipal, + optionId: string +): Promise { + const identity = identityFromPrincipal(principal) + const oauth = await getAuthorizedCredentialGroupOAuthContext(identity, optionId) + if (!oauth) throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + return { ...identity, oauth } +} + +export const startPublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.startOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: PublicCredentialGroupOAuthInput + }) => resolvePublicOAuthContext(principal, input.optionId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.invitationToken) + return { + authorizationUrl: await startCredentialGroupOAuth(context.oauth, input.invitationToken), + } + }, +}) + +interface CompletePublicCredentialGroupOAuthInput { + attempt: CredentialGroupOAuthAttempt + code: string +} + +export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.completeOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: CompletePublicCredentialGroupOAuthInput + }) => resolvePublicOAuthContext(principal, input.attempt.optionId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.attempt.invitationToken) + await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code) + return { connectedOptionId: context.oauth.option.id } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/send-invite.ts b/apps/sim/lib/credential-groups/application/send-invite.ts new file mode 100644 index 00000000000..6ae7d855219 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/send-invite.ts @@ -0,0 +1,72 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupEnrollmentError, + inviteCredentialGroupEnrollment, + loadCredentialGroupInviterIdentity, +} from '@/lib/credential-groups/enrollments' + +export interface SendCredentialGroupInviteInput { + credentialGroupId: string + email: string +} + +export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.sendInvite, + resolveContext: ({ input }: { input: SendCredentialGroupInviteInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ principal, input, context }) => { + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + const email = normalizeEmail(input.email) + if (!isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + await requireCredentialGroupsAvailable(context.workspaceId) + + const userId = requirePrincipalSubjectUserId(principal) + const inviter = await loadCredentialGroupInviterIdentity(userId) + const inviterName = inviter?.name?.trim() || inviter?.email + if (!inviterName) { + throw new OrchestrationError('conflict', 'Inviting user has no display identity') + } + + try { + const enrollment = await inviteCredentialGroupEnrollment( + context.workspaceId, + context.credentialGroupId, + userId, + inviterName, + email + ) + return { enrollment } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError) { + throw new OrchestrationError( + error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'internal', + error.message + ) + } + throw error + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Invited ${result.enrollment.email} to connect accounts`, + metadata: { email: normalizeEmail(input.email), enrollmentId: result.enrollment.id }, + }), +}) diff --git a/apps/sim/lib/credential-groups/application/slack-managed-users.ts b/apps/sim/lib/credential-groups/application/slack-managed-users.ts new file mode 100644 index 00000000000..a448edbd284 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/slack-managed-users.ts @@ -0,0 +1,138 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { + consumeSlackManagedUsersAttempt, + createSlackManagedUsersAttempt, + exchangeAndConfigureSlackManagedUsers, + loadSlackManagedUsersAttempt, + type SlackManagedUsersAttempt, +} from '@/lib/credential-groups/slack-managed-users' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +async function requireCredentialGroups(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new OrchestrationError('not_found', 'Credential Groups are not available') + } +} + +async function resolveWorkspace(workspaceId: string) { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveCredentialGroup(groupId: string, assertedWorkspaceId: string) { + const [group] = await db + .select({ id: credentialGroup.id, workspaceId: credentialGroup.workspaceId }) + .from(credentialGroup) + .where(eq(credentialGroup.id, groupId)) + .limit(1) + if (!group || group.workspaceId !== assertedWorkspaceId) { + throw new OrchestrationError('not_found', 'Credential Group not found') + } + return { ...(await resolveWorkspace(group.workspaceId)), credentialGroupId: group.id } +} + +export interface StartSlackCredentialGroupConfigurationInput { + assertedWorkspaceId: string + credentialGroupId: string + slackBotCredentialId: string + clientId: string + clientSecret: string +} + +export const startSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.startSlackConfiguration, + resolveContext: ({ input }: { input: StartSlackCredentialGroupConfigurationInput }) => + resolveCredentialGroup(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroups(context.workspaceId) + return createSlackManagedUsersAttempt({ + workspaceId: context.workspaceId, + userId: principal.userId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: input.slackBotCredentialId, + clientId: input.clientId, + clientSecret: input.clientSecret, + }) + }, +}) + +interface SlackCredentialGroupConfigurationCallbackInput { + state: string + code?: string + providerError?: string +} + +type SlackCredentialGroupConfigurationCallbackContext = Awaited< + ReturnType +> & { + attempt: SlackManagedUsersAttempt +} + +export const completeSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.completeSlackConfiguration, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: SlackCredentialGroupConfigurationCallbackInput + }): Promise => { + const attempt = await loadSlackManagedUsersAttempt(input.state) + if (!attempt) { + throw new OrchestrationError('validation', 'Authorization state is invalid or expired') + } + if (attempt.userId !== principal.userId) { + throw new OrchestrationError( + 'forbidden', + 'Authorization must be completed by the user who started it' + ) + } + return { ...(await resolveWorkspace(attempt.workspaceId)), attempt } + }, + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroups(context.workspaceId) + const attempt = await consumeSlackManagedUsersAttempt(input.state) + if ( + !attempt || + attempt.workspaceId !== context.attempt.workspaceId || + attempt.userId !== context.attempt.userId || + attempt.credentialGroupId !== context.attempt.credentialGroupId || + attempt.slackBotCredentialId !== context.attempt.slackBotCredentialId || + attempt.clientId !== context.attempt.clientId || + attempt.createdAt !== context.attempt.createdAt + ) { + throw new OrchestrationError('validation', 'Authorization state is invalid or expired') + } + if (input.providerError) return { ok: false as const, reason: 'provider_error' as const } + if (!input.code) throw new OrchestrationError('validation', 'Authorization code is missing') + const result = await exchangeAndConfigureSlackManagedUsers({ attempt, code: input.code }) + return { ok: true as const, reason: 'authorized' as const, result } + }, + projectAudit: ({ result }) => + result.ok + ? { + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: result.result.credentialGroupId, + resourceName: result.result.credentialGroupName, + description: 'Configured Slack for a Credential Group', + metadata: { + slackBotCredentialId: result.result.slackBotCredentialId, + slackAppId: result.result.appId, + slackTeamId: result.result.teamId, + }, + } + : [], +}) diff --git a/apps/sim/lib/credential-groups/application/validation.ts b/apps/sim/lib/credential-groups/application/validation.ts new file mode 100644 index 00000000000..2b2c6eb24ab --- /dev/null +++ b/apps/sim/lib/credential-groups/application/validation.ts @@ -0,0 +1,130 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import type { + CreateCredentialGroupInput, + CredentialGroupOptionInput, + CredentialGroupOptionUpdateInput, + UpdateCredentialGroupInput, +} from '@/lib/credential-groups/types' + +function validateOption( + option: CredentialGroupOptionInput | CredentialGroupOptionUpdateInput, + index: number +): void { + if (!isCredentialGroupProvider(option.provider)) { + throw new OrchestrationError('validation', `Credential option ${index + 1} is unsupported`) + } + if (!option.label.trim() || option.label.trim().length > 100) { + throw new OrchestrationError( + 'validation', + `Credential option ${index + 1} requires a label of at most 100 characters` + ) + } + if (option.provider === 'slack' && !option.slackBotCredentialId.trim()) { + throw new OrchestrationError('validation', 'Select a custom Slack bot') + } +} + +function validateOptions( + options: Array +): void { + if (options.length > CREDENTIAL_GROUP_PROVIDER_IDS.length) { + throw new OrchestrationError('validation', 'Too many credential options') + } + const labels = new Set() + const providers = new Set() + const ids = new Set() + options.forEach((option, index) => { + validateOption(option, index) + const label = option.label.trim().toLocaleLowerCase() + if (labels.has(label)) { + throw new OrchestrationError('validation', 'Credential option labels must be unique') + } + if (providers.has(option.provider)) { + throw new OrchestrationError('validation', 'Each provider can only be added once') + } + if ('id' in option && option.id) { + if (ids.has(option.id)) { + throw new OrchestrationError('validation', 'Credential option IDs must be unique') + } + ids.add(option.id) + } + labels.add(label) + providers.add(option.provider) + }) +} + +function normalizeOption( + option: T +): T { + return { ...option, label: option.label.trim() } +} + +export function validateCreateCredentialGroupInput( + input: CreateCredentialGroupInput +): CreateCredentialGroupInput { + const name = input.name.trim() + if (!name || name.length > 100) { + throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters') + } + const description = input.description?.trim() + if (description && description.length > 500) { + throw new OrchestrationError('validation', 'Description must be at most 500 characters') + } + validateOptions(input.options) + if (input.options.some((option) => option.provider === 'slack')) { + throw new OrchestrationError( + 'validation', + 'Create the Credential Group before configuring Slack' + ) + } + return { + name, + ...(description ? { description } : {}), + options: input.options.map(normalizeOption), + } +} + +export function validateUpdateCredentialGroupInput( + input: UpdateCredentialGroupInput +): UpdateCredentialGroupInput { + if (Object.keys(input).length === 0) { + throw new OrchestrationError('validation', 'At least one field must be updated') + } + const name = input.name?.trim() + if (input.name !== undefined && (!name || name.length > 100)) { + throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters') + } + const description = input.description?.trim() + if (description && description.length > 500) { + throw new OrchestrationError('validation', 'Description must be at most 500 characters') + } + if (input.options) validateOptions(input.options) + return { + ...(name ? { name } : {}), + ...(input.description !== undefined ? { description: description || null } : {}), + ...(input.options ? { options: input.options.map(normalizeOption) } : {}), + ...(input.status ? { status: input.status } : {}), + } +} + +export function validateCredentialGroupEnrollmentPage(limit: number): void { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100') + } +} + +export function validateCredentialGroupInvitationEmails(emails: string[]): string[] { + if (emails.length < 1 || emails.length > 100) { + throw new OrchestrationError('validation', 'Invite between 1 and 100 people at once') + } + const normalized = [...new Set(emails.map(normalizeEmail))] + if (normalized.some((email) => !isValidEmailSyntax(email))) { + throw new OrchestrationError('validation', 'Every invitation email must be valid') + } + return normalized +} diff --git a/apps/sim/lib/credential-groups/availability.test.ts b/apps/sim/lib/credential-groups/availability.test.ts new file mode 100644 index 00000000000..51960c8184d --- /dev/null +++ b/apps/sim/lib/credential-groups/availability.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsFeatureEnabled } = vi.hoisted(() => ({ + mockIsFeatureEnabled: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isHosted: true, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +import { resolveCredentialGroupsAvailability } from '@/lib/credential-groups/availability' + +describe('resolveCredentialGroupsAvailability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('attributes a disabled feature flag before considering the plan', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + + await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({ + available: false, + reason: 'feature_disabled', + }) + }) + + it('requires Enterprise when the hosted feature is enabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(true) + + await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({ + available: false, + reason: 'enterprise_plan_required', + }) + }) + + it('allows Enterprise workspaces when the hosted feature is enabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(true) + + await expect(resolveCredentialGroupsAvailability({ isEnterprise: true })).resolves.toEqual({ + available: true, + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/availability.ts b/apps/sim/lib/credential-groups/availability.ts new file mode 100644 index 00000000000..cc56832f87c --- /dev/null +++ b/apps/sim/lib/credential-groups/availability.ts @@ -0,0 +1,25 @@ +import { isHosted } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + +export type CredentialGroupsAvailability = + | { available: true } + | { available: false; reason: 'feature_disabled' | 'enterprise_plan_required' } + +export async function resolveCredentialGroupsAvailability(ownerBilling: { + isEnterprise: boolean +}): Promise { + if (!(await isFeatureEnabled('credential-groups'))) { + return { available: false, reason: 'feature_disabled' } + } + if (isHosted && !ownerBilling.isEnterprise) { + return { available: false, reason: 'enterprise_plan_required' } + } + return { available: true } +} + +/** Credential Groups are globally gated and restricted to Enterprise workspaces on Sim Cloud. */ +export async function isCredentialGroupsAvailable(ownerBilling: { + isEnterprise: boolean +}): Promise { + return (await resolveCredentialGroupsAvailability(ownerBilling)).available +} diff --git a/apps/sim/lib/credential-groups/credentials.test.ts b/apps/sim/lib/credential-groups/credentials.test.ts new file mode 100644 index 00000000000..200a2671d76 --- /dev/null +++ b/apps/sim/lib/credential-groups/credentials.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listCredentialGroupCredentialReferences } from '@/lib/credential-groups/credentials' + +describe('listCredentialGroupCredentialReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns the invited email associated with each managed credential', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + email: 'person@example.com', + displayName: 'Personal Gmail', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + createdAt: new Date('2026-08-12T12:00:00.000Z'), + }, + ]) + + const result = await listCredentialGroupCredentialReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + credentialGroupOptionIds: ['option-1'], + limit: 50, + }) + + expect(result).toEqual({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'Personal Gmail', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + nextCursor: null, + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts new file mode 100644 index 00000000000..4450ef0ff85 --- /dev/null +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -0,0 +1,173 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, +} from '@sim/db/schema' +import { and, asc, eq, gt, inArray, or } from 'drizzle-orm' + +export const MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE = 100 + +export interface CredentialGroupCredentialListContext { + credentialGroupId: string + workspaceId: string + name: string + status: 'active' | 'disabled' + options: CredentialGroupOptionConfig[] +} + +export interface CredentialGroupCredentialReference { + credentialId: string + email: string + displayName: string + providerId: string + providerSubjectId: string + providerTenantId: string | null +} + +export class CredentialGroupCredentialCursorNotFoundError extends Error { + constructor() { + super('Credential group credential cursor not found') + this.name = 'CredentialGroupCredentialCursorNotFoundError' + } +} + +interface ListCredentialGroupCredentialReferencesInput { + workspaceId: string + credentialGroupId: string + limit: number + cursor?: string + email?: string + credentialProviderIds?: string[] + credentialGroupOptionIds: string[] +} + +/** Loads the canonical group ownership needed by the application authorization boundary. */ +export async function loadCredentialGroupCredentialListContext( + credentialGroupId: string +): Promise { + const [row] = await db + .select({ + credentialGroupId: credentialGroup.id, + workspaceId: credentialGroup.workspaceId, + name: credentialGroup.name, + status: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .where(eq(credentialGroup.id, credentialGroupId)) + .limit(1) + return row ?? null +} + +/** Lists one bounded page of active managed credentials without selecting token material. */ +export async function listCredentialGroupCredentialReferences({ + workspaceId, + credentialGroupId, + limit, + cursor, + email, + credentialProviderIds, + credentialGroupOptionIds, +}: ListCredentialGroupCredentialReferencesInput): Promise<{ + credentials: CredentialGroupCredentialReference[] + nextCursor: string | null +}> { + if (credentialGroupOptionIds.length === 0) { + if (cursor) throw new CredentialGroupCredentialCursorNotFoundError() + return { credentials: [], nextCursor: null } + } + + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credential.id, createdAt: credential.createdAt }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.id, cursor), + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_oauth'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), + inArray(credential.credentialGroupOptionId, credentialGroupOptionIds), + email ? eq(credentialGroupEnrollment.email, email) : undefined, + credentialProviderIds?.length + ? inArray(credential.providerId, credentialProviderIds) + : undefined, + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']) + ) + ) + .limit(1) + if (!cursorRow) throw new CredentialGroupCredentialCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credential.id, + email: credentialGroupEnrollment.email, + displayName: credential.displayName, + providerId: credential.providerId, + providerSubjectId: credential.providerSubjectId, + providerTenantId: credential.providerTenantId, + createdAt: credential.createdAt, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_oauth'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), + inArray(credential.credentialGroupOptionId, credentialGroupOptionIds), + email ? eq(credentialGroupEnrollment.email, email) : undefined, + credentialProviderIds?.length + ? inArray(credential.providerId, credentialProviderIds) + : undefined, + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + cursorPosition + ? or( + gt(credential.createdAt, cursorPosition.createdAt), + and( + eq(credential.createdAt, cursorPosition.createdAt), + gt(credential.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(asc(credential.createdAt), asc(credential.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('Credential page cursor could not be derived') + return { + credentials: pageRows.map((row) => { + if (!row.providerId) throw new Error(`Managed credential ${row.id} has no provider ID`) + if (!row.providerSubjectId) { + throw new Error(`Managed credential ${row.id} has no provider subject ID`) + } + return { + credentialId: row.id, + email: row.email, + displayName: row.displayName, + providerId: row.providerId, + providerSubjectId: row.providerSubjectId, + providerTenantId: row.providerTenantId, + } + }), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts new file mode 100644 index 00000000000..4a826736f2e --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -0,0 +1,326 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { adapter } = vi.hoisted(() => ({ + adapter: { + getPolicy: vi.fn(), + hasRequiredScopes: vi.fn(), + }, +})) + +vi.mock('@/components/emails/render', () => ({ + renderCredentialGroupInvitationEmail: vi.fn(), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: vi.fn() })) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + isCredentialGroupsAvailable: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => adapter, +})) + +import { + completeCredentialGroupEnrollment, + listCredentialGroupEnrollments, + resendCredentialGroupEnrollment, +} from '@/lib/credential-groups/enrollments' +import { CREDENTIAL_GROUP_PROVIDER_IDS } from '@/lib/credential-groups/providers' +import { sendEmail } from '@/lib/messaging/email/mailer' + +const MAX_CONNECTION_SUMMARIES = CREDENTIAL_GROUP_PROVIDER_IDS.length * 3 + +const ENROLLMENT = { + id: 'enrollment-1', + credentialGroupId: 'group-1', + email: 'alex@example.com', + status: 'completed' as const, + invitationTokenHash: 'a'.repeat(64), + invitationExpiresAt: new Date('2026-08-18T12:00:00.000Z'), + invitedAt: new Date('2026-08-11T12:00:00.000Z'), + sentAt: new Date('2026-08-11T12:00:01.000Z'), + completedAt: new Date('2026-08-11T12:05:00.000Z'), + revokedAt: null, + lastDeliveryError: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-11T12:00:00.000Z'), + updatedAt: new Date('2026-08-11T12:05:00.000Z'), +} + +describe('listCredentialGroupEnrollments', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns bounded provider summaries instead of materializing every credential', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ + { + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'active', + count: 2, + }, + { + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'needs_reauth', + count: 1, + }, + ]) + + const result = await listCredentialGroupEnrollments('workspace-1', 'group-1', 50) + + expect(result.enrollments[0]?.connections).toEqual([ + { provider: 'gmail', status: 'active', count: 2 }, + { provider: 'gmail', status: 'needs_reauth', count: 1 }, + ]) + expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(3, MAX_CONNECTION_SUMMARIES + 1) + }) + + it('fails fast when a managed credential uses an unsupported provider', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ + { + enrollmentId: ENROLLMENT.id, + providerId: 'unexpected-provider', + status: 'active', + count: 1, + }, + ]) + + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 50)).rejects.toThrow( + 'Unsupported managed credential provider: unexpected-provider' + ) + }) + + it('rejects connection summaries beyond the bounded provider-state cardinality', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce( + Array.from({ length: MAX_CONNECTION_SUMMARIES + 1 }, (_, index) => ({ + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'active', + count: index + 1, + })) + ) + + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 50)).rejects.toThrow( + 'Managed credential connection summaries exceed the supported provider states' + ) + }) + + it('rejects an unbounded enrollment page request', async () => { + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 101)).rejects.toThrow( + 'Credential group enrollment limit must be between 1 and 100' + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) + +describe('resendCredentialGroupEnrollment', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('does not reactivate an enrollment revoked while resend waits for its lifecycle lock', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [{ id: 'option-1', status: 'active' }], + }, + ]) + .mockResolvedValueOnce([{ enrollment: { ...ENROLLMENT, status: 'invited' } }]) + .mockResolvedValueOnce([{ ...ENROLLMENT, status: 'invited' }]) + .mockResolvedValueOnce([{ ...ENROLLMENT, status: 'revoked' }]) + + await expect( + resendCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id, 'user-1', 'Inviter') + ).rejects.toThrow('Revoked enrollment cannot be resent') + + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(sendEmail).not.toHaveBeenCalled() + }) + + it('rotates the invitation without hiding credentials from a completed enrollment', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [{ id: 'option-1', status: 'active' }], + }, + ]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ENROLLMENT]) + .mockResolvedValueOnce([ENROLLMENT]) + dbChainMockFns.returning + .mockResolvedValueOnce([ENROLLMENT]) + .mockResolvedValueOnce([{ ...ENROLLMENT, sentAt: new Date() }]) + vi.mocked(sendEmail).mockResolvedValueOnce({ success: true, message: 'sent' }) + + const result = await resendCredentialGroupEnrollment( + 'workspace-1', + 'group-1', + ENROLLMENT.id, + 'user-1', + 'Inviter' + ) + + expect(result.status).toBe('completed') + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ status: 'completed', completedAt: ENROLLMENT.completedAt }) + ) + }) +}) + +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 () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + enrollment: { ...ENROLLMENT, status: 'in_progress' }, + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Gmail', + required: true, + status: 'active', + }, + ], + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + 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', + invitationTokenHash: ENROLLMENT.invitationTokenHash, + invitationExpiresAt: ENROLLMENT.invitationExpiresAt, + }, + ]) + + await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBeNull() + + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses completion when a connection needs reauthorization under the row locks', async () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + enrollment: { ...ENROLLMENT, status: 'in_progress' }, + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Gmail', + required: true, + status: 'active', + }, + ], + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + inviterName: 'Inviter', + }, + ]) + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + status: 'in_progress', + invitationTokenHash: ENROLLMENT.invitationTokenHash, + invitationExpiresAt: ENROLLMENT.invitationExpiresAt, + }, + ]) + queueTableRows(schemaMock.credentialGroup, [ + { + status: 'active', + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Gmail', + required: true, + status: 'active', + }, + ], + }, + ]) + 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'), + }, + ]) + + await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(false) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(adapter.getPolicy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts new file mode 100644 index 00000000000..aa3a8b24d9f --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -0,0 +1,943 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, + user, + workspace, +} from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { normalizeEmail, truncate } from '@sim/utils/string' +import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm' +import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render' +import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + getCredentialGroupProviderFromProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import type { + CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentDetail, + CredentialGroupEnrollmentRecord, + InviteCredentialGroupEnrollmentsInput, +} from '@/lib/credential-groups/types' +import type { DbOrTx } from '@/lib/db/types' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' + +const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const DELIVERY_CONCURRENCY = 5 +const MAX_ENROLLMENT_PAGE_SIZE = 100 +const CONNECTION_SUMMARIES_PER_ENROLLMENT = CREDENTIAL_GROUP_PROVIDER_IDS.length * 3 + +type EnrollmentRow = typeof credentialGroupEnrollment.$inferSelect + +export type CredentialGroupEnrollmentStatus = EnrollmentRow['status'] + +export interface ListCredentialGroupEnrollmentFilters { + email?: string + statuses?: CredentialGroupEnrollmentStatus[] +} + +interface InvitationContext { + workspaceId: string + workspaceName: string + groupId: string + groupName: string +} + +interface SendInvitationOptions { + expectedEnrollmentId?: string + revokedEnrollment: 'reactivate' | 'reject' +} + +export interface PublicCredentialGroupEnrollment { + inviterName: string + workspaceName: string + credentialGroupName: string + options: Array< + Pick & { + provider: CredentialGroupProvider + connections: Array<{ + email: string + displayName: string | null + avatarUrl: string | null + status: 'connected' | 'needs_reauth' | 'revoked' + grantedAt: string + }> + } + > + status: CredentialGroupEnrollmentRecord['status'] +} + +export interface CredentialGroupOAuthContext { + enrollmentId: string + credentialGroupId: string + workspaceId: string + workspaceName: string + workspaceOwnerId: string + email: string + enrollmentStatus: EnrollmentRow['status'] + option: CredentialGroupOptionConfig + options: CredentialGroupOptionConfig[] +} + +export interface PublicCredentialGroupEnrollmentIdentity { + enrollmentId: string + credentialGroupId: string + workspaceId: string + email: string + invitationTokenHash: string +} + +/** Serializes OAuth grant persistence and administrative revocation for one enrollment. */ +export async function lockCredentialGroupEnrollmentLifecycle( + executor: DbOrTx, + enrollmentId: string +): Promise { + if (!enrollmentId.trim()) throw new Error('Credential group enrollment ID is required') + await executor.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-enrollment:${enrollmentId}`}, 0))` + ) +} + +/** Serializes invitation issuance before an enrollment row is known or locked. */ +async function lockCredentialGroupInvitationTarget( + executor: DbOrTx, + groupId: string, + email: string +): Promise { + if (!groupId.trim()) throw new Error('Credential group ID is required') + if (!email.trim()) throw new Error('Credential group enrollment email is required') + await executor.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-invitation:${groupId}:${email}`}, 0))` + ) +} + +export class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: 404 | 409 | 502 + ) { + super(message) + this.name = 'CredentialGroupEnrollmentError' + } +} + +function hashInvitationToken(token: string): string { + return sha256Hex(token) +} + +function metadataString(metadata: object | null, key: string): string | null { + const value = metadata ? (metadata as Record)[key] : undefined + return typeof value === 'string' && value.length > 0 ? value : null +} + +async function resolvePublicEnrollmentRowByIdentity( + identity: Pick & { + enrollmentId?: string + } +) { + const [row] = await db + .select({ + enrollment: credentialGroupEnrollment, + groupId: credentialGroup.id, + groupName: credentialGroup.name, + groupStatus: credentialGroup.status, + options: credentialGroup.options, + workspaceId: workspace.id, + workspaceName: workspace.name, + workspaceOwnerId: workspace.ownerId, + inviterName: user.name, + }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(workspace, eq(workspace.id, credentialGroup.workspaceId)) + .leftJoin(user, eq(user.id, credentialGroupEnrollment.createdBy)) + .where( + and( + eq(credentialGroupEnrollment.invitationTokenHash, identity.invitationTokenHash), + identity.enrollmentId ? eq(credentialGroupEnrollment.id, identity.enrollmentId) : undefined + ) + ) + .limit(1) + + if (!row || row.groupStatus !== 'active') return null + if (row.enrollment.status === 'revoked' || row.enrollment.status === 'delivery_failed') + return null + if (row.enrollment.invitationExpiresAt.getTime() <= Date.now()) return null + + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(row.workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) return null + return row +} + +function identityForPublicEnrollmentRow( + row: NonNullable>> +): PublicCredentialGroupEnrollmentIdentity { + return { + enrollmentId: row.enrollment.id, + credentialGroupId: row.groupId, + workspaceId: row.workspaceId, + email: row.enrollment.email, + invitationTokenHash: row.enrollment.invitationTokenHash, + } +} + +/** Authenticates a public invitation token without exposing the bearer value downstream. */ +export async function authenticatePublicCredentialGroupEnrollment( + token: string +): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + return row ? identityForPublicEnrollmentRow(row) : null +} + +async function resolveAuthorizedPublicEnrollmentRow( + identity: PublicCredentialGroupEnrollmentIdentity +) { + const row = await resolvePublicEnrollmentRowByIdentity(identity) + if ( + !row || + row.groupId !== identity.credentialGroupId || + row.workspaceId !== identity.workspaceId || + row.enrollment.email !== identity.email + ) { + return null + } + return row +} + +function toCredentialGroupEnrollment(row: EnrollmentRow): CredentialGroupEnrollmentRecord { + return { + id: row.id, + credentialGroupId: row.credentialGroupId, + email: row.email, + status: row.status, + expiresAt: row.invitationExpiresAt.toISOString(), + invitedAt: row.invitedAt.toISOString(), + sentAt: row.sentAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + revokedAt: row.revokedAt?.toISOString() ?? null, + expired: row.invitationExpiresAt.getTime() <= Date.now(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +function toCredentialGroupConnectionProvider( + providerId: string | null +): CredentialGroupEnrollmentConnection['provider'] { + if (!providerId) throw new Error('Managed credential provider is missing') + return getCredentialGroupProviderFromProviderId(providerId) +} + +function toCredentialGroupConnectionStatus( + status: (typeof credential.$inferSelect)['managedOauthStatus'] +): CredentialGroupEnrollmentConnection['status'] { + if (status === 'active' || status === 'needs_reauth' || status === 'revoked') return status + throw new Error('Managed credential status is missing') +} + +async function getInvitationContext( + workspaceId: string, + groupId: string +): Promise { + const [row] = await db + .select({ + workspaceId: credentialGroup.workspaceId, + workspaceName: workspace.name, + groupId: credentialGroup.id, + groupName: credentialGroup.name, + groupStatus: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .innerJoin(workspace, eq(workspace.id, credentialGroup.workspaceId)) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + + if (!row) throw new CredentialGroupEnrollmentError('Credential group not found', 404) + if (row.groupStatus !== 'active') { + throw new CredentialGroupEnrollmentError('Credential group is disabled', 409) + } + if (!row.options.some((option) => option.status === 'active')) { + throw new CredentialGroupEnrollmentError('Add an account type before inviting people', 409) + } + return row +} + +async function sendInvitation( + context: InvitationContext, + userId: string, + inviterName: string, + email: string, + options: SendInvitationOptions +): Promise { + const now = new Date() + const token = generateId() + const tokenHash = hashInvitationToken(token) + const expiresAt = new Date(now.getTime() + INVITATION_TTL_MS) + + const issued = await db.transaction(async (tx) => { + await lockCredentialGroupInvitationTarget(tx, context.groupId, email) + const [existing] = await tx + .select() + .from(credentialGroupEnrollment) + .where( + and( + eq(credentialGroupEnrollment.credentialGroupId, context.groupId), + eq(credentialGroupEnrollment.email, email) + ) + ) + .limit(1) + + let current = existing + if (existing) { + await lockCredentialGroupEnrollmentLifecycle(tx, existing.id) + const [locked] = await tx + .select() + .from(credentialGroupEnrollment) + .where( + and( + eq(credentialGroupEnrollment.id, existing.id), + eq(credentialGroupEnrollment.credentialGroupId, context.groupId), + eq(credentialGroupEnrollment.email, email) + ) + ) + .limit(1) + current = locked + } + + if (options.expectedEnrollmentId && current?.id !== options.expectedEnrollmentId) { + throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + } + if (current?.status === 'revoked' && options.revokedEnrollment === 'reject') { + throw new CredentialGroupEnrollmentError('Revoked enrollment cannot be resent', 409) + } + + const preservesProgress = current?.status === 'in_progress' || current?.status === 'completed' + const nextStatus = preservesProgress ? current.status : ('invited' as const) + const mutableValues = { + status: nextStatus, + invitationTokenHash: tokenHash, + invitationExpiresAt: expiresAt, + invitedAt: now, + sentAt: null, + completedAt: preservesProgress ? current.completedAt : null, + revokedAt: null, + lastDeliveryError: null, + createdBy: userId, + updatedAt: now, + } + const [next] = current + ? await tx + .update(credentialGroupEnrollment) + .set(mutableValues) + .where(eq(credentialGroupEnrollment.id, current.id)) + .returning() + : await tx + .insert(credentialGroupEnrollment) + .values({ + id: generateId(), + credentialGroupId: context.groupId, + email, + ...mutableValues, + createdAt: now, + }) + .returning() + if (!next) throw new Error('Credential group enrollment write returned no row') + return next + }) + + const invitationLink = `${getBaseUrl()}/credential-groups/enroll/${token}` + const html = await renderCredentialGroupInvitationEmail({ + recipientEmail: email, + inviterName, + workspaceName: context.workspaceName, + credentialGroupName: context.groupName, + invitationLink, + }) + const result = await sendEmail({ + to: email, + subject: getCredentialGroupInvitationSubject(inviterName, context.workspaceName), + html, + from: getFromEmailAddress(), + emailType: 'transactional', + }) + + if (!result.success) { + const [failed] = await db + .update(credentialGroupEnrollment) + .set({ + status: issued.status === 'invited' ? 'delivery_failed' : issued.status, + lastDeliveryError: truncate(result.message, 500), + updatedAt: new Date(), + }) + .where( + and( + eq(credentialGroupEnrollment.id, issued.id), + eq(credentialGroupEnrollment.invitationTokenHash, tokenHash), + eq(credentialGroupEnrollment.status, issued.status) + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!failed) { + throw new CredentialGroupEnrollmentError( + 'Invitation was superseded by another enrollment action', + 409 + ) + } + throw new CredentialGroupEnrollmentError(result.message, 502) + } + + const [sent] = await db + .update(credentialGroupEnrollment) + .set({ sentAt: new Date(), lastDeliveryError: null, updatedAt: new Date() }) + .where( + and( + eq(credentialGroupEnrollment.id, issued.id), + eq(credentialGroupEnrollment.invitationTokenHash, tokenHash), + eq(credentialGroupEnrollment.status, issued.status) + ) + ) + .returning() + if (!sent) { + throw new CredentialGroupEnrollmentError( + 'Invitation was superseded by another delivery request', + 409 + ) + } + return toCredentialGroupEnrollment(sent) +} + +export async function listCredentialGroupEnrollments( + workspaceId: string, + groupId: string, + limit: number, + cursor?: string, + filters: ListCredentialGroupEnrollmentFilters = {} +): Promise<{ enrollments: CredentialGroupEnrollmentDetail[]; nextCursor: string | null }> { + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ENROLLMENT_PAGE_SIZE) { + throw new Error( + `Credential group enrollment limit must be between 1 and ${MAX_ENROLLMENT_PAGE_SIZE}` + ) + } + const [group] = await db + .select({ options: credentialGroup.options }) + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + if (!group) throw new CredentialGroupEnrollmentError('Credential group not found', 404) + const activeOptionIds = group.options + .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 rows = await db + .select({ enrollment: credentialGroupEnrollment }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + 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, + cursorPosition + ? or( + lt(credentialGroupEnrollment.invitedAt, cursorPosition.invitedAt), + and( + eq(credentialGroupEnrollment.invitedAt, cursorPosition.invitedAt), + lt(credentialGroupEnrollment.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(desc(credentialGroupEnrollment.invitedAt), desc(credentialGroupEnrollment.id)) + .limit(limit + 1) + const hasNextPage = rows.length > limit + const pageRows = hasNextPage ? rows.slice(0, limit) : rows + const enrollmentIds = pageRows.map(({ enrollment }) => enrollment.id) + const connectionSummaryLimit = enrollmentIds.length * CONNECTION_SUMMARIES_PER_ENROLLMENT + const connectionRows = + enrollmentIds.length === 0 || activeOptionIds.length === 0 + ? [] + : await db + .select({ + enrollmentId: credential.credentialGroupEnrollmentId, + providerId: credential.providerId, + status: credential.managedOauthStatus, + count: count(credential.id), + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.credentialGroupOptionId, activeOptionIds) + ) + ) + .groupBy( + credential.credentialGroupEnrollmentId, + credential.providerId, + credential.managedOauthStatus + ) + .limit(connectionSummaryLimit + 1) + if (connectionRows.length > connectionSummaryLimit) { + throw new Error('Managed credential connection summaries exceed the supported provider states') + } + const connectionsByEnrollment = new Map() + for (const connection of connectionRows) { + if (!connection.enrollmentId) { + throw new Error('Managed credential enrollment ID is missing') + } + const summary: CredentialGroupEnrollmentConnection = { + provider: toCredentialGroupConnectionProvider(connection.providerId), + status: toCredentialGroupConnectionStatus(connection.status), + count: connection.count, + } + const current = connectionsByEnrollment.get(connection.enrollmentId) + if (current) current.push(summary) + else connectionsByEnrollment.set(connection.enrollmentId, [summary]) + } + return { + enrollments: pageRows.map(({ enrollment }) => ({ + ...toCredentialGroupEnrollment(enrollment), + connections: connectionsByEnrollment.get(enrollment.id) ?? [], + })), + nextCursor: hasNextPage ? (pageRows.at(-1)?.enrollment.id ?? null) : null, + } +} + +export async function inviteCredentialGroupEnrollments( + workspaceId: string, + groupId: string, + userId: string, + inviterName: string, + body: InviteCredentialGroupEnrollmentsInput +) { + const context = await getInvitationContext(workspaceId, groupId) + const emails = [...new Set(body.emails.map(normalizeEmail))] + const results: Array< + | { email: string; success: true; enrollment: CredentialGroupEnrollmentRecord } + | { email: string; success: false; error: string } + > = [] + + for (let index = 0; index < emails.length; index += DELIVERY_CONCURRENCY) { + const chunk = emails.slice(index, index + DELIVERY_CONCURRENCY) + const chunkResults = await Promise.all( + chunk.map(async (email) => { + try { + const enrollment = await sendInvitation(context, userId, inviterName, email, { + revokedEnrollment: 'reactivate', + }) + return { email, success: true as const, enrollment } + } catch (error) { + return { + email, + success: false as const, + error: getErrorMessage(error, 'Failed to send invitation'), + } + } + }) + ) + results.push(...chunkResults) + } + + const sentCount = results.filter((result) => result.success).length + return { results, sentCount, failedCount: results.length - sentCount } +} + +export async function loadCredentialGroupInviterIdentity( + userId: string +): Promise<{ name: string | null; email: string } | null> { + const [row] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + return row ?? null +} + +export async function inviteCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + userId: string, + inviterName: string, + email: string +): Promise { + const context = await getInvitationContext(workspaceId, groupId) + return sendInvitation(context, userId, inviterName, normalizeEmail(email), { + revokedEnrollment: 'reactivate', + }) +} + +export async function resendCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + enrollmentId: string, + userId: string, + inviterName: string +): Promise { + const context = await getInvitationContext(workspaceId, groupId) + const [row] = await db + .select({ enrollment: credentialGroupEnrollment }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId) + ) + ) + .limit(1) + if (!row) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + return sendInvitation(context, userId, inviterName, row.enrollment.email, { + expectedEnrollmentId: enrollmentId, + revokedEnrollment: 'reject', + }) +} + +export async function revokeCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + enrollmentId: string +): Promise { + const [existing] = await db + .select({ email: credentialGroupEnrollment.email }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId) + ) + ) + .limit(1) + if (!existing) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + + 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 }) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroupEnrollment.credentialGroupId, groupId) + ) + ) + .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) + }) +} + +export async function getPublicCredentialGroupEnrollment( + token: string +): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + return row ? buildPublicCredentialGroupEnrollment(row) : null +} + +export async function getAuthorizedPublicCredentialGroupEnrollment( + identity: PublicCredentialGroupEnrollmentIdentity +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + + return buildPublicCredentialGroupEnrollment(row) +} + +async function buildPublicCredentialGroupEnrollment( + row: NonNullable>> +): Promise { + const connectionRows = await db + .select({ + optionId: credential.credentialGroupOptionId, + status: credential.managedOauthStatus, + scopeVersion: credential.managedOauthScopeVersion, + authorizationAppId: credential.authorizationAppId, + grantedScopes: credential.grantedScopes, + displayName: credential.displayName, + metadata: credential.providerMetadata, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ) + + return { + inviterName: row.inviterName ?? 'A workspace admin', + workspaceName: row.workspaceName, + credentialGroupName: row.groupName, + options: await Promise.all( + row.options.map(async (option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${option.provider}`) + } + const adapter = getCredentialGroupProviderAdapter(option.provider) + const policy = await adapter.getPolicy(option, { + workspaceId: row.workspaceId, + credentialGroupId: row.groupId, + }) + return { + id: option.id, + provider: option.provider, + label: option.label, + required: option.required, + status: option.status, + connections: connectionRows + .filter((connection) => connection.optionId === option.id && connection.grantedAt) + .map((connection) => { + const email = metadataString(connection.metadata, 'email') ?? connection.displayName + const status = + connection.status === 'revoked' + ? ('revoked' as const) + : connection.status !== 'active' || + connection.authorizationAppId !== policy.authorizationAppId || + connection.scopeVersion !== policy.scopeVersion || + !adapter.hasRequiredScopes( + connection.grantedScopes ?? [], + policy.requiredScopes + ) + ? ('needs_reauth' as const) + : ('connected' as const) + return { + email, + displayName: + metadataString(connection.metadata, 'displayName') ?? + metadataString(connection.metadata, 'name'), + avatarUrl: + metadataString(connection.metadata, 'avatarUrl') ?? + metadataString(connection.metadata, 'picture'), + status, + grantedAt: connection.grantedAt!.toISOString(), + } + }), + } + }) + ), + status: row.enrollment.status, + } +} + +/** Finalizes an enrollment only after every active credential option has one usable connection. */ +export async function completeCredentialGroupEnrollment(token: string): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + if (!row) return null + return completeResolvedCredentialGroupEnrollment(row, identityForPublicEnrollmentRow(row)) +} + +export async function completeAuthorizedCredentialGroupEnrollment( + identity: PublicCredentialGroupEnrollmentIdentity +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + return completeResolvedCredentialGroupEnrollment(row, identity) +} + +async function completeResolvedCredentialGroupEnrollment( + row: NonNullable>>, + identity: PublicCredentialGroupEnrollmentIdentity +): Promise { + return db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id) + const now = new Date() + const [current] = await tx + .select({ + status: credentialGroupEnrollment.status, + invitationTokenHash: credentialGroupEnrollment.invitationTokenHash, + invitationExpiresAt: credentialGroupEnrollment.invitationExpiresAt, + }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.id, row.enrollment.id)) + .limit(1) + if ( + !current || + current.status === 'revoked' || + current.status === 'delivery_failed' || + current.invitationTokenHash !== identity.invitationTokenHash || + current.invitationExpiresAt.getTime() <= now.getTime() + ) { + return null + } + + const [group] = await tx + .select({ + status: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, identity.credentialGroupId), + eq(credentialGroup.workspaceId, identity.workspaceId) + ) + ) + .limit(1) + .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 }) + .where( + and( + eq(credentialGroupEnrollment.id, row.enrollment.id), + inArray(credentialGroupEnrollment.status, ['invited', 'in_progress', 'completed']) + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!completed) throw new Error('Credential group enrollment completion returned no row') + return true + }) +} + +/** Resolves the private, server-only context bound to a public enrollment link and option. */ +export async function getCredentialGroupOAuthContext( + token: string, + optionId: string +): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + if (!row) return null + const option = row.options.find((candidate) => candidate.id === optionId) + if (!option || option.status !== 'active') return null + return credentialGroupOAuthContextFromRow(row, option) +} + +export async function getAuthorizedCredentialGroupOAuthContext( + identity: PublicCredentialGroupEnrollmentIdentity, + optionId: string +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + const option = row.options.find((candidate) => candidate.id === optionId) + if (!option || option.status !== 'active') return null + return credentialGroupOAuthContextFromRow(row, option) +} + +function credentialGroupOAuthContextFromRow( + row: NonNullable>>, + option: CredentialGroupOptionConfig +): CredentialGroupOAuthContext { + return { + enrollmentId: row.enrollment.id, + credentialGroupId: row.groupId, + workspaceId: row.workspaceId, + workspaceName: row.workspaceName, + workspaceOwnerId: row.workspaceOwnerId, + email: row.enrollment.email, + enrollmentStatus: row.enrollment.status, + option, + options: row.options, + } +} diff --git a/apps/sim/lib/credential-groups/groups.ts b/apps/sim/lib/credential-groups/groups.ts new file mode 100644 index 00000000000..abe6ed60b62 --- /dev/null +++ b/apps/sim/lib/credential-groups/groups.ts @@ -0,0 +1,110 @@ +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { and, desc, eq, lt, or } from 'drizzle-orm' +import { + getCredentialGroupProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +export const MAX_CREDENTIAL_GROUP_PAGE_SIZE = 100 + +export interface CredentialGroupSummary { + id: string + name: string + description: string | null + status: 'active' | 'disabled' + providerIds: string[] + createdAt: string + updatedAt: string +} + +export class CredentialGroupCursorNotFoundError extends Error { + constructor() { + super('Credential group cursor not found') + this.name = 'CredentialGroupCursorNotFoundError' + } +} + +interface ListCredentialGroupSummariesInput { + workspaceId: string + limit: number + cursor?: string +} + +/** Lists a bounded page of group metadata without decrypting provider configuration. */ +export async function listCredentialGroupSummaries({ + workspaceId, + limit, + cursor, +}: ListCredentialGroupSummariesInput): Promise<{ + credentialGroups: CredentialGroupSummary[] + nextCursor: string | null +}> { + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credentialGroup.id, createdAt: credentialGroup.createdAt }) + .from(credentialGroup) + .where(and(eq(credentialGroup.id, cursor), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + if (!cursorRow) throw new CredentialGroupCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credentialGroup.id, + name: credentialGroup.name, + description: credentialGroup.description, + status: credentialGroup.status, + options: credentialGroup.options, + createdAt: credentialGroup.createdAt, + updatedAt: credentialGroup.updatedAt, + }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, workspaceId), + cursorPosition + ? or( + lt(credentialGroup.createdAt, cursorPosition.createdAt), + and( + eq(credentialGroup.createdAt, cursorPosition.createdAt), + lt(credentialGroup.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(desc(credentialGroup.createdAt), desc(credentialGroup.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('Credential group page cursor could not be derived') + + return { + credentialGroups: pageRows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + status: row.status, + providerIds: [ + ...new Set( + row.options + .filter((option) => option.status === 'active') + .map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Credential Group provider is not registered: ${option.provider}`) + } + return getCredentialGroupProviderId(option.provider) + }) + ), + ], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/oauth-state.test.ts b/apps/sim/lib/credential-groups/oauth-state.test.ts new file mode 100644 index 00000000000..a172785076e --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-state.test.ts @@ -0,0 +1,129 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRedis, values } = vi.hoisted(() => { + const values = new Map() + return { + values, + mockRedis: { + set: vi.fn(async (key: string, value: string) => { + if (values.has(key)) return null + values.set(key, value) + return 'OK' + }), + eval: vi.fn(async (_script: string, _keyCount: number, key: string) => { + const value = values.get(key) ?? null + values.delete(key) + return value + }), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: vi.fn(() => mockRedis), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) + +import { getRedisClient } from '@/lib/core/config/redis' +import { + consumeCredentialGroupOAuthAttempt, + createCredentialGroupOAuthAttempt, + credentialGroupOAuthNonceMatches, +} from '@/lib/credential-groups/oauth-state' + +describe('credential group OAuth state', () => { + beforeEach(() => { + vi.clearAllMocks() + values.clear() + vi.mocked(getRedisClient).mockReturnValue(mockRedis as never) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('stores encrypted attempt material and consumes state once', async () => { + const created = await createCredentialGroupOAuthAttempt({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:app', + scopeVersion: 1, + requiredScopes: ['openid', 'email'], + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + + const stored = [...values.values()][0] + expect(stored).not.toContain('code-verifier') + expect(stored).not.toContain('invitation-token') + + const consumed = await consumeCredentialGroupOAuthAttempt(created.state) + expect(consumed).toMatchObject({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + expect(credentialGroupOAuthNonceMatches(created.nonce, consumed?.nonceHash ?? '')).toBe(true) + await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() + }) + + it('fails closed when Redis is unavailable', async () => { + vi.mocked(getRedisClient).mockReturnValue(null) + + await expect( + createCredentialGroupOAuthAttempt({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:app', + scopeVersion: 1, + requiredScopes: ['openid'], + redirectUri: 'https://sim.ai/callback', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + ).rejects.toThrow('Credential group OAuth requires Redis') + }) + + it('supports providers without PKCE while preserving one-time state', async () => { + const created = await createCredentialGroupOAuthAttempt({ + provider: 'slack', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'slack:A123:T123', + scopeVersion: 1, + requiredScopes: ['users:read'], + redirectUri: 'https://sim.ai/api/credential-groups/oauth/slack/callback', + invitationToken: 'invitation-token', + }) + + const consumed = await consumeCredentialGroupOAuthAttempt(created.state) + + expect(consumed).toMatchObject({ + provider: 'slack', + authorizationAppId: 'slack:A123:T123', + invitationToken: 'invitation-token', + }) + expect(consumed?.codeVerifier).toBeUndefined() + await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts new file mode 100644 index 00000000000..a9b00a9849a --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -0,0 +1,180 @@ +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + type CredentialGroupProvider, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +const OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const OAUTH_ATTEMPT_VERSION = 2 as const + +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +interface StoredCredentialGroupOAuthAttempt { + version: typeof OAUTH_ATTEMPT_VERSION + provider: CredentialGroupProvider + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + nonceHash: string + encryptedCodeVerifier?: string + encryptedInvitationToken: string + createdAt: number +} + +export interface CredentialGroupOAuthAttempt { + state: string + provider: CredentialGroupProvider + nonceHash: string + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + codeVerifier?: string + invitationToken: string + createdAt: number +} + +interface CreateCredentialGroupOAuthAttemptParams { + provider: CredentialGroupProvider + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + codeVerifier?: string + invitationToken: string +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) { + throw new Error('Credential group OAuth requires Redis') + } + return redis +} + +function attemptKey(state: string): string { + return `credential-group:oauth-attempt:${sha256Hex(state)}` +} + +function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === OAUTH_ATTEMPT_VERSION && + typeof candidate.provider === 'string' && + isCredentialGroupProvider(candidate.provider) && + typeof candidate.enrollmentId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.optionId === 'string' && + typeof candidate.authorizationAppId === 'string' && + typeof candidate.scopeVersion === 'number' && + Number.isInteger(candidate.scopeVersion) && + candidate.scopeVersion > 0 && + Array.isArray(candidate.requiredScopes) && + candidate.requiredScopes.length > 0 && + candidate.requiredScopes.every((scope) => typeof scope === 'string' && scope.length > 0) && + typeof candidate.redirectUri === 'string' && + typeof candidate.nonceHash === 'string' && + (candidate.encryptedCodeVerifier === undefined || + typeof candidate.encryptedCodeVerifier === 'string') && + typeof candidate.encryptedInvitationToken === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +/** Creates a short-lived, one-time OAuth attempt. Only state and nonce leave the server. */ +export async function createCredentialGroupOAuthAttempt( + params: CreateCredentialGroupOAuthAttemptParams +): Promise<{ state: string; nonce: string }> { + const redis = requireRedis() + const state = generateId() + const nonce = generateId() + const [encryptedCodeVerifier, encryptedInvitationToken] = await Promise.all([ + params.codeVerifier ? encryptSecret(params.codeVerifier) : undefined, + encryptSecret(params.invitationToken), + ]) + const attempt: StoredCredentialGroupOAuthAttempt = { + version: OAUTH_ATTEMPT_VERSION, + provider: params.provider, + enrollmentId: params.enrollmentId, + credentialGroupId: params.credentialGroupId, + optionId: params.optionId, + authorizationAppId: params.authorizationAppId, + scopeVersion: params.scopeVersion, + requiredScopes: params.requiredScopes, + redirectUri: params.redirectUri, + nonceHash: sha256Hex(nonce), + ...(encryptedCodeVerifier ? { encryptedCodeVerifier: encryptedCodeVerifier.encrypted } : {}), + encryptedInvitationToken: encryptedInvitationToken.encrypted, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(state), + JSON.stringify(attempt), + 'PX', + OAUTH_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Credential group OAuth state collision') + return { state, nonce } +} + +/** Atomically burns state before the single-use authorization code is exchanged. */ +export async function consumeCredentialGroupOAuthAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.eval(CONSUME_SCRIPT, 1, attemptKey(state)) + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Credential group OAuth state is malformed') + + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Credential group OAuth state is malformed') + if (Date.now() - parsed.createdAt > OAUTH_ATTEMPT_TTL_MS) return null + + const [codeVerifier, invitationToken] = await Promise.all([ + parsed.encryptedCodeVerifier ? decryptSecret(parsed.encryptedCodeVerifier) : undefined, + decryptSecret(parsed.encryptedInvitationToken), + ]) + return { + state, + provider: parsed.provider, + nonceHash: parsed.nonceHash, + enrollmentId: parsed.enrollmentId, + credentialGroupId: parsed.credentialGroupId, + optionId: parsed.optionId, + authorizationAppId: parsed.authorizationAppId, + scopeVersion: parsed.scopeVersion, + requiredScopes: parsed.requiredScopes, + redirectUri: parsed.redirectUri, + ...(codeVerifier ? { codeVerifier: codeVerifier.decrypted } : {}), + invitationToken: invitationToken.decrypted, + createdAt: parsed.createdAt, + } +} + +/** Compares a verified ID-token nonce with the hash retained in the OAuth attempt. */ +export function credentialGroupOAuthNonceMatches(nonce: string, storedNonceHash: string): boolean { + return safeCompare(sha256Hex(nonce), storedNonceHash) +} diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts new file mode 100644 index 00000000000..d63b2d7c423 --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -0,0 +1,220 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { adapter } = vi.hoisted(() => ({ + adapter: { + provider: 'gmail' as const, + requiresRefreshToken: true, + getPolicy: vi.fn(), + prepareAuthorization: vi.fn(), + exchangeAndVerify: vi.fn(), + hasRequiredScopes: vi.fn(), + refreshToken: vi.fn(), + isTerminalRefreshError: vi.fn(), + }, +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => adapter, +})) + +vi.mock('@/lib/credentials/managed-oauth', () => ({ + decryptManagedOAuthTokenSet: vi.fn(), + encryptManagedOAuthTokenSet: vi.fn().mockResolvedValue('encrypted-token-set'), +})) + +import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth' + +const POLICY = { + provider: 'gmail' as const, + providerId: 'google-email', + authorizationAppId: 'google:client', + requiredScopes: ['openid', 'https://www.googleapis.com/auth/gmail.modify'], + scopeVersion: 1, +} + +const CONTEXT = { + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + email: 'person@example.com', + enrollmentStatus: 'in_progress' as const, + option: { + id: 'option-1', + provider: 'gmail' as const, + label: 'Gmail', + required: true, + status: 'active' as const, + }, + options: [], +} + +const GROUP = { + status: 'active' as const, + options: [ + { + ...CONTEXT.option, + authorizationAppId: POLICY.authorizationAppId, + requiredScopes: POLICY.requiredScopes, + scopeVersion: POLICY.scopeVersion, + }, + ], +} + +describe('credential group OAuth persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + adapter.getPolicy.mockResolvedValue(POLICY) + adapter.exchangeAndVerify.mockResolvedValue({ + providerId: POLICY.providerId, + providerSubjectId: 'google-subject-1', + providerTenantId: null, + displayName: 'person@example.com', + metadata: { email: 'person@example.com' }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + grantedScopes: POLICY.requiredScopes, + accessTokenExpiresAt: new Date('2026-08-14T00:00:00Z'), + refreshTokenExpiresAt: null, + }) + }) + + it('does not reactivate a credential after its enrollment is revoked', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'revoked' }]) + + await expect( + completeCredentialGroupOAuth( + CONTEXT, + { + state: 'state-1', + provider: 'gmail', + nonceHash: 'nonce-hash', + enrollmentId: CONTEXT.enrollmentId, + credentialGroupId: CONTEXT.credentialGroupId, + optionId: CONTEXT.option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'verifier', + invitationToken: 'invitation-token', + createdAt: Date.now(), + }, + 'authorization-code' + ) + ).rejects.toThrow('This account invitation was revoked.') + + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('preserves completed enrollment state when an account reconnects', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'completed' }]) + queueTableRows(schemaMock.credentialGroup, [GROUP]) + queueTableRows(schemaMock.credential, [ + { + id: 'credential-1', + providerSubjectId: 'google-subject-1', + encryptedOauthTokenSet: null, + refreshTokenExpiresAt: null, + }, + ]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'credential-1' }]) + .mockResolvedValueOnce([{ id: CONTEXT.enrollmentId }]) + + await completeCredentialGroupOAuth( + { ...CONTEXT, enrollmentStatus: 'completed' }, + { + state: 'state-1', + provider: 'gmail', + nonceHash: 'nonce-hash', + enrollmentId: CONTEXT.enrollmentId, + credentialGroupId: CONTEXT.credentialGroupId, + optionId: CONTEXT.option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'verifier', + invitationToken: 'invitation-token', + createdAt: Date.now(), + }, + 'authorization-code' + ) + + const enrollmentUpdate = dbChainMockFns.set.mock.calls[1]?.[0] + expect(enrollmentUpdate).toEqual( + expect.objectContaining({ status: 'completed', updatedAt: expect.any(Date) }) + ) + expect(enrollmentUpdate).not.toHaveProperty('completedAt') + }) + + it('rejects an exchanged grant when the group policy changed before persistence', async () => { + const nextPolicy = { + ...POLICY, + requiredScopes: [...POLICY.requiredScopes, 'https://www.googleapis.com/auth/gmail.readonly'], + scopeVersion: 2, + } + adapter.getPolicy.mockResolvedValueOnce(POLICY).mockResolvedValueOnce(nextPolicy) + dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'completed' }]) + queueTableRows(schemaMock.credentialGroup, [ + { + ...GROUP, + options: [ + { + ...GROUP.options[0], + requiredScopes: nextPolicy.requiredScopes, + scopeVersion: nextPolicy.scopeVersion, + }, + ], + }, + ]) + + await expect( + completeCredentialGroupOAuth( + { ...CONTEXT, enrollmentStatus: 'completed' }, + { + state: 'state-1', + provider: 'gmail', + nonceHash: 'nonce-hash', + enrollmentId: CONTEXT.enrollmentId, + credentialGroupId: CONTEXT.credentialGroupId, + optionId: CONTEXT.option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'verifier', + invitationToken: 'invitation-token', + createdAt: Date.now(), + }, + 'authorization-code' + ) + ).rejects.toThrow('This credential option changed.') + + expect(adapter.getPolicy).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'option-1' }), + { + workspaceId: CONTEXT.workspaceId, + credentialGroupId: CONTEXT.credentialGroupId, + executor: dbChainMock.db, + } + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts new file mode 100644 index 00000000000..edac52336ae --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -0,0 +1,289 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, ne, sql } from 'drizzle-orm' +import { + type CredentialGroupOAuthContext, + lockCredentialGroupEnrollmentLifecycle, +} from '@/lib/credential-groups/enrollments' +import { + type CredentialGroupOAuthAttempt, + createCredentialGroupOAuthAttempt, +} from '@/lib/credential-groups/oauth-state' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, + VerifiedCredentialGroupGrant, +} from '@/lib/credential-groups/provider-adapter' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { + getCredentialGroupProviderService, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import { + decryptManagedOAuthTokenSet, + encryptManagedOAuthTokenSet, +} from '@/lib/credentials/managed-oauth' + +function scopesEqual(left: string[], right: string[]): boolean { + const normalizedLeft = [...new Set(left)].sort() + const normalizedRight = [...new Set(right)].sort() + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((scope, index) => scope === normalizedRight[index]) + ) +} + +function policiesEqual( + left: CredentialGroupProviderPolicy, + right: CredentialGroupProviderPolicy +): boolean { + return ( + left.provider === right.provider && + left.providerId === right.providerId && + left.authorizationAppId === right.authorizationAppId && + left.scopeVersion === right.scopeVersion && + scopesEqual(left.requiredScopes, right.requiredScopes) + ) +} + +function getOptionAdapter(context: CredentialGroupOAuthContext): CredentialGroupProviderAdapter { + if (!isCredentialGroupProvider(context.option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${context.option.provider}`) + } + return getCredentialGroupProviderAdapter(context.option.provider) +} + +async function assertCurrentPolicy( + context: CredentialGroupOAuthContext, + adapter: CredentialGroupProviderAdapter, + attempt?: CredentialGroupOAuthAttempt +): Promise { + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const optionMatches = context.option.provider === policy.provider + const attemptMatches = + !attempt || + (attempt.provider === policy.provider && + attempt.authorizationAppId === policy.authorizationAppId && + attempt.scopeVersion === policy.scopeVersion && + scopesEqual(attempt.requiredScopes, policy.requiredScopes)) + if (!optionMatches || !attemptMatches) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + return policy +} + +/** Builds a provider authorization URL after persisting a provider-bound one-time attempt. */ +export async function startCredentialGroupOAuth( + context: CredentialGroupOAuthContext, + invitationToken: string +): Promise { + const adapter = getOptionAdapter(context) + const policy = await assertCurrentPolicy(context, adapter) + const prepared = await adapter.prepareAuthorization(context, policy) + const { state, nonce } = await createCredentialGroupOAuthAttempt({ + provider: policy.provider, + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + optionId: context.option.id, + authorizationAppId: policy.authorizationAppId, + scopeVersion: policy.scopeVersion, + requiredScopes: policy.requiredScopes, + redirectUri: prepared.redirectUri, + codeVerifier: prepared.codeVerifier, + invitationToken, + }) + return await prepared.buildAuthorizationUrl({ state, nonce }) +} + +async function persistGrant( + context: CredentialGroupOAuthContext, + adapter: CredentialGroupProviderAdapter, + policy: CredentialGroupProviderPolicy, + grant: VerifiedCredentialGroupGrant +): Promise { + if (grant.providerId !== policy.providerId) { + throw new CredentialGroupOAuthError('Provider returned a credential for another app.', 502) + } + + await db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, context.enrollmentId) + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-oauth:${context.enrollmentId}:${context.option.id}`}, 0))` + ) + const [enrollment] = await tx + .select({ status: credentialGroupEnrollment.status }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.id, context.enrollmentId)) + .limit(1) + if (!enrollment || enrollment.status === 'revoked') { + throw new CredentialGroupOAuthError('This account invitation was revoked.', 409) + } + + const [group] = await tx + .select({ status: credentialGroup.status, options: credentialGroup.options }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, context.credentialGroupId), + eq(credentialGroup.workspaceId, context.workspaceId) + ) + ) + .limit(1) + .for('update') + const currentOption = group?.options.find((option) => option.id === context.option.id) + if ( + !group || + group.status !== 'active' || + !currentOption || + currentOption.status !== 'active' || + currentOption.provider !== adapter.provider + ) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + const currentPolicy = await adapter.getPolicy(currentOption, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + executor: tx, + }) + if (!policiesEqual(currentPolicy, policy)) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + + const [existing] = await tx + .select({ + id: credential.id, + providerSubjectId: credential.providerSubjectId, + encryptedOauthTokenSet: credential.encryptedOauthTokenSet, + refreshTokenExpiresAt: credential.refreshTokenExpiresAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, context.enrollmentId), + eq(credential.credentialGroupOptionId, context.option.id) + ) + ) + .limit(1) + + let refreshToken = grant.refreshToken + if ( + !refreshToken && + existing?.providerSubjectId === grant.providerSubjectId && + existing.encryptedOauthTokenSet + ) { + refreshToken = (await decryptManagedOAuthTokenSet(existing.encryptedOauthTokenSet)) + .refreshToken + } + if (adapter.requiresRefreshToken && !refreshToken) { + const service = getCredentialGroupProviderService(policy.provider) + throw new CredentialGroupOAuthError( + `${service.name} did not issue offline access. Remove Sim from the provider and try again.`, + 409 + ) + } + + const encryptedOauthTokenSet = await encryptManagedOAuthTokenSet({ + accessToken: grant.accessToken, + ...(refreshToken ? { refreshToken } : {}), + }) + const now = new Date() + const service = getCredentialGroupProviderService(policy.provider) + const values = { + workspaceId: context.workspaceId, + type: 'managed_oauth' as const, + displayName: grant.displayName, + description: `Managed ${service.name} account for ${context.workspaceName}`, + providerId: policy.providerId, + accountId: null, + authorizationAppId: policy.authorizationAppId, + credentialGroupEnrollmentId: context.enrollmentId, + credentialGroupOptionId: context.option.id, + managedOauthScopeVersion: policy.scopeVersion, + providerSubjectId: grant.providerSubjectId, + providerTenantId: grant.providerTenantId, + managedOauthStatus: 'active' as const, + grantedScopes: grant.grantedScopes, + providerMetadata: grant.metadata, + encryptedOauthTokenSet, + grantedAt: now, + revokedAt: null, + accessTokenExpiresAt: grant.accessTokenExpiresAt, + refreshTokenExpiresAt: grant.refreshTokenExpiresAt ?? existing?.refreshTokenExpiresAt ?? null, + lastRefreshedAt: null, + updatedAt: now, + } + + if (existing) { + const [updated] = await tx + .update(credential) + .set(values) + .where(eq(credential.id, existing.id)) + .returning({ id: credential.id }) + if (!updated) throw new Error('Managed OAuth credential update returned no row') + } else { + const [inserted] = await tx + .insert(credential) + .values({ + id: generateId(), + ...values, + createdBy: context.workspaceOwnerId, + createdAt: now, + }) + .returning({ id: credential.id }) + if (!inserted) throw new Error('Managed OAuth credential insert returned no row') + } + + const [updatedEnrollment] = await tx + .update(credentialGroupEnrollment) + .set({ + status: enrollment.status === 'completed' ? 'completed' : 'in_progress', + ...(enrollment.status === 'completed' ? {} : { completedAt: null }), + updatedAt: now, + }) + .where( + and( + eq(credentialGroupEnrollment.id, context.enrollmentId), + ne(credentialGroupEnrollment.status, 'revoked') + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!updatedEnrollment) { + throw new CredentialGroupOAuthError('This account invitation was revoked.', 409) + } + }) +} + +/** Exchanges a single-use code through its provider adapter and persists a normalized grant. */ +export async function completeCredentialGroupOAuth( + context: CredentialGroupOAuthContext, + attempt: CredentialGroupOAuthAttempt, + code: string +): Promise { + if ( + attempt.enrollmentId !== context.enrollmentId || + attempt.credentialGroupId !== context.credentialGroupId || + attempt.optionId !== context.option.id || + attempt.provider !== context.option.provider + ) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const adapter = getOptionAdapter(context) + const policy = await assertCurrentPolicy(context, adapter, attempt) + const grant = await adapter.exchangeAndVerify({ context, attempt, code, policy }) + await persistGrant(context, adapter, policy, grant) +} diff --git a/apps/sim/lib/credential-groups/provider-adapter.ts b/apps/sim/lib/credential-groups/provider-adapter.ts new file mode 100644 index 00000000000..0f27828ace4 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-adapter.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto' +import type { CredentialGroupOptionConfig, ManagedOAuthProviderMetadata } from '@sim/db/schema' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import type { DbOrTx } from '@/lib/db/types' +import type { RefreshTokenResult } from '@/lib/oauth' + +export interface CredentialGroupProviderPolicy { + provider: CredentialGroupProvider + providerId: string + authorizationAppId: string + requiredScopes: string[] + scopeVersion: number +} + +export function credentialGroupScopePolicyVersion(scopes: string[]): number { + const digest = createHash('sha256') + .update([...new Set(scopes)].sort().join('\0')) + .digest() + const version = digest.readUInt32BE(0) & 0x7fffffff + return version || 1 +} + +export interface VerifiedCredentialGroupGrant { + providerId: string + providerSubjectId: string + providerTenantId: string | null + displayName: string + metadata: ManagedOAuthProviderMetadata + accessToken: string + refreshToken?: string + grantedScopes: string[] + accessTokenExpiresAt: Date | null + refreshTokenExpiresAt: Date | null +} + +export interface PreparedCredentialGroupAuthorization { + redirectUri: string + codeVerifier?: string + buildAuthorizationUrl(params: { state: string; nonce: string }): string | Promise +} + +export interface CredentialGroupProviderAdapter { + provider: CredentialGroupProvider + requiresRefreshToken: boolean + getPolicy( + option: Pick | undefined, + context: { + workspaceId: string + credentialGroupId?: string + authorizationAppId?: string + executor?: DbOrTx + } + ): Promise + prepareAuthorization( + context: CredentialGroupOAuthContext, + policy: CredentialGroupProviderPolicy + ): Promise + exchangeAndVerify(params: { + context: CredentialGroupOAuthContext + attempt: CredentialGroupOAuthAttempt + code: string + policy: CredentialGroupProviderPolicy + }): Promise + hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean + refreshToken(refreshToken: string): Promise + isTerminalRefreshError(errorCode: string | undefined): boolean +} + +export class CredentialGroupProviderConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = 'CredentialGroupProviderConfigurationError' + } +} + +export class CredentialGroupOAuthError extends Error { + constructor( + message: string, + readonly statusCode: 400 | 401 | 403 | 404 | 409 | 502 | 503 + ) { + super(message) + this.name = 'CredentialGroupOAuthError' + } +} diff --git a/apps/sim/lib/credential-groups/provider-configuration.ts b/apps/sim/lib/credential-groups/provider-configuration.ts new file mode 100644 index 00000000000..40ea17614ea --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-configuration.ts @@ -0,0 +1,144 @@ +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, sql } from 'drizzle-orm' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { DbOrTx } from '@/lib/db/types' + +const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE = + 'credential-group-provider-configuration' as const +const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION = 1 as const + +export interface SlackCredentialGroupConfiguration { + slackBotCredentialId: string + clientId: string + clientSecret: string + appId: string + teamId: string + scopes: string[] + verifiedAt: string +} + +export interface CredentialGroupProviderConfiguration { + type: typeof CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE + version: typeof CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION + slack?: SlackCredentialGroupConfiguration +} + +function isSlackConfiguration(value: unknown): value is SlackCredentialGroupConfiguration { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.slackBotCredentialId === 'string' && + typeof candidate.clientId === 'string' && + typeof candidate.clientSecret === 'string' && + typeof candidate.appId === 'string' && + typeof candidate.teamId === 'string' && + Array.isArray(candidate.scopes) && + candidate.scopes.every((scope) => typeof scope === 'string') && + typeof candidate.verifiedAt === 'string' + ) +} + +function parseCredentialGroupProviderConfiguration( + value: unknown +): CredentialGroupProviderConfiguration { + if (!value || typeof value !== 'object') { + throw new Error('Credential Group provider configuration is malformed') + } + const candidate = value as Record + if ( + candidate.type !== CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE || + candidate.version !== CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION || + (candidate.slack !== undefined && !isSlackConfiguration(candidate.slack)) + ) { + throw new Error('Credential Group provider configuration is malformed') + } + return { + type: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE, + version: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION, + ...(candidate.slack ? { slack: candidate.slack as SlackCredentialGroupConfiguration } : {}), + } +} + +export function emptyCredentialGroupProviderConfiguration(): CredentialGroupProviderConfiguration { + return { + type: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE, + version: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION, + } +} + +export async function encryptCredentialGroupProviderConfiguration( + configuration: CredentialGroupProviderConfiguration +): Promise { + const parsed = parseCredentialGroupProviderConfiguration(configuration) + return (await encryptSecret(JSON.stringify(parsed))).encrypted +} + +export async function decryptCredentialGroupProviderConfiguration( + encryptedConfiguration: string | null +): Promise { + if (!encryptedConfiguration) return emptyCredentialGroupProviderConfiguration() + try { + const decrypted = await decryptSecret(encryptedConfiguration) + return parseCredentialGroupProviderConfiguration(JSON.parse(decrypted.decrypted) as unknown) + } catch (error) { + throw new Error( + `Credential Group provider configuration could not be read: ${getErrorMessage(error)}` + ) + } +} + +export async function getSlackCredentialGroupConfiguration(params: { + workspaceId: string + credentialGroupId: string + executor?: DbOrTx +}): Promise { + const executor = params.executor ?? db + const [row] = await executor + .select({ encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + if (!row) return null + const configuration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + return configuration.slack ?? null +} + +export async function listSlackCredentialGroupConfigurationsForBot(params: { + workspaceId: string + slackBotCredentialId: string +}): Promise { + const rows = await db + .select({ encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, params.workspaceId), + sql`${credentialGroup.options} @> ${JSON.stringify([ + { provider: 'slack', slackBotCredentialId: params.slackBotCredentialId }, + ])}::jsonb` + ) + ) + return Promise.all( + rows.map(async (row) => { + const configuration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + if (!configuration.slack) { + throw new Error('Credential Group Slack configuration is missing') + } + if (configuration.slack.slackBotCredentialId !== params.slackBotCredentialId) { + throw new Error('Credential Group Slack configuration does not match its custom bot') + } + return configuration.slack + }) + ) +} diff --git a/apps/sim/lib/credential-groups/provider-registry.test.ts b/apps/sim/lib/credential-groups/provider-registry.test.ts new file mode 100644 index 00000000000..e190d73b20e --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-registry.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createGoogleManagedOAuthConnector } from '@/lib/auth/connectors/managed-oauth' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { + getCredentialGroupProviderFromProviderId, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' + +const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify' +const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send' +const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels' + +describe('Credential Group provider registry', () => { + it('derives provider identity and display metadata from the OAuth service catalog', () => { + const service = getCredentialGroupProviderService('gmail') + + expect(service.name).toBe('Gmail') + expect(service.providerId).toBe('google-email') + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe('gmail') + }) + + it('maps Google Calendar to its existing OAuth provider', () => { + const service = getCredentialGroupProviderService('google-calendar') + + expect(service.name).toBe('Google Calendar') + expect(service.providerId).toBe('google-calendar') + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe('google-calendar') + }) + + it('uses provider-owned scope implication rules', () => { + const managedOAuth = createGoogleManagedOAuthConnector('google-email') + const canonicalScopes = getCredentialGroupProviderService('gmail').scopes + const grantedScopes = canonicalScopes.filter( + (scope) => scope !== GMAIL_SEND_SCOPE && scope !== GMAIL_LABELS_SCOPE + ) + + expect(grantedScopes).toContain(GMAIL_MODIFY_SCOPE) + expect(managedOAuth.hasRequiredScopes(grantedScopes, canonicalScopes)).toBe(true) + expect(managedOAuth.hasRequiredScopes([], canonicalScopes)).toBe(false) + }) + + it('requires the complete Google Calendar scope policy', () => { + const managedOAuth = createGoogleManagedOAuthConnector('google-calendar') + const requiredScopes = getCredentialGroupProviderService('google-calendar').scopes + + expect(managedOAuth.hasRequiredScopes(requiredScopes, requiredScopes)).toBe(true) + expect(managedOAuth.hasRequiredScopes(requiredScopes.slice(1), requiredScopes)).toBe(false) + }) + + it('maps the legacy Slack tool scope bundle to the managed-user policy', () => { + const adapter = getCredentialGroupProviderAdapter('slack') + const canonicalScopes = getCredentialGroupProviderService('slack').scopes + + expect(adapter.hasRequiredScopes([...SLACK_MANAGED_USER_SCOPES], canonicalScopes)).toBe(true) + expect( + adapter.hasRequiredScopes( + SLACK_MANAGED_USER_SCOPES.filter((scope) => scope !== 'chat:write'), + canonicalScopes + ) + ).toBe(false) + expect(adapter.hasRequiredScopes(['chat:write'], ['chat:write'])).toBe(true) + }) + + it('fails fast for an unregistered managed provider ID', () => { + expect(() => getCredentialGroupProviderFromProviderId('unknown-provider')).toThrow( + 'Unsupported managed credential provider' + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/provider-registry.ts b/apps/sim/lib/credential-groups/provider-registry.ts new file mode 100644 index 00000000000..5ec9e84a916 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-registry.ts @@ -0,0 +1,28 @@ +import type { CredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-adapter' +import { + type CredentialGroupProvider, + getCredentialGroupProviderFromProviderId, +} from '@/lib/credential-groups/providers' +import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider' +import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider' + +const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record< + CredentialGroupProvider, + CredentialGroupProviderAdapter +> = { + gmail: createStandardOAuthCredentialGroupProviderAdapter('gmail'), + 'google-calendar': createStandardOAuthCredentialGroupProviderAdapter('google-calendar'), + slack: slackCredentialGroupProviderAdapter, +} + +export function getCredentialGroupProviderAdapter( + provider: CredentialGroupProvider +): CredentialGroupProviderAdapter { + return CREDENTIAL_GROUP_PROVIDER_ADAPTERS[provider] +} + +export function getCredentialGroupProviderAdapterByProviderId( + providerId: string +): CredentialGroupProviderAdapter { + return getCredentialGroupProviderAdapter(getCredentialGroupProviderFromProviderId(providerId)) +} diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts new file mode 100644 index 00000000000..ac9162a06d2 --- /dev/null +++ b/apps/sim/lib/credential-groups/providers.ts @@ -0,0 +1,84 @@ +import type { OAuthServiceConfig } from '@/lib/oauth' +import { getServiceConfigByServiceId } from '@/lib/oauth' + +export const CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS = ['gmail', 'google-calendar'] as const + +export type CredentialGroupStandardOAuthProvider = + (typeof CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)[number] + +export const CREDENTIAL_GROUP_PROVIDER_IDS = [ + ...CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, + 'slack', +] as const + +export type CredentialGroupProvider = (typeof CREDENTIAL_GROUP_PROVIDER_IDS)[number] + +export interface CredentialGroupProviderSupport { + serviceId: string + description: string + configuration: 'oauth' | 'slack_custom_bot' +} + +const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record< + CredentialGroupProvider, + CredentialGroupProviderSupport +> = { + gmail: { + serviceId: 'gmail', + description: 'Let each person connect one Gmail account', + configuration: 'oauth', + }, + 'google-calendar': { + serviceId: 'google-calendar', + description: 'Let each person connect one Google Calendar account', + configuration: 'oauth', + }, + slack: { + serviceId: 'slack', + description: 'Let each person connect through your custom Slack app', + configuration: 'slack_custom_bot', + }, +} + +export function isCredentialGroupProvider(value: string): value is CredentialGroupProvider { + return CREDENTIAL_GROUP_PROVIDER_IDS.some((provider) => provider === value) +} + +export function isCredentialGroupStandardOAuthProvider( + value: CredentialGroupProvider +): value is CredentialGroupStandardOAuthProvider { + return CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS.some((provider) => provider === value) +} + +export function getCredentialGroupProviderService( + provider: CredentialGroupProvider +): OAuthServiceConfig { + const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + const service = getServiceConfigByServiceId(support.serviceId) + if (!service) { + throw new Error( + `Credential Group provider ${provider} references missing OAuth service ${support.serviceId}` + ) + } + return service +} + +export function getCredentialGroupProviderSupport( + provider: CredentialGroupProvider +): CredentialGroupProviderSupport { + return CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] +} + +export function getCredentialGroupProviderId(provider: CredentialGroupProvider): string { + return getCredentialGroupProviderService(provider).providerId +} + +export function getCredentialGroupProviderFromProviderId( + providerId: string +): CredentialGroupProvider { + const provider = CREDENTIAL_GROUP_PROVIDER_IDS.find( + (candidate) => getCredentialGroupProviderId(candidate) === providerId + ) + if (!provider) throw new Error(`Unsupported managed credential provider: ${providerId}`) + return provider +} diff --git a/apps/sim/lib/credential-groups/rate-limit.ts b/apps/sim/lib/credential-groups/rate-limit.ts new file mode 100644 index 00000000000..a16f46304c7 --- /dev/null +++ b/apps/sim/lib/credential-groups/rate-limit.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server' +import { RateLimitError, RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { getClientIp } from '@/lib/core/utils/request' + +const rateLimiter = new RateLimiter() + +const CREDENTIAL_GROUP_INVITATION_RATE_LIMIT = { + maxTokens: 5, + refillRate: 5, + refillIntervalMs: 60_000, +} as const + +function credentialGroupInvitationRateLimitKey(workspaceId: string): string { + return `route:credential-group-invitations:workspace:${workspaceId}` +} + +const PUBLIC_ENROLLMENT_METADATA_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 120, + refillRate: 120, + refillIntervalMs: 60_000, +} + +const PUBLIC_OAUTH_START_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + +const PUBLIC_OAUTH_CALLBACK_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 60, + refillRate: 60, + refillIntervalMs: 15 * 60_000, +} + +type PublicCredentialGroupRateLimitScope = + | 'metadata' + | 'oauth-start' + | 'oauth-callback' + | 'complete' + +function rateLimitResponse(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { + const retryAfterSeconds = Math.ceil((retryAfterMs ?? fallbackMs) / 1000) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfterSeconds), + 'Cache-Control': 'no-store', + }, + } + ) +} + +function configForPublicScope(scope: PublicCredentialGroupRateLimitScope): TokenBucketConfig { + if (scope === 'metadata') return PUBLIC_ENROLLMENT_METADATA_RATE_LIMIT + if (scope === 'oauth-start' || scope === 'complete') return PUBLIC_OAUTH_START_RATE_LIMIT + return PUBLIC_OAUTH_CALLBACK_RATE_LIMIT +} + +/** Per-IP guard for unauthenticated enrollment reads and OAuth endpoints. */ +export async function enforcePublicCredentialGroupIpRateLimit( + request: { headers: { get(name: string): string | null } }, + scope: PublicCredentialGroupRateLimitScope +): Promise { + const config = configForPublicScope(scope) + const ip = getClientIp(request) + const result = await rateLimiter.checkRateLimitDirect( + `public-credential-group:${scope}:ip:${ip}`, + config, + { failClosed: scope !== 'metadata' } + ) + return result.allowed ? null : rateLimitResponse(result.retryAfterMs, config.refillIntervalMs) +} + +/** Prevents one leaked invitation from starting unbounded provider consent flows. */ +export async function enforceCredentialGroupEnrollmentOAuthRateLimit( + enrollmentId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + `public-credential-group:oauth-start:enrollment:${enrollmentId}`, + PUBLIC_OAUTH_START_RATE_LIMIT, + { failClosed: true } + ) + return result.allowed + ? null + : rateLimitResponse(result.retryAfterMs, PUBLIC_OAUTH_START_RATE_LIMIT.refillIntervalMs) +} + +export class CredentialGroupInvitationRateLimitError extends RateLimitError { + constructor( + readonly retryAfterSeconds: number, + readonly resetAt: Date + ) { + super('Rate limit exceeded') + this.name = 'CredentialGroupInvitationRateLimitError' + } +} + +/** Shared workspace admission for HTTP batch invitations and resends. */ +export async function enforceCredentialGroupInvitationRouteRateLimit( + workspaceId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + credentialGroupInvitationRateLimitKey(workspaceId), + CREDENTIAL_GROUP_INVITATION_RATE_LIMIT, + { failClosed: true } + ) + if (!result.allowed) { + throw new CredentialGroupInvitationRateLimitError( + Math.max(1, Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)), + result.resetAt + ) + } +} + +/** Applies the shared invitation budget to non-HTTP workflow execution. */ +export async function enforceCredentialGroupInvitationExecutionRateLimit( + workspaceId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + credentialGroupInvitationRateLimitKey(workspaceId), + CREDENTIAL_GROUP_INVITATION_RATE_LIMIT, + { failClosed: true } + ) + if (!result.allowed) throw new RateLimitError('Credential Group invitation rate limit exceeded') +} diff --git a/apps/sim/lib/credential-groups/service.test.ts b/apps/sim/lib/credential-groups/service.test.ts new file mode 100644 index 00000000000..c73afcc2bee --- /dev/null +++ b/apps/sim/lib/credential-groups/service.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetPolicy } = vi.hoisted(() => ({ + mockGetPolicy: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => ({ getPolicy: mockGetPolicy }), +})) + +import { updateCredentialGroup } from '@/lib/credential-groups/service' + +describe('Credential Group service', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('validates provider policy through the active update transaction', async () => { + const option = { + id: 'option-1', + provider: 'slack' as const, + label: 'Slack', + slackBotCredentialId: 'bot-1', + authorizationAppId: 'slack:A123:T123', + requiredScopes: ['chat:write'], + scopeVersion: 1, + required: true, + status: 'active' as const, + } + const existing = { + id: 'group-1', + workspaceId: 'workspace-1', + publicId: 'public-1', + name: 'Support accounts', + description: null, + options: [option], + encryptedProviderConfiguration: null, + status: 'active' as const, + createdBy: 'user-1', + createdAt: new Date('2026-08-13T00:00:00Z'), + updatedAt: new Date('2026-08-13T00:00:00Z'), + } + queueTableRows(schemaMock.credentialGroup, [existing]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...existing, updatedAt: new Date('2026-08-13T01:00:00Z') }, + ]) + mockGetPolicy.mockResolvedValue({ + provider: 'slack', + providerId: 'slack', + authorizationAppId: option.authorizationAppId, + requiredScopes: option.requiredScopes, + scopeVersion: option.scopeVersion, + }) + + await expect( + updateCredentialGroup('workspace-1', 'group-1', { + options: [ + { + id: option.id, + provider: option.provider, + label: option.label, + slackBotCredentialId: option.slackBotCredentialId, + required: option.required, + }, + ], + }) + ).resolves.toMatchObject({ id: 'group-1' }) + + expect(mockGetPolicy).toHaveBeenCalledWith( + expect.objectContaining({ slackBotCredentialId: 'bot-1' }), + { + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + executor: dbChainMock.db, + } + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts new file mode 100644 index 00000000000..1ff48a9a7b1 --- /dev/null +++ b/apps/sim/lib/credential-groups/service.ts @@ -0,0 +1,268 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, desc, eq, inArray } from 'drizzle-orm' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { isCredentialGroupProvider } from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import type { + CreateCredentialGroupInput, + CredentialGroupOptionInput, + CredentialGroupRecord, + UpdateCredentialGroupInput, +} from '@/lib/credential-groups/types' +import type { DbOrTx } from '@/lib/db/types' + +function scopesEqual(left: string[], right: string[]): boolean { + const normalizedLeft = [...new Set(left)].sort() + const normalizedRight = [...new Set(right)].sort() + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((scope, index) => scope === normalizedRight[index]) + ) +} + +async function buildOption( + workspaceId: string, + option: CredentialGroupOptionInput, + credentialGroupId?: string, + executor: DbOrTx = db +): Promise { + const providerConfig = await getCredentialGroupProviderAdapter(option.provider).getPolicy( + option, + { workspaceId, credentialGroupId, executor } + ) + return { + id: generateId(), + provider: option.provider, + label: option.label, + ...(option.provider === 'slack' ? { slackBotCredentialId: option.slackBotCredentialId } : {}), + authorizationAppId: providerConfig.authorizationAppId, + requiredScopes: providerConfig.requiredScopes, + scopeVersion: providerConfig.scopeVersion, + required: option.required, + status: 'active', + } +} + +async function updateOptions( + workspaceId: string, + credentialGroupId: string, + inputs: NonNullable, + existingOptions: CredentialGroupOptionConfig[], + executor: DbOrTx +): Promise { + const existingById = new Map(existingOptions.map((option) => [option.id, option])) + return Promise.all( + inputs.map(async (input) => { + if (!input.id) return buildOption(workspaceId, input, credentialGroupId, executor) + const existing = existingById.get(input.id) + if (!existing) throw new Error(`Credential group option ${input.id} does not exist`) + if (input.provider !== existing.provider) { + throw new Error('A credential option provider cannot be changed; add a new option instead') + } + + const providerConfig = await getCredentialGroupProviderAdapter(input.provider).getPolicy( + input, + { workspaceId, credentialGroupId, executor } + ) + return { + id: existing.id, + provider: existing.provider, + label: input.label, + ...(input.provider === 'slack' ? { slackBotCredentialId: input.slackBotCredentialId } : {}), + authorizationAppId: providerConfig.authorizationAppId, + requiredScopes: providerConfig.requiredScopes, + scopeVersion: providerConfig.scopeVersion, + required: input.required, + status: existing.status, + } + }) + ) +} + +async function toCredentialGroup( + row: typeof credentialGroup.$inferSelect +): Promise { + const providerConfiguration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + return { + id: row.id, + workspaceId: row.workspaceId, + name: row.name, + description: row.description, + options: row.options.map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${option.provider}`) + } + const common = { + id: option.id, + label: option.label, + required: option.required, + status: option.status, + } + if (option.provider !== 'slack') { + return { ...common, provider: option.provider, configurationStatus: 'ready' as const } + } + if (!option.slackBotCredentialId) { + throw new Error(`Slack credential option ${option.id} has no custom bot`) + } + return { + ...common, + provider: 'slack' as const, + slackBotCredentialId: option.slackBotCredentialId, + configurationStatus: + !providerConfiguration.slack || + providerConfiguration.slack.slackBotCredentialId !== option.slackBotCredentialId + ? ('not_configured' as const) + : option.scopeVersion !== + credentialGroupScopePolicyVersion([...SLACK_MANAGED_USER_SCOPES]) || + !SLACK_MANAGED_USER_SCOPES.every((scope) => + providerConfiguration.slack?.scopes.includes(scope) + ) + ? ('needs_update' as const) + : ('ready' as const), + } + }), + status: row.status, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +export async function listCredentialGroups(workspaceId: string): Promise { + const rows = await db + .select() + .from(credentialGroup) + .where(eq(credentialGroup.workspaceId, workspaceId)) + .orderBy(desc(credentialGroup.createdAt)) + return Promise.all(rows.map(toCredentialGroup)) +} + +export async function getCredentialGroup( + workspaceId: string, + groupId: string +): Promise { + const [row] = await db + .select() + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + return row ? toCredentialGroup(row) : null +} + +export async function createCredentialGroup( + workspaceId: string, + userId: string, + body: CreateCredentialGroupInput +): Promise { + const now = new Date() + const options = await Promise.all(body.options.map((option) => buildOption(workspaceId, option))) + const [created] = await db + .insert(credentialGroup) + .values({ + id: generateId(), + workspaceId, + publicId: generateId(), + name: body.name, + description: body.description || null, + options, + status: 'active', + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + .returning() + + if (!created) throw new Error('Credential group insert returned no row') + return toCredentialGroup(created) +} + +export async function deleteCredentialGroup( + workspaceId: string, + groupId: string +): Promise { + const deleted = await db + .delete(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .returning({ id: credentialGroup.id }) + return deleted.length > 0 +} + +export async function updateCredentialGroup( + workspaceId: string, + groupId: string, + body: UpdateCredentialGroupInput +): Promise { + return db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + .for('update') + if (!existing) return null + + const nextOptions = + body.options !== undefined + ? await updateOptions(workspaceId, groupId, body.options, existing.options, tx) + : existing.options + const keepsSlack = nextOptions.some((option) => option.provider === 'slack') + const encryptedProviderConfiguration = keepsSlack + ? existing.encryptedProviderConfiguration + : null + const nextOptionById = new Map(nextOptions.map((option) => [option.id, option])) + const invalidatedOptionIds = existing.options + .filter((option) => { + const next = nextOptionById.get(option.id) + return ( + !next || + next.authorizationAppId !== option.authorizationAppId || + next.scopeVersion !== option.scopeVersion || + !scopesEqual(next.requiredScopes, option.requiredScopes) || + body.status === 'disabled' + ) + }) + .map((option) => option.id) + + const [updated] = await tx + .update(credentialGroup) + .set({ + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.description !== undefined ? { description: body.description || null } : {}), + ...(body.options !== undefined ? { options: nextOptions } : {}), + ...(body.options !== undefined ? { encryptedProviderConfiguration } : {}), + ...(body.status !== undefined ? { status: body.status } : {}), + updatedAt: new Date(), + }) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .returning() + + if (!updated) throw new Error('Credential group update returned no row') + if (invalidatedOptionIds.length > 0) { + const enrollmentIds = tx + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, groupId)) + await tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.credentialGroupOptionId, invalidatedOptionIds) + ) + ) + } + return toCredentialGroup(updated) + }) +} diff --git a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts new file mode 100644 index 00000000000..9e6ec9990ce --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts @@ -0,0 +1,29 @@ +/** + * User-token policy requested and verified by Credential Group Slack OAuth. + * This is independent of the custom bot manifest and its configuration UI. + */ +export const SLACK_MANAGED_USER_SCOPES = [ + 'channels:history', + 'channels:read', + 'channels:write', + 'canvases:read', + 'canvases:write', + 'chat:write', + 'files:read', + 'files:write', + 'groups:history', + 'groups:read', + 'groups:write', + 'im:history', + 'im:read', + 'im:write', + 'mpim:history', + 'mpim:read', + 'mpim:write', + 'reactions:read', + 'reactions:write', + 'users.profile:read', + 'users.profile:write', + 'users:read', + 'users:read.email', +] as const diff --git a/apps/sim/lib/credential-groups/slack-managed-users.test.ts b/apps/sim/lib/credential-groups/slack-managed-users.test.ts new file mode 100644 index 00000000000..9e13d3ce11c --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-users.test.ts @@ -0,0 +1,399 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { attempts, redis } = vi.hoisted(() => { + const attempts = new Map() + return { + attempts, + redis: { + set: vi.fn(async (key: string, value: string) => { + if (attempts.has(key)) return null + attempts.set(key, value) + return 'OK' + }), + get: vi.fn(async (key: string) => attempts.get(key) ?? null), + eval: vi.fn(async (_script: string, _count: number, key: string) => { + const value = attempts.get(key) ?? null + attempts.delete(key) + return value + }), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => redis })) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: + value === 'encrypted-bot' + ? JSON.stringify({ + type: 'slack_custom_bot', + signingSecret: 'signing-secret', + botToken: 'xoxb-token', + teamId: 'T123', + }) + : Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.ai' })) + +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + consumeSlackManagedUsersAttempt, + createSlackManagedUsersAttempt, + exchangeAndConfigureSlackManagedUsers, + exchangeSlackUserAuthorization, + loadSlackManagedUsersAttempt, + verifySlackCustomBotAppIdentity, + verifySlackUserIdentity, +} from '@/lib/credential-groups/slack-managed-users' + +function slackResponse(value: Record): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('Slack managed-user authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + attempts.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('binds the bot token to Slack app and workspace identities', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ ok: true, team_id: 'T123', user_id: 'U123', bot_id: 'B123' }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, bot: { id: 'B123', app_id: 'A123' } })) + vi.stubGlobal('fetch', fetchMock) + + await expect(verifySlackCustomBotAppIdentity('xoxb-token')).resolves.toEqual({ + appId: 'A123', + teamId: 'T123', + }) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://slack.com/api/bots.info', + expect.objectContaining({ body: new URLSearchParams({ bot: 'B123' }) }) + ) + }) + + it('encrypts setup secrets and consumes the short-lived state once', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: '22222222-2222-4222-8222-222222222222', + updatedAt: new Date('2026-08-12T00:00:00Z'), + }, + ]) + .mockResolvedValueOnce([ + { + id: '11111111-1111-4111-8111-111111111111', + name: 'Support bot', + updatedAt: new Date('2026-08-12T00:00:00Z'), + encryptedServiceAccountKey: 'encrypted-bot', + }, + ]) + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + slackResponse({ ok: true, team_id: 'T123', user_id: 'U123', bot_id: 'B123' }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, bot: { app_id: 'A123' } })) + ) + + const created = await createSlackManagedUsersAttempt({ + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + clientId: 'client-id', + clientSecret: 'client-secret', + }) + + expect(created.authorizationUrl).toContain('team=T123') + expect(created.authorizationUrl).toContain('user_scope=channels%3Ahistory') + expect([...attempts.values()][0]).not.toContain('client-secret') + await expect(loadSlackManagedUsersAttempt(created.state)).resolves.toMatchObject({ + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientSecret: 'client-secret', + }) + await expect(consumeSlackManagedUsersAttempt(created.state)).resolves.toMatchObject({ + clientId: 'client-id', + }) + await expect(consumeSlackManagedUsersAttempt(created.state)).resolves.toBeNull() + }) + + it('returns an actionable error when the custom bot lacks users:read', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', bot_id: 'B123' })) + .mockResolvedValueOnce( + slackResponse({ ok: false, error: 'missing_scope', needed: 'users:read' }) + ) + ) + + await expect(verifySlackCustomBotAppIdentity('xoxb-token')).rejects.toThrow( + 'Add the users:read bot scope' + ) + }) + + it('stores Slack OAuth client configuration on the Credential Group', async () => { + const updatedAt = new Date('2026-08-12T00:00:00Z') + queueTableRows(schemaMock.credentialGroup, [ + { + id: '22222222-2222-4222-8222-222222222222', + workspaceId: 'workspace-1', + name: 'Support accounts', + options: [], + encryptedProviderConfiguration: null, + updatedAt, + }, + ]) + queueTableRows(schemaMock.credential, [ + { + id: '11111111-1111-4111-8111-111111111111', + updatedAt, + encryptedServiceAccountKey: 'encrypted-bot', + }, + ]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: '11111111-1111-4111-8111-111111111111' }]) + .mockResolvedValueOnce([{ id: '22222222-2222-4222-8222-222222222222' }]) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: SLACK_MANAGED_USER_SCOPES.join(','), + }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', user_id: 'U123' })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + user: { id: 'U123', profile: { email: 'theo@sim.ai' } }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, revoked: true })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + exchangeAndConfigureSlackManagedUsers({ + attempt: { + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + credentialGroupUpdatedAt: updatedAt.getTime(), + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + slackBotCredentialUpdatedAt: updatedAt.getTime(), + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://sim.ai/callback', + createdAt: Date.now(), + }, + code: 'single-use-code', + }) + ).resolves.toMatchObject({ + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ authorizationAppId: null, managedOauthScopeVersion: null }) + ) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + encryptedProviderConfiguration: expect.any(String), + options: [ + expect.objectContaining({ + provider: 'slack', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }), + ], + }) + ) + expect(JSON.stringify(dbChainMockFns.set.mock.calls[1])).not.toContain('client-secret') + }) + + it('requires Slack to attest a user token, app, team, user, and scopes', async () => { + const fetchMock = vi.fn().mockResolvedValue( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: 'users:read,users:read.email', + }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await exchangeSlackUserAuthorization({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'single-use-code', + redirectUri: 'https://sim.ai/callback', + }) + + expect(result).toMatchObject({ + appId: 'A123', + teamId: 'T123', + userId: 'U123', + accessToken: 'xoxp-token', + tokenType: 'user', + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://slack.com/api/oauth.v2.access', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }), + }) + ) + }) + + it('fails closed when Slack omits the user token type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + scope: 'users:read', + }, + }) + ) + ) + + await expect( + exchangeSlackUserAuthorization({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'single-use-code', + redirectUri: 'https://sim.ai/callback', + }) + ).rejects.toThrow('Slack returned an incomplete authorization') + }) + + it('revokes the setup token and stores nothing when client credentials target another app', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A999', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: SLACK_MANAGED_USER_SCOPES.join(','), + }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, revoked: true })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + exchangeAndConfigureSlackManagedUsers({ + attempt: { + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + credentialGroupUpdatedAt: new Date('2026-08-12T00:00:00Z').getTime(), + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + slackBotCredentialUpdatedAt: new Date('2026-08-12T00:00:00Z').getTime(), + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://sim.ai/callback', + createdAt: Date.now(), + }, + code: 'single-use-code', + }) + ).rejects.toThrow('different Slack app or workspace') + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://slack.com/api/auth.revoke', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer xoxp-token' }), + }) + ) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) + + it('verifies the token identity and reads cosmetic profile metadata', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', user_id: 'U123' })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + user: { + id: 'U123', + name: 'theo', + profile: { + email: 'theo@sim.ai', + display_name: 'Theo', + image_192: 'https://avatars.slack-edge.com/theo.png', + }, + }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect( + verifySlackUserIdentity({ + accessToken: 'xoxp-token', + expectedTeamId: 'T123', + expectedUserId: 'U123', + }) + ).resolves.toEqual({ + userId: 'U123', + teamId: 'T123', + email: 'theo@sim.ai', + displayName: 'Theo', + avatarUrl: 'https://avatars.slack-edge.com/theo.png', + username: 'theo', + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts new file mode 100644 index 00000000000..3d7368d6f3c --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -0,0 +1,759 @@ +import { Buffer } from 'node:buffer' +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { + decryptCredentialGroupProviderConfiguration, + encryptCredentialGroupProviderConfiguration, +} from '@/lib/credential-groups/provider-configuration' +import { 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' + +const logger = createLogger('SlackManagedUsers') +const SLACK_MANAGED_USERS_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const SLACK_MANAGED_USERS_ATTEMPT_VERSION = 2 as const +const MAX_SLACK_RESPONSE_BYTES = 64 * 1024 +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +interface SlackCustomBotSecret { + type: typeof SLACK_CUSTOM_BOT_SECRET_TYPE + signingSecret: string + botToken: string + teamId: string + botUserId?: string + teamName?: string + metadata?: Record +} + +interface StoredSlackManagedUsersAttempt { + version: typeof SLACK_MANAGED_USERS_ATTEMPT_VERSION + workspaceId: string + userId: string + credentialGroupId: string + credentialGroupUpdatedAt: number + slackBotCredentialId: string + slackBotCredentialUpdatedAt: number + expectedAppId: string + expectedTeamId: string + clientId: string + encryptedClientSecret: string + redirectUri: string + createdAt: number +} + +export interface SlackManagedUsersAttempt { + workspaceId: string + userId: string + credentialGroupId: string + credentialGroupUpdatedAt: number + slackBotCredentialId: string + slackBotCredentialUpdatedAt: number + expectedAppId: string + expectedTeamId: string + clientId: string + clientSecret: string + redirectUri: string + createdAt: number +} + +export interface SlackOAuthSuccess { + appId: string + teamId: string + teamName: string + userId: string + accessToken: string + scopes: string[] + tokenType: 'user' + expiresIn?: number + refreshToken?: string +} + +export interface VerifiedSlackUserIdentity { + userId: string + teamId: string + email: string + displayName?: string + avatarUrl?: string + username?: string +} + +export class SlackManagedUsersError extends Error { + constructor( + message: string, + readonly code: + | 'invalid_state' + | 'provider_error' + | 'invalid_client' + | 'invalid_response' + | 'missing_bot_scope' + | 'token_rotation_enabled' + | 'revoke_failed' + ) { + super(message) + this.name = 'SlackManagedUsersError' + } +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) throw new Error('Slack managed-user setup requires Redis') + return redis +} + +function attemptKey(state: string): string { + return `credential-group:slack-managed-users:${sha256Hex(state)}` +} + +function isStoredAttempt(value: unknown): value is StoredSlackManagedUsersAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === SLACK_MANAGED_USERS_ATTEMPT_VERSION && + typeof candidate.workspaceId === 'string' && + typeof candidate.userId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.credentialGroupUpdatedAt === 'number' && + typeof candidate.slackBotCredentialId === 'string' && + typeof candidate.slackBotCredentialUpdatedAt === 'number' && + typeof candidate.expectedAppId === 'string' && + typeof candidate.expectedTeamId === 'string' && + typeof candidate.clientId === 'string' && + typeof candidate.encryptedClientSecret === 'string' && + typeof candidate.redirectUri === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +function parseSlackCustomBotSecret(value: unknown): SlackCustomBotSecret { + if (!value || typeof value !== 'object') { + throw new Error('Slack custom bot secret is malformed') + } + const candidate = value as Record + if ( + candidate.type !== SLACK_CUSTOM_BOT_SECRET_TYPE || + typeof candidate.signingSecret !== 'string' || + typeof candidate.botToken !== 'string' || + typeof candidate.teamId !== 'string' + ) { + throw new Error('Slack custom bot secret is malformed') + } + return { + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + signingSecret: candidate.signingSecret, + botToken: candidate.botToken, + teamId: candidate.teamId, + ...(typeof candidate.botUserId === 'string' ? { botUserId: candidate.botUserId } : {}), + ...(typeof candidate.teamName === 'string' ? { teamName: candidate.teamName } : {}), + ...(candidate.metadata && typeof candidate.metadata === 'object' + ? { metadata: candidate.metadata as Record } + : {}), + } +} + +function stringField(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object') return null + const field = (value as Record)[key] + return typeof field === 'string' && field.length > 0 ? field : null +} + +async function readBoundedJson(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_SLACK_RESPONSE_BYTES) { + throw new SlackManagedUsersError('Slack returned an oversized response.', 'invalid_response') + } + const text = await response.text() + if (Buffer.byteLength(text, 'utf8') > MAX_SLACK_RESPONSE_BYTES) { + throw new SlackManagedUsersError('Slack returned an oversized response.', 'invalid_response') + } + try { + return JSON.parse(text) as unknown + } catch { + throw new SlackManagedUsersError('Slack returned an invalid response.', 'invalid_response') + } +} + +function parseSlackOAuthResponse(value: unknown): SlackOAuthSuccess { + if (!value || typeof value !== 'object') { + throw new SlackManagedUsersError('Slack returned an invalid response.', 'invalid_response') + } + const response = value as Record + if (response.ok !== true) { + const errorCode = stringField(response, 'error') + throw new SlackManagedUsersError( + errorCode === 'invalid_client_id' || errorCode === 'bad_client_secret' + ? 'Slack rejected the Client ID or Client Secret.' + : 'Slack could not verify this app.', + errorCode === 'invalid_client_id' || errorCode === 'bad_client_secret' + ? 'invalid_client' + : 'provider_error' + ) + } + + const appId = stringField(response, 'app_id') + const team = response.team + const teamId = stringField(team, 'id') + const teamName = stringField(team, 'name') + const authedUser = response.authed_user + const userId = stringField(authedUser, 'id') + const accessToken = stringField(authedUser, 'access_token') + const tokenType = stringField(authedUser, 'token_type') + const scope = stringField(authedUser, 'scope') + if ( + !appId?.startsWith('A') || + !teamId?.startsWith('T') || + !teamName || + !userId?.startsWith('U') || + !accessToken || + tokenType !== 'user' || + !scope + ) { + throw new SlackManagedUsersError( + 'Slack returned an incomplete authorization.', + 'invalid_response' + ) + } + + const expiresIn = + authedUser && typeof authedUser === 'object' + ? (authedUser as Record).expires_in + : undefined + const refreshToken = stringField(authedUser, 'refresh_token') ?? undefined + return { + appId, + teamId, + teamName, + userId, + accessToken, + scopes: scope + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + tokenType: 'user', + ...(typeof expiresIn === 'number' ? { expiresIn } : {}), + ...(refreshToken ? { refreshToken } : {}), + } +} + +export async function revokeSlackToken(token: string): Promise { + const response = await fetch('https://slack.com/api/auth.revoke', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + cache: 'no-store', + }) + const value = await readBoundedJson(response) + if ( + !response.ok || + !value || + typeof value !== 'object' || + (value as Record).ok !== true || + (value as Record).revoked !== true + ) { + throw new SlackManagedUsersError( + 'Slack issued a setup token but could not revoke it. Try again.', + 'revoke_failed' + ) + } +} + +async function callSlackApi(method: string, accessToken: string, body?: URLSearchParams) { + let response: Response + try { + response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ...(body ? { body } : {}), + cache: 'no-store', + }) + } catch (error) { + logger.error('Slack API verification failed', { method, error: getErrorMessage(error) }) + throw new SlackManagedUsersError('Slack could not verify the authorization.', 'provider_error') + } + const value = await readBoundedJson(response) + if ( + !response.ok || + !value || + typeof value !== 'object' || + (value as Record).ok !== true + ) { + const providerError = stringField(value, 'error') + if (method === 'bots.info' && providerError === 'missing_scope') { + throw new SlackManagedUsersError( + 'Add the users:read bot scope to this Slack app, reinstall it, and update the custom bot credential before enabling managed users.', + 'missing_bot_scope' + ) + } + throw new SlackManagedUsersError('Slack could not verify the authorization.', 'provider_error') + } + return value as Record +} + +export async function verifySlackCustomBotAppIdentity(botToken: string): Promise<{ + appId: string + teamId: string +}> { + const auth = await callSlackApi('auth.test', botToken) + const teamId = stringField(auth, 'team_id') + const botId = stringField(auth, 'bot_id') + if (!teamId?.startsWith('T') || !botId?.startsWith('B')) { + throw new SlackManagedUsersError( + 'Slack did not identify this token as an installed bot.', + 'invalid_response' + ) + } + const info = await callSlackApi('bots.info', botToken, new URLSearchParams({ bot: botId })) + const appId = stringField(info.bot, 'app_id') + if (!appId?.startsWith('A')) { + throw new SlackManagedUsersError( + 'Slack did not return the app for this custom bot.', + 'invalid_response' + ) + } + return { appId, teamId } +} + +export async function verifySlackUserIdentity(params: { + accessToken: string + expectedTeamId: string + expectedUserId: string +}): Promise { + const auth = await callSlackApi('auth.test', params.accessToken) + const teamId = stringField(auth, 'team_id') + const userId = stringField(auth, 'user_id') + if (teamId !== params.expectedTeamId || userId !== params.expectedUserId) { + throw new SlackManagedUsersError( + 'Slack returned a credential for another user or workspace.', + 'invalid_response' + ) + } + + const info = await callSlackApi( + 'users.info', + params.accessToken, + new URLSearchParams({ user: params.expectedUserId }) + ) + const user = info.user + if (!user || typeof user !== 'object') { + throw new SlackManagedUsersError( + 'Slack returned an incomplete user profile.', + 'invalid_response' + ) + } + const profile = (user as Record).profile + const email = stringField(profile, 'email') + if (!email) { + throw new SlackManagedUsersError( + 'Slack did not return the user email required by this invitation.', + 'invalid_response' + ) + } + const displayName = stringField(profile, 'display_name') ?? stringField(profile, 'real_name') + const avatarUrl = stringField(profile, 'image_192') ?? stringField(profile, 'image_72') + const username = stringField(user, 'name') + return { + userId, + teamId, + email, + ...(displayName ? { displayName } : {}), + ...(avatarUrl ? { avatarUrl } : {}), + ...(username ? { username } : {}), + } +} + +export async function exchangeSlackUserAuthorization(params: { + clientId: string + clientSecret: string + code: string + redirectUri: string +}): Promise { + const basicAuth = Buffer.from(`${params.clientId}:${params.clientSecret}`, 'utf8').toString( + 'base64' + ) + const body = new URLSearchParams({ code: params.code, redirect_uri: params.redirectUri }) + let response: Response + try { + response = await fetch('https://slack.com/api/oauth.v2.access', { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + cache: 'no-store', + }) + } catch (error) { + logger.error('Slack OAuth exchange failed', { error: getErrorMessage(error) }) + throw new SlackManagedUsersError('Slack could not complete authorization.', 'provider_error') + } + return parseSlackOAuthResponse(await readBoundedJson(response)) +} + +export function getSlackManagedUsersRedirectUri(): string { + return `${getBaseUrl()}/api/credential-groups/slack-managed-users/callback` +} + +export async function createSlackManagedUsersAttempt(params: { + workspaceId: string + userId: string + credentialGroupId: string + slackBotCredentialId: string + clientId: string + clientSecret: string +}): Promise<{ state: string; authorizationUrl: string }> { + const [group] = await db + .select({ id: credentialGroup.id, updatedAt: credentialGroup.updatedAt }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + if (!group) throw new SlackManagedUsersError('Credential Group not found.', 'invalid_response') + const bot = await getSlackCustomBotCredential({ + workspaceId: params.workspaceId, + credentialId: params.slackBotCredentialId, + }) + if (!bot) throw new SlackManagedUsersError('Custom Slack bot not found.', 'invalid_response') + const identity = await verifySlackCustomBotAppIdentity(bot.botToken) + if (identity.teamId !== bot.teamId) { + throw new SlackManagedUsersError( + 'The custom bot token no longer belongs to its stored Slack workspace.', + 'invalid_response' + ) + } + const redis = requireRedis() + const state = generateId() + const redirectUri = getSlackManagedUsersRedirectUri() + const encryptedClientSecret = await encryptSecret(params.clientSecret) + const attempt: StoredSlackManagedUsersAttempt = { + version: SLACK_MANAGED_USERS_ATTEMPT_VERSION, + workspaceId: params.workspaceId, + userId: params.userId, + credentialGroupId: group.id, + credentialGroupUpdatedAt: group.updatedAt.getTime(), + slackBotCredentialId: bot.id, + slackBotCredentialUpdatedAt: bot.updatedAt.getTime(), + expectedAppId: identity.appId, + expectedTeamId: identity.teamId, + clientId: params.clientId, + encryptedClientSecret: encryptedClientSecret.encrypted, + redirectUri, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(state), + JSON.stringify(attempt), + 'PX', + SLACK_MANAGED_USERS_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Slack managed-user state collision') + + const authorizationUrl = new URL('https://slack.com/oauth/v2/authorize') + authorizationUrl.searchParams.set('client_id', params.clientId) + authorizationUrl.searchParams.set('user_scope', SLACK_MANAGED_USER_SCOPES.join(',')) + authorizationUrl.searchParams.set('redirect_uri', redirectUri) + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('team', identity.teamId) + return { state, authorizationUrl: authorizationUrl.toString() } +} + +export async function consumeSlackManagedUsersAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.eval(CONSUME_SCRIPT, 1, attemptKey(state)) + return parseSlackManagedUsersAttempt(raw) +} + +export async function loadSlackManagedUsersAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.get(attemptKey(state)) + return parseSlackManagedUsersAttempt(raw) +} + +async function parseSlackManagedUsersAttempt( + raw: unknown +): Promise { + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Slack managed-user state is malformed') + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Slack managed-user state is malformed') + if (Date.now() - parsed.createdAt > SLACK_MANAGED_USERS_ATTEMPT_TTL_MS) return null + const clientSecret = await decryptSecret(parsed.encryptedClientSecret) + return { + workspaceId: parsed.workspaceId, + userId: parsed.userId, + credentialGroupId: parsed.credentialGroupId, + credentialGroupUpdatedAt: parsed.credentialGroupUpdatedAt, + slackBotCredentialId: parsed.slackBotCredentialId, + slackBotCredentialUpdatedAt: parsed.slackBotCredentialUpdatedAt, + expectedAppId: parsed.expectedAppId, + expectedTeamId: parsed.expectedTeamId, + clientId: parsed.clientId, + clientSecret: clientSecret.decrypted, + redirectUri: parsed.redirectUri, + createdAt: parsed.createdAt, + } +} + +export async function exchangeAndConfigureSlackManagedUsers(params: { + attempt: SlackManagedUsersAttempt + code: string +}): Promise<{ + credentialGroupId: string + credentialGroupName: string + slackBotCredentialId: string + appId: string + teamId: string +}> { + const grant = await exchangeSlackUserAuthorization({ + clientId: params.attempt.clientId, + clientSecret: params.attempt.clientSecret, + code: params.code, + redirectUri: params.attempt.redirectUri, + }) + if (grant.expiresIn !== undefined || grant.refreshToken) { + await Promise.allSettled( + [grant.accessToken, grant.refreshToken] + .filter((token): token is string => Boolean(token)) + .map(revokeSlackToken) + ) + throw new SlackManagedUsersError( + 'Disable token rotation in the Slack app and try again.', + 'token_rotation_enabled' + ) + } + + try { + if ( + grant.appId !== params.attempt.expectedAppId || + grant.teamId !== params.attempt.expectedTeamId + ) { + throw new SlackManagedUsersError( + 'The Client ID and Client Secret belong to a different Slack app or workspace than the selected custom bot.', + 'invalid_response' + ) + } + if (!SLACK_MANAGED_USER_SCOPES.every((scope) => grant.scopes.includes(scope))) { + throw new SlackManagedUsersError( + 'Slack did not grant every permission required for managed users.', + 'invalid_response' + ) + } + await verifySlackUserIdentity({ + accessToken: grant.accessToken, + expectedTeamId: grant.teamId, + expectedUserId: grant.userId, + }) + } finally { + await revokeSlackToken(grant.accessToken) + } + const authorizationAppId = `slack:${grant.appId}:${grant.teamId}` + const now = new Date() + const scopeVersion = credentialGroupScopePolicyVersion([...SLACK_MANAGED_USER_SCOPES]) + + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-managed-users:${params.attempt.credentialGroupId}`}, 0))` + ) + const [group] = await tx + .select() + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.attempt.credentialGroupId), + eq(credentialGroup.workspaceId, params.attempt.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group || group.updatedAt.getTime() !== params.attempt.credentialGroupUpdatedAt) { + throw new SlackManagedUsersError( + 'The Credential Group changed while Slack authorization was in progress. Start again.', + 'invalid_state' + ) + } + const [botRow] = await tx + .select({ + id: credential.id, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + eq(credential.id, params.attempt.slackBotCredentialId), + eq(credential.workspaceId, params.attempt.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + ) + ) + .limit(1) + if ( + !botRow?.encryptedServiceAccountKey || + botRow.updatedAt.getTime() !== params.attempt.slackBotCredentialUpdatedAt + ) { + throw new SlackManagedUsersError( + 'The custom bot changed while Slack authorization was in progress. Start again.', + 'invalid_state' + ) + } + const decrypted = await decryptSecret(botRow.encryptedServiceAccountKey) + const botSecret = parseSlackCustomBotSecret(JSON.parse(decrypted.decrypted) as unknown) + if (botSecret.teamId !== grant.teamId) { + throw new SlackManagedUsersError( + 'The custom bot no longer belongs to the verified Slack workspace.', + 'invalid_state' + ) + } + const sanitizedBotSecret = await encryptSecret(JSON.stringify(botSecret)) + const [cleanedBot] = await tx + .update(credential) + .set({ + encryptedServiceAccountKey: sanitizedBotSecret.encrypted, + authorizationAppId: null, + managedOauthScopeVersion: null, + updatedAt: now, + }) + .where(eq(credential.id, botRow.id)) + .returning({ id: credential.id }) + if (!cleanedBot) throw new Error('Slack custom bot cleanup returned no row') + const currentConfiguration = await decryptCredentialGroupProviderConfiguration( + group.encryptedProviderConfiguration + ) + const encryptedConfiguration = await encryptCredentialGroupProviderConfiguration({ + ...currentConfiguration, + slack: { + slackBotCredentialId: botRow.id, + clientId: params.attempt.clientId, + clientSecret: params.attempt.clientSecret, + appId: grant.appId, + teamId: grant.teamId, + scopes: [...new Set(grant.scopes)], + verifiedAt: now.toISOString(), + }, + }) + const existingOption = group.options.find((option) => option.provider === 'slack') + const nextOption = { + id: existingOption?.id ?? generateId(), + provider: 'slack', + label: existingOption?.label ?? 'Slack', + slackBotCredentialId: botRow.id, + authorizationAppId, + requiredScopes: [...SLACK_MANAGED_USER_SCOPES], + scopeVersion, + required: existingOption?.required ?? true, + status: existingOption?.status ?? ('active' as const), + } + const options = existingOption + ? group.options.map((option) => (option.id === existingOption.id ? nextOption : option)) + : [...group.options, nextOption] + const [updated] = await tx + .update(credentialGroup) + .set({ + options, + encryptedProviderConfiguration: encryptedConfiguration, + updatedAt: now, + }) + .where(eq(credentialGroup.id, group.id)) + .returning({ id: credentialGroup.id }) + if (!updated) throw new Error('Credential Group Slack configuration update returned no row') + if ( + existingOption && + (existingOption.authorizationAppId !== authorizationAppId || + existingOption.scopeVersion !== scopeVersion) + ) { + const enrollmentIds = tx + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, group.id)) + await tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: now }) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + eq(credential.credentialGroupOptionId, existingOption.id) + ) + ) + } + return { + credentialGroupId: group.id, + credentialGroupName: group.name, + slackBotCredentialId: botRow.id, + appId: grant.appId, + teamId: grant.teamId, + } + }) +} + +export async function getSlackCustomBotCredential(params: { + workspaceId: string + credentialId: string + executor?: DbOrTx +}): Promise<{ + id: string + name: string + updatedAt: Date + botToken: string + teamId: string + teamName?: string +} | null> { + const executor = params.executor ?? db + const [row] = await executor + .select({ + id: credential.id, + name: credential.displayName, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID), + eq(credential.id, params.credentialId) + ) + ) + .limit(1) + if (!row) return null + if (!row.encryptedServiceAccountKey) throw new Error('Slack custom bot secret is missing') + const decrypted = await decryptSecret(row.encryptedServiceAccountKey) + const secret = parseSlackCustomBotSecret(JSON.parse(decrypted.decrypted) as unknown) + return { + id: row.id, + name: row.name, + updatedAt: row.updatedAt, + botToken: secret.botToken, + teamId: secret.teamId, + teamName: secret.teamName, + } +} diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts new file mode 100644 index 00000000000..d6633c862b7 --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -0,0 +1,239 @@ +import { normalizeEmail } from '@sim/utils/string' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupOAuthError, + CredentialGroupProviderConfigurationError, + credentialGroupScopePolicyVersion, +} 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 { + exchangeSlackUserAuthorization, + getSlackCustomBotCredential, + revokeSlackToken, + verifySlackUserIdentity, +} from '@/lib/credential-groups/slack-managed-users' +import type { DbOrTx } from '@/lib/db/types' + +const PROVIDER = 'slack' as const + +async function getSlackPolicy(params: { + workspaceId: string + credentialGroupId: string + slackBotCredentialId?: string + executor?: DbOrTx +}): Promise< + CredentialGroupProviderPolicy & { + slackBotCredentialId: string + clientId: string + clientSecret: string + appId: string + teamId: string + } +> { + const managed = await getSlackCredentialGroupConfiguration({ + workspaceId: params.workspaceId, + credentialGroupId: params.credentialGroupId, + ...(params.executor ? { executor: params.executor } : {}), + }) + if (!managed) { + throw new CredentialGroupProviderConfigurationError('Configure Slack on this Credential Group') + } + if (params.slackBotCredentialId && managed.slackBotCredentialId !== params.slackBotCredentialId) { + throw new CredentialGroupProviderConfigurationError( + 'The selected custom Slack bot does not match this Credential Group configuration' + ) + } + const app = await getSlackCustomBotCredential({ + workspaceId: params.workspaceId, + credentialId: managed.slackBotCredentialId, + ...(params.executor ? { executor: params.executor } : {}), + }) + if (!app) { + throw new CredentialGroupProviderConfigurationError( + 'The selected custom Slack bot is unavailable' + ) + } + if (app.teamId !== managed.teamId) { + throw new CredentialGroupProviderConfigurationError( + 'The custom Slack bot no longer belongs to the configured Slack workspace' + ) + } + const service = getCredentialGroupProviderService(PROVIDER) + const requiredScopes = [...SLACK_MANAGED_USER_SCOPES] + const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes) + if (!requiredScopes.every((scope) => managed.scopes.includes(scope))) { + throw new CredentialGroupProviderConfigurationError( + 'Managed-user permissions changed. Reconfigure Slack on this Credential Group.' + ) + } + return { + provider: PROVIDER, + providerId: service.providerId, + authorizationAppId: `slack:${managed.appId}:${managed.teamId}`, + requiredScopes, + scopeVersion, + slackBotCredentialId: managed.slackBotCredentialId, + clientId: managed.clientId, + clientSecret: managed.clientSecret, + appId: managed.appId, + teamId: managed.teamId, + } +} + +/** + * Slack tools still request the legacy canonical bot bundle when they do not declare + * operation-level scopes. Managed user grants translate only that exact fallback to the + * managed-user policy; explicit tool scopes remain exact requirements. + */ +function hasRequiredSlackScopes(grantedScopes: string[], requiredScopes: string[]): boolean { + const granted = new Set(grantedScopes) + const canonicalBotScopes = getCredentialGroupProviderService(PROVIDER).scopes + const isCanonicalFallback = + requiredScopes.length === canonicalBotScopes.length && + requiredScopes.every((scope) => canonicalBotScopes.includes(scope)) + const effectiveRequiredScopes = isCanonicalFallback ? SLACK_MANAGED_USER_SCOPES : requiredScopes + return effectiveRequiredScopes.every((scope) => granted.has(scope)) +} + +export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter = { + provider: PROVIDER, + requiresRefreshToken: false, + async getPolicy(option, context) { + if (!context.credentialGroupId) { + throw new CredentialGroupProviderConfigurationError('Credential Group context is required') + } + const slackBotCredentialId = option?.slackBotCredentialId + return getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + ...(slackBotCredentialId ? { slackBotCredentialId } : {}), + ...(context.executor ? { executor: context.executor } : {}), + }) + }, + async prepareAuthorization(context, policy) { + const currentPolicy = await getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: context.option.slackBotCredentialId, + }) + if (currentPolicy.authorizationAppId !== policy.authorizationAppId) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + return { + redirectUri, + buildAuthorizationUrl: ({ state }) => { + const authorizationUrl = new URL('https://slack.com/oauth/v2/authorize') + authorizationUrl.searchParams.set('client_id', currentPolicy.clientId) + authorizationUrl.searchParams.set('user_scope', policy.requiredScopes.join(',')) + authorizationUrl.searchParams.set('redirect_uri', redirectUri) + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('team', currentPolicy.teamId) + return authorizationUrl.toString() + }, + } + }, + async exchangeAndVerify({ context, attempt, code, policy }) { + const currentPolicy = await getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: context.option.slackBotCredentialId, + }) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + if ( + currentPolicy.authorizationAppId !== policy.authorizationAppId || + attempt.redirectUri !== redirectUri + ) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + + let grant: Awaited> + try { + grant = await exchangeSlackUserAuthorization({ + clientId: currentPolicy.clientId, + clientSecret: currentPolicy.clientSecret, + code, + redirectUri: attempt.redirectUri, + }) + } catch { + throw new CredentialGroupOAuthError( + 'Slack could not complete authorization. Please try again.', + 502 + ) + } + + if (grant.expiresIn !== undefined || grant.refreshToken) { + await Promise.allSettled( + [grant.accessToken, grant.refreshToken] + .filter((token): token is string => Boolean(token)) + .map(revokeSlackToken) + ) + throw new CredentialGroupOAuthError( + 'Slack token rotation was enabled after this app was configured. Disable it and try again.', + 409 + ) + } + + try { + if ( + grant.appId !== currentPolicy.appId || + grant.teamId !== currentPolicy.teamId || + !hasRequiredSlackScopes(grant.scopes, policy.requiredScopes) + ) { + throw new CredentialGroupOAuthError( + 'All requested Slack permissions are required to connect this account.', + 403 + ) + } + const identity = await verifySlackUserIdentity({ + accessToken: grant.accessToken, + expectedTeamId: grant.teamId, + expectedUserId: grant.userId, + }) + const email = normalizeEmail(identity.email) + if (email !== context.email) { + throw new CredentialGroupOAuthError( + `Sign in with ${context.email} to complete this invitation.`, + 403 + ) + } + + return { + providerId: policy.providerId, + providerSubjectId: identity.userId, + providerTenantId: identity.teamId, + displayName: email, + metadata: { + email, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + ...(identity.username ? { username: identity.username } : {}), + }, + accessToken: grant.accessToken, + grantedScopes: [...new Set(grant.scopes)], + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + } + } catch (error) { + await revokeSlackToken(grant.accessToken) + if (error instanceof CredentialGroupOAuthError) throw error + throw new CredentialGroupOAuthError('Slack could not verify the granted access.', 502) + } + }, + hasRequiredScopes: hasRequiredSlackScopes, + async refreshToken() { + throw new Error('Slack managed credentials do not use token refresh') + }, + isTerminalRefreshError() { + return false + }, +} diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts new file mode 100644 index 00000000000..4fc36880347 --- /dev/null +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { createHash } from 'node:crypto' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' + +const { mockGetToken, mockVerifyIdentity } = vi.hoisted(() => ({ + mockGetToken: vi.fn(), + mockVerifyIdentity: vi.fn(), +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.example.com', +})) + +vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ + getManagedOAuthConnectorProviderConfig: (providerId: string) => + providerId === 'google-calendar' + ? { + providerId, + clientId: 'client-1', + clientSecret: 'secret-1', + authorizationUrl: 'https://accounts.example.com/authorize', + tokenUrl: 'https://accounts.example.com/token', + accessType: 'offline', + scopes: ['calendar.read', 'profile'], + getToken: mockGetToken, + managedOAuth: { + additionalScopes: ['openid'], + requiresRefreshToken: true, + pkce: true, + prompt: 'consent select_account', + authorizationUrlParams: { include_granted_scopes: 'false' }, + getAuthorizationAppId: (clientId: string) => `google:${clientId}`, + verifyIdentity: mockVerifyIdentity, + hasRequiredScopes: (granted: string[], required: string[]) => + required.every((scope) => granted.includes(scope)), + isTerminalRefreshError: (errorCode: string | undefined) => + errorCode === 'invalid_grant', + }, + } + : undefined, +})) + +import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider' + +const adapter = createStandardOAuthCredentialGroupProviderAdapter('google-calendar') + +function buildContext(): CredentialGroupOAuthContext { + return { + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + email: 'person@example.com', + enrollmentStatus: 'in_progress', + option: { + id: 'option-1', + provider: 'google-calendar', + label: 'Google Calendar', + authorizationAppId: 'google:client-1', + requiredScopes: ['calendar.read', 'profile', 'openid'], + scopeVersion: 1, + required: true, + status: 'active', + }, + options: [], + } +} + +function buildAttempt(scopeVersion: number): CredentialGroupOAuthAttempt { + return { + state: 'state-1', + provider: 'google-calendar', + nonceHash: createHash('sha256').update('nonce-1').digest('hex'), + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:client-1', + scopeVersion, + requiredScopes: ['calendar.read', 'profile', 'openid'], + redirectUri: 'https://sim.example.com/api/credential-groups/oauth/google-calendar/callback', + codeVerifier: 'verifier-1', + invitationToken: 'invitation-1', + createdAt: Date.now(), + } +} + +describe('standard OAuth Credential Group provider', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetToken.mockResolvedValue({ + tokenType: 'Bearer', + accessToken: 'access-1', + refreshToken: 'refresh-1', + accessTokenExpiresAt: new Date('2026-08-14T01:00:00Z'), + }) + mockVerifyIdentity.mockResolvedValue({ + providerSubjectId: 'google-sub-1', + providerTenantId: 'example.com', + email: 'person@example.com', + emailVerified: true, + displayName: 'Person', + avatarUrl: 'https://example.com/avatar.png', + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + }) + + it('builds authorization from the existing connector configuration', async () => { + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const prepared = await adapter.prepareAuthorization(context, policy) + const authorizationUrl = new URL( + await prepared.buildAuthorizationUrl({ state: 'state-1', nonce: 'nonce-1' }) + ) + + expect(policy).toMatchObject({ + provider: 'google-calendar', + providerId: 'google-calendar', + authorizationAppId: 'google:client-1', + requiredScopes: ['calendar.read', 'profile', 'openid'], + }) + expect(prepared.codeVerifier).toHaveLength(86) + expect(authorizationUrl.origin).toBe('https://accounts.example.com') + expect(authorizationUrl.searchParams.get('client_id')).toBe('client-1') + expect(authorizationUrl.searchParams.get('state')).toBe('state-1') + expect(authorizationUrl.searchParams.get('nonce')).toBe('nonce-1') + expect(authorizationUrl.searchParams.get('login_hint')).toBe('person@example.com') + expect(authorizationUrl.searchParams.get('include_granted_scopes')).toBe('false') + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') + }) + + it('persists a verified provider identity and returned scopes', async () => { + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const grant = await adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + + expect(mockGetToken).toHaveBeenCalledWith({ + code: 'code-1', + redirectURI: 'https://sim.example.com/api/credential-groups/oauth/google-calendar/callback', + codeVerifier: 'verifier-1', + }) + expect(grant).toMatchObject({ + providerId: 'google-calendar', + providerSubjectId: 'google-sub-1', + providerTenantId: 'example.com', + displayName: 'person@example.com', + accessToken: 'access-1', + refreshToken: 'refresh-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + metadata: { + email: 'person@example.com', + displayName: 'Person', + avatarUrl: 'https://example.com/avatar.png', + }, + }) + }) + + it('rejects a different invited email', async () => { + mockVerifyIdentity.mockResolvedValueOnce({ + providerSubjectId: 'google-sub-2', + providerTenantId: null, + email: 'other@example.com', + emailVerified: true, + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).rejects.toMatchObject({ statusCode: 403 }) + }) +}) diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts new file mode 100644 index 00000000000..dcbd3bea2a9 --- /dev/null +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -0,0 +1,353 @@ +import { randomBytes } from 'node:crypto' +import { + applyDefaultAccessTokenExpiry, + createAuthorizationURL, + type OAuth2Tokens, + validateAuthorizationCode, +} from '@better-auth/core/oauth2' +import { normalizeEmail } from '@sim/utils/string' +import { + type ConnectorProviderConfig, + getManagedOAuthConnectorProviderConfig, +} from '@/lib/auth/connectors/managed-oauth' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialGroupOAuthNonceMatches } from '@/lib/credential-groups/oauth-state' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupOAuthError, + CredentialGroupProviderConfigurationError, + credentialGroupScopePolicyVersion, +} from '@/lib/credential-groups/provider-adapter' +import type { CredentialGroupStandardOAuthProvider } from '@/lib/credential-groups/providers' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { refreshOAuthToken } from '@/lib/oauth' + +const OAUTH_DISCOVERY_TIMEOUT_MS = 10_000 +const OAUTH_DISCOVERY_MAX_BYTES = 256 * 1024 + +interface OAuthEndpoints { + authorizationEndpoint: string + tokenEndpoint: string +} + +interface CurrentStandardOAuthProvider { + connector: ConnectorProviderConfig + policy: CredentialGroupProviderPolicy +} + +function staticParams( + value: ConnectorProviderConfig['authorizationUrlParams'], + label: string +): Record { + if (typeof value === 'function') { + throw new CredentialGroupProviderConfigurationError( + `${label} cannot depend on an authenticated Sim request` + ) + } + return value ?? {} +} + +async function resolveOAuthEndpoints( + connector: ConnectorProviderConfig, + providerName: string +): Promise { + if (connector.discoveryUrl) { + let response: Response + try { + response = await fetch(connector.discoveryUrl, { + headers: connector.discoveryHeaders, + signal: AbortSignal.timeout(OAUTH_DISCOVERY_TIMEOUT_MS), + }) + } catch { + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}) + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + let document: unknown + try { + document = await readResponseJsonWithLimit(response, { + maxBytes: OAUTH_DISCOVERY_MAX_BYTES, + label: `${providerName} OAuth discovery response`, + }) + } catch { + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + if (!document || typeof document !== 'object') { + throw new CredentialGroupOAuthError(`${providerName} OAuth configuration is invalid.`, 503) + } + const discovery = document as Record + if ( + typeof discovery.authorization_endpoint !== 'string' || + typeof discovery.token_endpoint !== 'string' + ) { + throw new CredentialGroupOAuthError(`${providerName} OAuth configuration is invalid.`, 503) + } + return { + authorizationEndpoint: discovery.authorization_endpoint, + tokenEndpoint: discovery.token_endpoint, + } + } + + if (!connector.authorizationUrl || !connector.tokenUrl) { + throw new CredentialGroupProviderConfigurationError( + `${providerName} OAuth endpoints are not configured` + ) + } + return { + authorizationEndpoint: connector.authorizationUrl, + tokenEndpoint: connector.tokenUrl, + } +} + +function getCurrentProvider( + provider: CredentialGroupStandardOAuthProvider +): CurrentStandardOAuthProvider { + const service = getCredentialGroupProviderService(provider) + const connector = getManagedOAuthConnectorProviderConfig(service.providerId) + if (!connector) { + throw new CredentialGroupProviderConfigurationError( + `Managed ${service.name} authorization is not configured` + ) + } + const requiredScopes = [ + ...new Set([...(connector.scopes ?? []), ...connector.managedOAuth.additionalScopes]), + ] + if (requiredScopes.length === 0) { + throw new CredentialGroupProviderConfigurationError( + `Managed ${service.name} authorization has no scope policy` + ) + } + return { + connector, + policy: { + provider, + providerId: service.providerId, + authorizationAppId: connector.managedOAuth.getAuthorizationAppId(connector.clientId), + requiredScopes, + scopeVersion: credentialGroupScopePolicyVersion(requiredScopes), + }, + } +} + +function assertCurrentPolicy( + expected: CredentialGroupProviderPolicy, + current: CredentialGroupProviderPolicy +): void { + if ( + expected.provider !== current.provider || + expected.providerId !== current.providerId || + expected.authorizationAppId !== current.authorizationAppId || + expected.scopeVersion !== current.scopeVersion + ) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } +} + +function generatePkceVerifier(): string { + return randomBytes(64).toString('base64url') +} + +async function exchangeAuthorizationCode(params: { + connector: ConnectorProviderConfig + code: string + codeVerifier?: string + redirectUri: string + tokenEndpoint: string +}): Promise { + const { connector, code, codeVerifier, redirectUri, tokenEndpoint } = params + const tokens = connector.getToken + ? await connector.getToken({ code, redirectURI: redirectUri, codeVerifier }) + : await validateAuthorizationCode({ + headers: connector.authorizationHeaders, + code, + codeVerifier, + redirectURI: redirectUri, + options: { + clientId: connector.clientId, + clientSecret: connector.clientSecret, + redirectURI: redirectUri, + }, + tokenEndpoint, + authentication: connector.authentication, + additionalParams: staticParams(connector.tokenUrlParams, 'OAuth token parameters'), + }) + return applyDefaultAccessTokenExpiry(tokens, connector.accessTokenExpiresIn) +} + +/** + * Reuses the native connector's OAuth client, endpoints, scopes, and exchange hooks while + * persisting the result through public enrollment instead of a signed-in Sim account. + */ +export function createStandardOAuthCredentialGroupProviderAdapter( + provider: CredentialGroupStandardOAuthProvider +): CredentialGroupProviderAdapter { + return { + provider, + get requiresRefreshToken() { + return getCurrentProvider(provider).connector.managedOAuth.requiresRefreshToken + }, + async getPolicy() { + return getCurrentProvider(provider).policy + }, + async prepareAuthorization(context, policy) { + const current = getCurrentProvider(provider) + assertCurrentPolicy(policy, current.policy) + const managed = current.connector.managedOAuth + const endpoints = await resolveOAuthEndpoints( + current.connector, + getCredentialGroupProviderService(provider).name + ) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${provider}/callback` + const codeVerifier = managed.pkce ? generatePkceVerifier() : undefined + return { + redirectUri, + ...(codeVerifier ? { codeVerifier } : {}), + buildAuthorizationUrl: async ({ state, nonce }) => { + const authorizationUrl = await createAuthorizationURL({ + id: current.connector.providerId, + options: { + clientId: current.connector.clientId, + clientSecret: current.connector.clientSecret, + redirectURI: redirectUri, + }, + authorizationEndpoint: endpoints.authorizationEndpoint, + state, + ...(codeVerifier ? { codeVerifier } : {}), + scopes: policy.requiredScopes, + redirectURI: redirectUri, + prompt: managed.prompt ?? current.connector.prompt, + accessType: current.connector.accessType, + responseType: current.connector.responseType, + responseMode: current.connector.responseMode, + loginHint: context.email, + additionalParams: { + ...staticParams( + current.connector.authorizationUrlParams, + 'OAuth authorization parameters' + ), + ...managed.authorizationUrlParams, + nonce, + }, + }) + return authorizationUrl.toString() + }, + } + }, + async exchangeAndVerify({ context, attempt, code, policy }) { + const current = getCurrentProvider(provider) + assertCurrentPolicy(policy, current.policy) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${provider}/callback` + if (attempt.redirectUri !== redirectUri) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const managed = current.connector.managedOAuth + if (managed.pkce && !attempt.codeVerifier) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const service = getCredentialGroupProviderService(provider) + const endpoints = await resolveOAuthEndpoints(current.connector, service.name) + let tokens: OAuth2Tokens + try { + tokens = await exchangeAuthorizationCode({ + connector: current.connector, + code, + ...(attempt.codeVerifier ? { codeVerifier: attempt.codeVerifier } : {}), + redirectUri: attempt.redirectUri, + tokenEndpoint: endpoints.tokenEndpoint, + }) + } catch { + throw new CredentialGroupOAuthError( + `${service.name} could not complete authorization. Please try again.`, + 502 + ) + } + if (tokens.tokenType !== 'Bearer' || !tokens.accessToken) { + throw new CredentialGroupOAuthError( + `${service.name} returned an incomplete authorization.`, + 502 + ) + } + let identity: Awaited> + try { + identity = await managed.verifyIdentity({ + tokens, + clientId: current.connector.clientId, + }) + } catch { + throw new CredentialGroupOAuthError( + `${service.name} returned an invalid identity token.`, + 502 + ) + } + if ( + !identity.emailVerified || + !identity.nonce || + !credentialGroupOAuthNonceMatches(identity.nonce, attempt.nonceHash) + ) { + throw new CredentialGroupOAuthError( + `${service.name} returned an invalid identity token.`, + 502 + ) + } + const email = normalizeEmail(identity.email) + if (email !== context.email) { + throw new CredentialGroupOAuthError( + `Sign in with ${context.email} to complete this invitation.`, + 403 + ) + } + if (!managed.hasRequiredScopes(identity.grantedScopes, policy.requiredScopes)) { + throw new CredentialGroupOAuthError( + `All requested ${service.name} permissions are required to connect this account.`, + 403 + ) + } + return { + providerId: policy.providerId, + providerSubjectId: identity.providerSubjectId, + providerTenantId: identity.providerTenantId, + displayName: email, + metadata: { + email, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + }, + accessToken: tokens.accessToken, + ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), + grantedScopes: identity.grantedScopes, + accessTokenExpiresAt: tokens.accessTokenExpiresAt ?? null, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt ?? null, + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + return getCurrentProvider(provider).connector.managedOAuth.hasRequiredScopes( + grantedScopes, + requiredScopes + ) + }, + async refreshToken(refreshToken) { + return refreshOAuthToken(getCurrentProvider(provider).policy.providerId, refreshToken) + }, + isTerminalRefreshError(errorCode) { + return getCurrentProvider(provider).connector.managedOAuth.isTerminalRefreshError(errorCode) + }, + } +} diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts new file mode 100644 index 00000000000..a9c39dc5ebe --- /dev/null +++ b/apps/sim/lib/credential-groups/types.ts @@ -0,0 +1,95 @@ +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' + +interface CredentialGroupOptionInputBase { + label: string + required: boolean +} + +export type CredentialGroupOptionInput = + | (CredentialGroupOptionInputBase & { + provider: Exclude + }) + | (CredentialGroupOptionInputBase & { + provider: 'slack' + slackBotCredentialId: string + }) + +export type CredentialGroupOptionUpdateInput = CredentialGroupOptionInput & { id?: string } + +export interface CreateCredentialGroupInput { + name: string + description?: string + options: CredentialGroupOptionInput[] +} + +export interface UpdateCredentialGroupInput { + name?: string + description?: string | null + options?: CredentialGroupOptionUpdateInput[] + status?: 'active' | 'disabled' +} + +interface CredentialGroupOptionBase { + id: string + label: string + required: boolean + status: 'active' | 'disabled' +} + +export type CredentialGroupOption = + | (CredentialGroupOptionBase & { + provider: Exclude + configurationStatus: 'ready' + }) + | (CredentialGroupOptionBase & { + provider: 'slack' + slackBotCredentialId: string + configurationStatus: 'not_configured' | 'ready' | 'needs_update' + }) + +export interface CredentialGroupRecord { + id: string + workspaceId: string + name: string + description: string | null + options: CredentialGroupOption[] + status: 'active' | 'disabled' + createdAt: string + updatedAt: string +} + +export type CredentialGroupEnrollmentStatus = + | 'invited' + | 'delivery_failed' + | 'in_progress' + | 'completed' + | 'revoked' + +export interface CredentialGroupEnrollmentRecord { + id: string + credentialGroupId: string + email: string + status: CredentialGroupEnrollmentStatus + expiresAt: string + invitedAt: string + sentAt: string | null + completedAt: string | null + revokedAt: string | null + expired: boolean + createdAt: string + updatedAt: string +} + +export interface CredentialGroupEnrollmentConnection { + provider: CredentialGroupProvider + status: 'active' | 'needs_reauth' | 'revoked' + count: number +} + +export interface CredentialGroupEnrollmentDetail extends CredentialGroupEnrollmentRecord { + connections: CredentialGroupEnrollmentConnection[] +} + +export interface InviteCredentialGroupEnrollmentsInput { + emails: string[] +} diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts new file mode 100644 index 00000000000..7038b7f2eb3 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -0,0 +1,13 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' +import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' + +export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' + +export const managedOAuthCredentialDelegationPolicy = { + audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: ManagedOAuthCredentialApplicationContext + ) => principal.resourceScope?.credentialId === context.credentialId, +} satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credentials/application/managed-oauth-delegation.ts b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts new file mode 100644 index 00000000000..95c85426c0d --- /dev/null +++ b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts @@ -0,0 +1,41 @@ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { MANAGED_OAUTH_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' + +export class InvalidManagedOAuthDelegationError extends Error { + constructor() { + super('Managed credential execution requires valid workflow delegation') + this.name = 'InvalidManagedOAuthDelegationError' + } +} + +/** Authenticates and binds an executor delegation to one managed credential ID. */ +export async function authenticateManagedOAuthDelegation( + authorization: string, + credentialId: string +): Promise { + if (!authorization.startsWith('Bearer ')) throw new InvalidManagedOAuthDelegationError() + + try { + const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + return await bindInternalExecutorDelegation(claims, { + audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, + resourceScope: { credentialId }, + }) + } catch (error) { + if ( + error instanceof InvalidInternalDelegationTokenError || + error instanceof InvalidInternalDelegationBindingError + ) { + throw new InvalidManagedOAuthDelegationError() + } + throw error + } +} diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4a3dcde7c11..3f1fad25074 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -7,4 +7,11 @@ export const credentialOperations = { workspaceApiKey: 'allow', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + useManagedOAuth: defineWorkspaceOperation({ + id: 'credentials.managed_oauth.use', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), } as const diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts new file mode 100644 index 00000000000..0a3e2b24b73 --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + resolvePermission: vi.fn(), + resolveToken: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/credentials/managed-oauth', () => ({ + loadManagedOAuthCredentialApplicationContext: mocks.loadContext, + resolveManagedOAuthToken: mocks.resolveToken, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { CREDENTIAL_ACCESSED: 'credential.accessed' }, + AuditResourceType: { CREDENTIAL: 'credential' }, + recordAudit: mocks.recordAudit, +})) + +import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' + +const context = { + credentialId: 'credential-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, +} + +const input = { + credentialId: 'credential-1', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + toolId: 'gmail_read', +} + +function executorPrincipal(credentialId = 'credential-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + } +} + +describe('resolveManagedOAuthCredentialToken', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveToken.mockResolvedValue({ accessToken: 'access-token', refreshed: false }) + }) + + it('rejects unsupported principals before loading the credential', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + resolveManagedOAuthCredentialToken.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadContext).not.toHaveBeenCalled() + }) + + it('rejects a delegation scoped to another credential', async () => { + await expect( + resolveManagedOAuthCredentialToken.execute({ + principal: executorPrincipal('credential-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.resolveToken).not.toHaveBeenCalled() + }) + + it('resolves the token only after current workspace authorization', async () => { + const result = await resolveManagedOAuthCredentialToken.execute({ + principal: executorPrincipal(), + input, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.resolveToken).toHaveBeenCalledWith({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }) + expect(result).toEqual({ accessToken: 'access-token', refreshed: false }) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts new file mode 100644 index 00000000000..3213f8731f8 --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts @@ -0,0 +1,47 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { managedOAuthCredentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedOAuthCredentialApplicationContext, + type ResolvedManagedOAuthToken, + resolveManagedOAuthToken, +} from '@/lib/credentials/managed-oauth' + +export interface ResolveManagedOAuthTokenInput { + credentialId: string + expectedProviderId: string + requiredScopes: string[] + toolId: string +} + +export const resolveManagedOAuthCredentialToken = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedOAuth, + resolveContext: async ({ input }: { input: ResolveManagedOAuthTokenInput }) => { + const context = await loadManagedOAuthCredentialApplicationContext(input.credentialId) + if (!context) throw new OrchestrationError('not_found', 'Managed credential not found') + return context + }, + authorizationOptions: { delegation: managedOAuthCredentialDelegationPolicy }, + execute: async ({ input, context }): Promise => + resolveManagedOAuthToken({ + credentialId: context.credentialId, + workspaceId: context.workspaceId, + expectedProviderId: input.expectedProviderId, + requiredScopes: input.requiredScopes, + }), + projectAudit({ input, context }) { + return { + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: `Accessed managed OAuth credential for provider ${input.expectedProviderId}`, + metadata: { + provider: input.expectedProviderId, + credentialType: 'managed_oauth', + toolId: input.toolId, + }, + } + }, +}) diff --git a/apps/sim/lib/credentials/client-state.ts b/apps/sim/lib/credentials/client-state.ts index 70d52d95fa2..c1e6e3b6fee 100644 --- a/apps/sim/lib/credentials/client-state.ts +++ b/apps/sim/lib/credentials/client-state.ts @@ -4,15 +4,6 @@ export const PENDING_OAUTH_CREDENTIAL_DRAFT_KEY = 'sim.pending-oauth-credential- export const PENDING_CREDENTIAL_CREATE_REQUEST_KEY = 'sim.pending-credential-create-request' export const PENDING_CREDENTIAL_CREATE_REQUEST_EVENT = 'sim:pending-credential-create-request' -interface PendingOAuthCredentialDraft { - workspaceId: string - providerId: string - displayName: string - existingCredentialIds: string[] - existingAccountIds: string[] - requestedAt: number -} - export interface PendingCredentialCreateRequest { workspaceId: string type: 'env_personal' | 'env_workspace' @@ -29,23 +20,6 @@ function parseJson(raw: string | null): T | null { } } -export function readPendingOAuthCredentialDraft(): PendingOAuthCredentialDraft | null { - if (typeof window === 'undefined') return null - return parseJson( - window.sessionStorage.getItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY) - ) -} - -export function writePendingOAuthCredentialDraft(payload: PendingOAuthCredentialDraft) { - if (typeof window === 'undefined') return - window.sessionStorage.setItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY, JSON.stringify(payload)) -} - -export function clearPendingOAuthCredentialDraft() { - if (typeof window === 'undefined') return - window.sessionStorage.removeItem(PENDING_OAUTH_CREDENTIAL_DRAFT_KEY) -} - export function readPendingCredentialCreateRequest(): PendingCredentialCreateRequest | null { if (typeof window === 'undefined') return null return parseJson( diff --git a/apps/sim/lib/credentials/managed-oauth.test.ts b/apps/sim/lib/credentials/managed-oauth.test.ts new file mode 100644 index 00000000000..1df5b3543e7 --- /dev/null +++ b/apps/sim/lib/credentials/managed-oauth.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBilling: vi.fn(), + isAvailable: vi.fn(), + getAdapter: vi.fn(), + decryptSecret: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getBilling, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + isCredentialGroupsAvailable: mocks.isAvailable, +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapterByProviderId: mocks.getAdapter, +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mocks.decryptSecret, + encryptSecret: vi.fn(), +})) + +import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' + +describe('managed OAuth token resolution', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getBilling.mockResolvedValue({ plan: 'enterprise' }) + mocks.isAvailable.mockResolvedValue(true) + mocks.decryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-oauth-token-set', + version: 1, + tokenType: 'Bearer', + accessToken: 'xoxp-slack-token', + }), + }) + mocks.getAdapter.mockReturnValue({ + getPolicy: vi.fn().mockResolvedValue({ + authorizationAppId: 'slack:A123:T123', + scopeVersion: 1, + }), + hasRequiredScopes: vi.fn().mockReturnValue(true), + }) + }) + + it('uses a non-expiring Slack access token without entering refresh', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'managed_oauth', + providerId: 'slack', + authorizationAppId: 'slack:A123:T123', + managedOauthScopeVersion: 1, + managedOauthStatus: 'active', + grantedScopes: ['chat:write'], + encryptedOauthTokenSet: 'encrypted-token-set', + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }, + ]) + + await expect( + resolveManagedOAuthToken({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + expectedProviderId: 'slack', + requiredScopes: ['chat:write'], + }) + ).resolves.toEqual({ accessToken: 'xoxp-slack-token', refreshed: false }) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts new file mode 100644 index 00000000000..196711eab8b --- /dev/null +++ b/apps/sim/lib/credentials/managed-oauth.ts @@ -0,0 +1,441 @@ +import { db } from '@sim/db' +import { credential, credentialGroupEnrollment } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, sql } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { + type CredentialGroupProviderAdapter, + CredentialGroupProviderConfigurationError, + type CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { getCredentialGroupProviderAdapterByProviderId } from '@/lib/credential-groups/provider-registry' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('ManagedOAuthCredential') + +const MANAGED_OAUTH_TOKEN_SET_TYPE = 'managed-oauth-token-set' as const +const MANAGED_OAUTH_TOKEN_SET_VERSION = 1 as const +const ACCESS_TOKEN_REFRESH_WINDOW_MS = 30_000 + +export type ManagedOAuthCredentialErrorCode = + | 'MANAGED_CREDENTIAL_NOT_FOUND' + | 'MANAGED_CREDENTIAL_UNAVAILABLE' + | 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH' + | 'MANAGED_CREDENTIAL_REVOKED' + | 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + | 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE' + | 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET' + | 'MANAGED_CREDENTIAL_REFRESH_FAILED' + +export class ManagedOAuthCredentialError extends Error { + constructor( + readonly code: ManagedOAuthCredentialErrorCode, + message: string, + readonly statusCode: 401 | 403 | 404 | 500 | 502 | 503 + ) { + super(message) + this.name = 'ManagedOAuthCredentialError' + } +} + +export interface ManagedOAuthTokenSet { + type: typeof MANAGED_OAUTH_TOKEN_SET_TYPE + version: typeof MANAGED_OAUTH_TOKEN_SET_VERSION + tokenType: 'Bearer' + accessToken: string + refreshToken?: string + idToken?: string +} + +export interface ResolvedManagedOAuthToken { + accessToken: string + idToken?: string + refreshed: boolean +} + +type DbOrTx = typeof db | Parameters[0]>[0] + +interface ResolveManagedOAuthTokenParams { + credentialId: string + workspaceId: string + expectedProviderId: string + requiredScopes: string[] +} + +export interface ManagedOAuthCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialId: string +} + +function isManagedOAuthTokenSet(value: unknown): value is ManagedOAuthTokenSet { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.type === MANAGED_OAUTH_TOKEN_SET_TYPE && + candidate.version === MANAGED_OAUTH_TOKEN_SET_VERSION && + candidate.tokenType === 'Bearer' && + typeof candidate.accessToken === 'string' && + candidate.accessToken.length > 0 && + (candidate.refreshToken === undefined || + (typeof candidate.refreshToken === 'string' && candidate.refreshToken.length > 0)) && + (candidate.idToken === undefined || + (typeof candidate.idToken === 'string' && candidate.idToken.length > 0)) + ) +} + +/** Encrypts the versioned token envelope written by managed OAuth callbacks and refreshes. */ +export async function encryptManagedOAuthTokenSet(tokenSet: { + accessToken: string + refreshToken?: string + idToken?: string +}): Promise { + const accessToken = tokenSet.accessToken.trim() + const refreshToken = tokenSet.refreshToken?.trim() + const idToken = tokenSet.idToken?.trim() + if (!accessToken) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed OAuth access token is empty', + 500 + ) + } + + const envelope: ManagedOAuthTokenSet = { + type: MANAGED_OAUTH_TOKEN_SET_TYPE, + version: MANAGED_OAUTH_TOKEN_SET_VERSION, + tokenType: 'Bearer', + accessToken, + ...(refreshToken ? { refreshToken } : {}), + ...(idToken ? { idToken } : {}), + } + return (await encryptSecret(JSON.stringify(envelope))).encrypted +} + +/** Decrypts and strictly validates a managed OAuth token envelope. */ +export async function decryptManagedOAuthTokenSet( + encryptedTokenSet: string +): Promise { + try { + const { decrypted } = await decryptSecret(encryptedTokenSet) + const parsed: unknown = JSON.parse(decrypted) + if (!isManagedOAuthTokenSet(parsed)) throw new Error('Invalid managed OAuth token envelope') + return parsed + } catch (error) { + logger.error('Failed to decrypt managed OAuth token set', { + error: getErrorMessage(error), + }) + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential token data is invalid', + 500 + ) + } +} + +async function getManagedCredential(exec: DbOrTx, credentialId: string, workspaceId?: string) { + const [row] = await exec + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + authorizationAppId: credential.authorizationAppId, + managedOauthScopeVersion: credential.managedOauthScopeVersion, + managedOauthStatus: credential.managedOauthStatus, + grantedScopes: credential.grantedScopes, + encryptedOauthTokenSet: credential.encryptedOauthTokenSet, + accessTokenExpiresAt: credential.accessTokenExpiresAt, + refreshTokenExpiresAt: credential.refreshTokenExpiresAt, + credentialGroupId: credentialGroupEnrollment.credentialGroupId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_oauth'), + workspaceId ? eq(credential.workspaceId, workspaceId) : undefined + ) + ) + .limit(1) + return row ?? null +} + +/** Resolves the canonical workspace context for authorization without exposing token material. */ +export async function loadManagedOAuthCredentialApplicationContext( + credentialId: string +): Promise { + const row = await getManagedCredential(db, credentialId) + if (!row) return null + + const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) + if (!workspaceContext) return null + return { ...workspaceContext, credentialId: row.id } +} + +async function assertManagedCredentialUsable( + row: NonNullable>>, + expectedProviderId: string, + requiredScopes: string[] +): Promise { + if (row.providerId !== expectedProviderId) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH', + 'Managed credential belongs to a different provider', + 403 + ) + } + if (row.managedOauthStatus === 'revoked') { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_REVOKED', + 'Managed credential has been revoked', + 401 + ) + } + if (row.managedOauthStatus !== 'active') { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential needs to be authorized again', + 401 + ) + } + if (!row.authorizationAppId || !row.encryptedOauthTokenSet || !row.grantedScopes?.length) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential metadata is incomplete', + 500 + ) + } + + const adapter = getCredentialGroupProviderAdapterByProviderId(row.providerId) + let policy: CredentialGroupProviderPolicy + try { + policy = await adapter.getPolicy(undefined, { + workspaceId: row.workspaceId, + credentialGroupId: row.credentialGroupId, + authorizationAppId: row.authorizationAppId, + }) + } catch (error) { + if (!(error instanceof CredentialGroupProviderConfigurationError)) throw error + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential authorization app is unavailable', + 401 + ) + } + if ( + row.authorizationAppId !== policy.authorizationAppId || + row.managedOauthScopeVersion !== policy.scopeVersion + ) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential was authorized with a different OAuth app', + 401 + ) + } + + if (!adapter.hasRequiredScopes(row.grantedScopes, requiredScopes)) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE', + 'Managed credential is missing one or more required scopes', + 403 + ) + } + return adapter +} + +function hasFreshAccessToken(accessTokenExpiresAt: Date | null, now: Date): boolean { + return ( + accessTokenExpiresAt === null || + accessTokenExpiresAt.getTime() > now.getTime() + ACCESS_TOKEN_REFRESH_WINDOW_MS + ) +} + +async function markManagedCredentialNeedsReauth( + exec: DbOrTx, + credentialId: string, + updatedAt: Date +): Promise { + await exec + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt }) + .where(and(eq(credential.id, credentialId), eq(credential.managedOauthStatus, 'active'))) +} + +/** Resolves a managed credential ID into a usable token without exposing it to list APIs. */ +export async function resolveManagedOAuthToken( + params: ResolveManagedOAuthTokenParams +): Promise { + const initial = await getManagedCredential(db, params.credentialId, params.workspaceId) + if (!initial) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NOT_FOUND', + 'Managed credential not found', + 404 + ) + } + + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(initial.workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_UNAVAILABLE', + 'Managed credentials are not available for this workspace', + 403 + ) + } + + try { + await assertManagedCredentialUsable(initial, params.expectedProviderId, params.requiredScopes) + } catch (error) { + if ( + error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + ) { + await markManagedCredentialNeedsReauth(db, initial.id, new Date()) + } + throw error + } + const initialTokenSet = await decryptManagedOAuthTokenSet(initial.encryptedOauthTokenSet!) + const now = new Date() + if (hasFreshAccessToken(initial.accessTokenExpiresAt, now)) { + return { + accessToken: initialTokenSet.accessToken, + ...(initialTokenSet.idToken ? { idToken: initialTokenSet.idToken } : {}), + refreshed: false, + } + } + + const refreshOutcome = await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`managed-oauth:${params.credentialId}`}, 0))` + ) + const current = await getManagedCredential(tx, params.credentialId, params.workspaceId) + if (!current) { + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NOT_FOUND', + 'Managed credential not found', + 404 + ), + } + } + + let adapter: CredentialGroupProviderAdapter + try { + adapter = await assertManagedCredentialUsable( + current, + params.expectedProviderId, + params.requiredScopes + ) + } catch (error) { + if ( + error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + ) { + await markManagedCredentialNeedsReauth(tx, current.id, new Date()) + } + return { + error: + error instanceof ManagedOAuthCredentialError + ? error + : new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential metadata is invalid', + 500 + ), + } + } + + const currentTokenSet = await decryptManagedOAuthTokenSet(current.encryptedOauthTokenSet!) + const lockedAt = new Date() + if (hasFreshAccessToken(current.accessTokenExpiresAt, lockedAt)) { + return { + token: { + accessToken: currentTokenSet.accessToken, + ...(currentTokenSet.idToken ? { idToken: currentTokenSet.idToken } : {}), + refreshed: false, + }, + } + } + + if ( + !currentTokenSet.refreshToken || + (current.refreshTokenExpiresAt && current.refreshTokenExpiresAt <= lockedAt) + ) { + await markManagedCredentialNeedsReauth(tx, current.id, lockedAt) + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential needs to be authorized again', + 401 + ), + } + } + + const refreshed = await adapter.refreshToken(currentTokenSet.refreshToken) + if (!refreshed.ok) { + const terminal = adapter.isTerminalRefreshError(refreshed.errorCode) + if (terminal) { + await markManagedCredentialNeedsReauth(tx, current.id, new Date()) + } + return { + error: new ManagedOAuthCredentialError( + terminal ? 'MANAGED_CREDENTIAL_NEEDS_REAUTH' : 'MANAGED_CREDENTIAL_REFRESH_FAILED', + terminal + ? 'Managed credential needs to be authorized again' + : 'Managed credential refresh failed', + terminal ? 401 : 502 + ), + } + } + + const encryptedOauthTokenSet = await encryptManagedOAuthTokenSet({ + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + idToken: currentTokenSet.idToken, + }) + const refreshedAt = new Date() + const accessTokenExpiresAt = new Date(refreshedAt.getTime() + refreshed.expiresIn * 1000) + const [updated] = await tx + .update(credential) + .set({ + encryptedOauthTokenSet, + accessTokenExpiresAt, + lastRefreshedAt: refreshedAt, + updatedAt: refreshedAt, + }) + .where(and(eq(credential.id, current.id), eq(credential.managedOauthStatus, 'active'))) + .returning({ id: credential.id }) + if (!updated) { + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential changed while its token was refreshing', + 401 + ), + } + } + + return { + token: { + accessToken: refreshed.accessToken, + ...(currentTokenSet.idToken ? { idToken: currentTokenSet.idToken } : {}), + refreshed: true, + }, + } + }) + + if ('error' in refreshOutcome && refreshOutcome.error) throw refreshOutcome.error + if ('token' in refreshOutcome && refreshOutcome.token) return refreshOutcome.token + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_REFRESH_FAILED', + 'Managed credential refresh returned no token', + 500 + ) +} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index f0dabc6795b..ff34fd0a244 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -1,11 +1,22 @@ import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { credential, environment, webhook, workspaceEnvironment } from '@sim/db/schema' +import { + credential, + credentialGroup, + environment, + webhook, + workspaceEnvironment, +} from '@sim/db/schema' 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 { decryptSecret } from '@/lib/core/security/encryption' +import { listSlackCredentialGroupConfigurationsForBot } from '@/lib/credential-groups/provider-configuration' +import { + SlackManagedUsersError, + verifySlackCustomBotAppIdentity, +} from '@/lib/credential-groups/slack-managed-users' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { @@ -169,6 +180,9 @@ export async function performUpdateCredential( 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, @@ -250,6 +264,41 @@ export async function performUpdateCredential( : null try { + const slackConfigurations = + providerId === SLACK_CUSTOM_BOT_PROVIDER_ID + ? await listSlackCredentialGroupConfigurationsForBot({ + workspaceId: access.credential.workspaceId, + slackBotCredentialId: access.credential.id, + }) + : [] + if (slackConfigurations.length > 0) { + if (!params.botToken) { + throw new ServiceAccountSecretError( + 'Bot token is required to reconnect a managed-user Slack app' + ) + } + try { + const identity = await verifySlackCustomBotAppIdentity(params.botToken) + if ( + slackConfigurations.some( + (configuration) => + identity.appId !== configuration.appId || identity.teamId !== configuration.teamId + ) + ) { + throw new ServiceAccountSecretError( + 'This bot token belongs to a different Slack app or workspace. Create a new custom bot credential for a different Slack app.' + ) + } + } catch (error) { + if (error instanceof ServiceAccountSecretError) throw error + if (error instanceof SlackManagedUsersError) { + throw new ServiceAccountSecretError(error.message) + } + throw new ServiceAccountSecretError( + 'Could not verify that the replacement bot token belongs to the configured Slack app' + ) + } + } const secret = await verifyAndBuildServiceAccountSecret(providerId, { signingSecret: params.signingSecret, botToken: params.botToken, @@ -387,6 +436,9 @@ export async function performDeleteCredential( 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, @@ -402,6 +454,31 @@ 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) { diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index cc5dfb3a368..e5ff19c7d1a 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -1,9 +1,62 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' -import { listWorkspacePrincipalCredentials } from '@/lib/credentials/queries' +import { + listVisibleWorkspaceCredentials, + listWorkspacePrincipalCredentials, +} from '@/lib/credentials/queries' + +describe('listVisibleWorkspaceCredentials', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('always excludes managed OAuth credentials from selector-backed listings', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + await listVisibleWorkspaceCredentials({ + workspaceId: 'workspace-1', + userId: 'user-1', + workspaceAccess: { canAdmin: true }, + }) + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) + + it('does not expose Credential Group configuration on a custom Slack bot', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + displayName: 'Support bot', + description: null, + providerId: 'slack-custom-bot', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + encryptedServiceAccountKey: 'encrypted', + memberRole: null, + }, + ]) + + const { data } = await listVisibleWorkspaceCredentials({ + workspaceId: 'workspace-1', + userId: 'user-1', + workspaceAccess: { canAdmin: true }, + }) + const [result] = data + + expect(result).not.toHaveProperty('managedOAuthConfigurationStatus') + expect(result).not.toHaveProperty('authorizationAppId') + expect(result).not.toHaveProperty('managedOauthScopeVersion') + }) +}) describe('listWorkspacePrincipalCredentials', () => { beforeEach(() => { diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index bacb9d2cda7..92122ebf37f 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import { type CursorKey, @@ -119,7 +119,10 @@ export async function listVisibleWorkspaceCredentials(params: { limit, } = params - const whereClauses = [eq(credential.workspaceId, workspaceId)] + const whereClauses = [ + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth'), + ] if (types?.length) whereClauses.push(inArray(credential.type, types)) if (providerId) whereClauses.push(eq(credential.providerId, providerId)) const ownedEnvSecretsClause = params.ownedEnvSecretsOnly @@ -261,22 +264,3 @@ 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)) - ) - .limit(1) - return row ?? null -} diff --git a/apps/sim/lib/execution/cancel-workflow-execution.test.ts b/apps/sim/lib/execution/cancel-workflow-execution.test.ts index 53621352a03..1f51da65463 100644 --- a/apps/sim/lib/execution/cancel-workflow-execution.test.ts +++ b/apps/sim/lib/execution/cancel-workflow-execution.test.ts @@ -17,6 +17,7 @@ const { mockPublishWorkflowGroupCancellationEvent, mockReleaseExecutionSlot, mockUpdateSet, + mockUpdateReturning, mockResolveWorkflowExecutionOwnership, } = vi.hoisted(() => ({ mockAbortManualExecution: vi.fn(), @@ -32,6 +33,7 @@ const { mockPublishWorkflowGroupCancellationEvent: vi.fn(), mockReleaseExecutionSlot: vi.fn(), mockUpdateSet: vi.fn(), + mockUpdateReturning: vi.fn(), mockResolveWorkflowExecutionOwnership: vi.fn(), })) @@ -40,7 +42,7 @@ vi.mock('@sim/db', () => ({ update: () => ({ set: (values: unknown) => { mockUpdateSet(values) - return { where: () => Promise.resolve(undefined) } + return { where: () => ({ returning: () => Promise.resolve(mockUpdateReturning()) }) } }, }), }, @@ -99,6 +101,16 @@ vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +/** + * The durable writes a workflow-group transition reports back. The transaction + * updates the workflow log only, the cell sidecar only, or both, so a single + * `kind` cannot answer whether this request wrote anything. + */ +const NO_WRITES = { workflowLogTerminalized: false, sidecarCancelled: false } as const +const LOG_WRITE = { workflowLogTerminalized: true, sidecarCancelled: false } as const +const SIDECAR_WRITE = { workflowLogTerminalized: false, sidecarCancelled: true } as const +const BOTH_WRITES = { workflowLogTerminalized: true, sidecarCancelled: true } as const + const INPUT = { executionId: 'execution-1', workflowId: 'workflow-1', @@ -112,7 +124,9 @@ describe('cancelWorkflowExecution', () => { mockResolveWorkflowExecutionOwnership.mockResolvedValue({ belongsToWorkflow: true, workflowGroupWorkspaceId: null, + priorStatus: 'running', }) + mockUpdateReturning.mockReturnValue([{ id: 'log-1' }]) mockBeginPausedCancellation.mockResolvedValue(false) mockGetPausedCancellationStatus.mockResolvedValue(null) mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' }) @@ -128,6 +142,147 @@ describe('cancelWorkflowExecution', () => { }) }) + /** + * The row reads `cancelled` after a successful cancel just as it does after + * someone else's, so a status re-read alone would report this run's own work + * as `already_cancelled`. Nothing is re-read once the claim moved a row. + */ + it('reports a durable write when an active run is cancelled', async () => { + mockResolveWorkflowExecutionOwnership + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: 'running', + }) + .mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: 'cancelled', + }) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' }) + expect(mockResolveWorkflowExecutionOwnership).toHaveBeenCalledTimes(1) + }) + + /** + * A cancel against a run that already reached a terminal state changes + * nothing: the log claim's `status = 'running'` predicate matches no row and + * no terminal metadata moves. Reporting `recorded`/`durablyRecorded: true` + * there tells a caller a durable write happened when none did, so the outcome + * names the state that was actually observed instead. + */ + it.each([ + ['cancelled', 'already_cancelled'], + ['completed', 'already_completed'], + ['failed', 'already_failed'], + ])('reports a run already %s as a no-op rather than a durable write', async (status, reason) => { + mockResolveWorkflowExecutionOwnership.mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: status, + }) + mockUpdateReturning.mockReturnValue([]) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: false, reason }) + }) + + /** + * Reclassification only ever applies to an otherwise-clean outcome. A run that + * reached any terminal status can still carry paused-HITL state — a + * force-failed run keeps whatever pause rows it had — and when reconciling + * that genuinely fails, the caller is owed the step that failed rather than a + * no-op that also flips `success` to `true`. + */ + it.each([['cancelled'], ['completed'], ['failed']])( + 'still reports the failing step when a %s run has paused work left over', + async (status) => { + mockResolveWorkflowExecutionOwnership.mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: status, + }) + mockBeginPausedCancellation.mockResolvedValue(true) + mockCompletePausedCancellation.mockResolvedValue(false) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ + success: false, + durablyRecorded: true, + reason: 'paused_database_cancel_failed', + }) + } + ) + + /** + * The status read at entry can be stale: a run that finishes after it and + * before the claim leaves a `running` snapshot on a cancel whose claim matched + * no row. The claim's own row count is what separates that from a cancel this + * request really performed. + */ + it.each([ + ['completed', 'already_completed'], + ['failed', 'already_failed'], + ])('reports a run that reached %s after the entry read as a no-op', async (status, reason) => { + mockResolveWorkflowExecutionOwnership + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: 'running', + }) + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: status, + }) + mockUpdateReturning.mockReturnValue([]) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: false, reason }) + }) + + it('reports an undifferentiated outcome when the claim finds no durable log row', async () => { + mockResolveWorkflowExecutionOwnership.mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: null, + }) + mockUpdateReturning.mockReturnValue([]) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' }) + }) + + /** + * The re-read is purely observational — it only refines *which* no-op the + * caller is told about. A database that cannot answer it must not take the + * cancel down with it: the run has already been cancelled in Redis and its + * reservation still has to be released, so the failure degrades to the + * undifferentiated outcome rather than propagating. + */ + it('degrades to the undifferentiated outcome when the status re-read fails', async () => { + mockResolveWorkflowExecutionOwnership + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: 'running', + }) + .mockRejectedValueOnce(new Error('connection terminated')) + mockUpdateReturning.mockReturnValue([]) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' }) + expect(mockResolveWorkflowExecutionOwnership).toHaveBeenCalledTimes(2) + expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-1') + }) + it('releases the plan concurrency reservation after a successful cancellation', async () => { const result = await cancelWorkflowExecution(INPUT) @@ -151,12 +306,14 @@ describe('cancelWorkflowExecution', () => { mockResolveWorkflowExecutionOwnership.mockResolvedValue({ belongsToWorkflow: true, workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', }) const cancelled = { kind: 'cancelled' as const, tableId: 'table-1', rowId: 'row-1', groupId: 'group-1', + writes: BOTH_WRITES, } mockCancelWorkflowGroupExecution.mockResolvedValue(cancelled) @@ -173,9 +330,189 @@ describe('cancelWorkflowExecution', () => { expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-1') }) + /** + * A workflow-group log that is already `cancelled` can still own a cell + * sidecar left in `error`, and reconciling it to `cancelled` is a durable + * write this request performed. The terminal entry snapshot cannot see that + * work, so it must not reinterpret the outcome as a no-op — the API would + * otherwise tell the caller nothing changed and drop the cancellation event. + */ + it('reports a durable write when a cancelled group run still had its sidecar reconciled', async () => { + mockResolveWorkflowExecutionOwnership.mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'cancelled', + }) + const cancelled = { + kind: 'cancelled' as const, + tableId: 'table-1', + rowId: 'row-1', + groupId: 'group-1', + writes: SIDECAR_WRITE, + } + mockCancelWorkflowGroupExecution.mockResolvedValue(cancelled) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' }) + expect(mockPublishWorkflowGroupCancellationEvent).toHaveBeenCalledWith(cancelled, 'execution-1') + }) + + /** + * The group path terminalizes the workflow log itself when the cell sidecar + * is already gone, so that outcome is a durable write too. + */ + it('reports a durable write when the group path cancels a run whose sidecar is gone', async () => { + mockResolveWorkflowExecutionOwnership.mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', + }) + mockCancelWorkflowGroupExecution.mockResolvedValue({ + kind: 'cancelled_without_sidecar', + writes: LOG_WRITE, + }) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' }) + expect(mockUpdateSet).not.toHaveBeenCalled() + }) + + /** + * The mirror case: a group run that was already terminal and whose sidecar was + * already `cancelled` leaves both records untouched, so it still reports the + * state it observed rather than a durable write. + */ + it('reports a group run that changed nothing as a no-op', async () => { + mockResolveWorkflowExecutionOwnership.mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'cancelled', + }) + mockCancelWorkflowGroupExecution.mockResolvedValue({ + kind: 'already_cancelled', + tableId: 'table-1', + rowId: 'row-1', + groupId: 'group-1', + writes: NO_WRITES, + }) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ + success: true, + durablyRecorded: false, + reason: 'already_cancelled', + }) + }) + + /** + * The same `already_cancelled` kind covers a transition that left the sidecar + * alone but still terminalized an active workflow log. That log write is + * durable, so the outcome must stay `recorded` and must not re-read a state + * this request itself wrote. + */ + it('reports a durable write when a group run only repaired its workflow log', async () => { + mockResolveWorkflowExecutionOwnership.mockResolvedValue({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', + }) + mockCancelWorkflowGroupExecution.mockResolvedValue({ + kind: 'already_cancelled', + tableId: 'table-1', + rowId: 'row-1', + groupId: 'group-1', + writes: LOG_WRITE, + }) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ success: true, durablyRecorded: true, reason: 'recorded' }) + expect(mockResolveWorkflowExecutionOwnership).toHaveBeenCalledOnce() + }) + + /** + * The lost race the sidecar-bearing kind used to hide: a concurrent cancel + * terminalized both records between the entry snapshot and this transaction, + * which then found the sidecar already `cancelled` and the log already + * `cancelled` and wrote nothing. A non-terminal entry snapshot cannot catch + * that, so the transition's own report of having written nothing is what + * forces the re-read — otherwise the request would claim a durable write and + * fire the v2 cancel analytics gate on a no-op. + */ + it('reports a group run that lost the race with its sidecar already cancelled as a no-op', async () => { + mockResolveWorkflowExecutionOwnership + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', + }) + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'cancelled', + }) + mockCancelWorkflowGroupExecution.mockResolvedValue({ + kind: 'already_cancelled', + tableId: 'table-1', + rowId: 'row-1', + groupId: 'group-1', + writes: NO_WRITES, + }) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ + success: true, + durablyRecorded: false, + reason: 'already_cancelled', + }) + expect(mockResolveWorkflowExecutionOwnership).toHaveBeenCalledTimes(2) + }) + + /** + * The lost-race re-read applies to the group path as well: an entry snapshot + * can still read `running` when the sidecar-less transition finds the log + * already `cancelled` and writes nothing. + */ + it('reports a group run that lost the race to another cancel as a no-op', async () => { + mockResolveWorkflowExecutionOwnership + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', + }) + .mockResolvedValueOnce({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'cancelled', + }) + mockCancelWorkflowGroupExecution.mockResolvedValue({ + kind: 'already_cancelled_without_sidecar', + writes: NO_WRITES, + }) + + const result = await cancelWorkflowExecution(INPUT) + + expect(result).toMatchObject({ + success: true, + durablyRecorded: false, + reason: 'already_cancelled', + }) + expect(mockResolveWorkflowExecutionOwnership).toHaveBeenCalledTimes(2) + }) + it.each([ - [{ kind: 'conflict' as const, status: 'completed' }, 'cannot be cancelled while completed'], - [{ kind: 'not_workflow_group' as const }, 'no longer the active table execution'], + [ + { kind: 'conflict' as const, status: 'completed', writes: NO_WRITES }, + 'cannot be cancelled while completed', + ], + [ + { kind: 'not_workflow_group' as const, writes: NO_WRITES }, + 'no longer the active table execution', + ], ])( 'releases the reservation before reporting a refused workflow-group cell claim as a conflict', async (outcome, message) => { @@ -195,8 +532,8 @@ describe('cancelWorkflowExecution', () => { ) it.each([ - [{ kind: 'conflict' as const, status: 'completed' }], - [{ kind: 'not_workflow_group' as const }], + [{ kind: 'conflict' as const, status: 'completed', writes: NO_WRITES }], + [{ kind: 'not_workflow_group' as const, writes: NO_WRITES }], ])( 'keeps the reservation held when a refused claim follows a paused cancellation', async (outcome) => { @@ -213,8 +550,8 @@ describe('cancelWorkflowExecution', () => { ) it.each([ - [{ kind: 'conflict' as const, status: 'completed' }], - [{ kind: 'not_workflow_group' as const }], + [{ kind: 'conflict' as const, status: 'completed', writes: NO_WRITES }], + [{ kind: 'not_workflow_group' as const, writes: NO_WRITES }], ])( 'keeps the reservation held when a refused claim follows a failed cancellation', async (outcome) => { @@ -237,6 +574,7 @@ describe('cancelWorkflowExecution', () => { mockResolveWorkflowExecutionOwnership.mockResolvedValue({ belongsToWorkflow: true, workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', }) const failure = new Error('Workflow-group cancellation lost its locked workflow-log claim') mockCancelWorkflowGroupExecution.mockRejectedValue(failure) @@ -251,6 +589,7 @@ describe('cancelWorkflowExecution', () => { mockResolveWorkflowExecutionOwnership.mockResolvedValue({ belongsToWorkflow: true, workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', }) mockBeginPausedCancellation.mockResolvedValue(true) mockCancelWorkflowGroupExecution.mockRejectedValue(new Error('serialization conflict')) @@ -263,6 +602,7 @@ describe('cancelWorkflowExecution', () => { mockResolveWorkflowExecutionOwnership.mockResolvedValue({ belongsToWorkflow: true, workflowGroupWorkspaceId: 'workspace-1', + priorStatus: 'running', }) mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: false, diff --git a/apps/sim/lib/execution/cancel-workflow-execution.ts b/apps/sim/lib/execution/cancel-workflow-execution.ts index 8d542d3bb27..10e92517854 100644 --- a/apps/sim/lib/execution/cancel-workflow-execution.ts +++ b/apps/sim/lib/execution/cancel-workflow-execution.ts @@ -18,6 +18,7 @@ import { cancelWorkflowGroupExecution, type PublishableWorkflowGroupCancellation, publishWorkflowGroupCancellationEvent, + type WorkflowGroupCancellationWrites, } from '@/lib/table/workflow-group-cancellation' import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' import { resolveWorkflowExecutionOwnership } from '@/lib/workflows/executor/execution-queries' @@ -45,17 +46,109 @@ async function cancelActiveWorkflowJob(executionId: string): Promise { * Cancellation outcome vocabulary produced by this service, and so the whole * vocabulary the public v2 endpoint can return. `recorded`/`redis_unavailable`/ * `redis_write_failed` come from the Redis record step; the two `paused_*` - * values from the paused-HITL path. The internal cancel route resolves further - * outcomes on top of these — see `internalCancelWorkflowExecutionReasonSchema` - * in `lib/api/contracts/workflows`. + * values from the paused-HITL path; the three `already_*` values report a run + * that was already terminal when the request arrived, where the cancel claim + * matched no row and nothing durable was written. The internal cancel route + * resolves further outcomes on top of these — see + * `internalCancelWorkflowExecutionReasonSchema` in `lib/api/contracts/workflows`. */ export type CancelWorkflowExecutionReason = | 'recorded' + | 'already_cancelled' + | 'already_completed' + | 'already_failed' | 'redis_unavailable' | 'redis_write_failed' | 'paused_event_publish_failed' | 'paused_database_cancel_failed' +/** Maps each log status a cancel claim can never move to the outcome that reports it. */ +const TERMINAL_NO_OP_REASONS = { + cancelled: 'already_cancelled', + completed: 'already_completed', + failed: 'already_failed', +} as const satisfies Record + +type TerminalExecutionStatus = keyof typeof TERMINAL_NO_OP_REASONS + +function toTerminalExecutionStatus( + status: string | null | undefined +): TerminalExecutionStatus | null { + return typeof status === 'string' && status in TERMINAL_NO_OP_REASONS + ? (status as TerminalExecutionStatus) + : null +} + +/** + * What this request's own terminal claim did: moved the run to `cancelled` here + * and now, provably matched no row, or ran on a path that cannot tell. Every + * path that can terminalize the run — the direct log claim and the + * workflow-group transition — answers in this one vocabulary, so the report can + * ask a single question: did this request durably write? + * + * Only the direct claim ever answers `unknown`, and only when it could not run + * or its statement failed. The workflow-group transition always knows: it + * reports the writes it performed. + */ +type TerminalWriteOutcome = 'applied' | 'no_row' | 'unknown' + +/** + * Reads a workflow-group transition's durability off the writes it reported + * rather than off its `kind`. Terminalizing the workflow log and cancelling the + * cell sidecar are each a durable write this request performed, and a single + * `kind` covers both a transition that did one of them and one that did + * neither: `already_cancelled` leaves a sidecar that was already `cancelled` + * alone, but may still have terminalized an active workflow log. + */ +function toTerminalWriteOutcome(writes: WorkflowGroupCancellationWrites): TerminalWriteOutcome { + return writes.workflowLogTerminalized || writes.sidecarCancelled ? 'applied' : 'no_row' +} + +/** + * Names the terminal state the cancel could not move, or `null` when it did + * real work or when this path cannot tell — in which case the caller keeps the + * undifferentiated report rather than guessing. + * + * A request that durably wrote is never a no-op, whatever the entry snapshot + * said. A run can be terminal at entry and still owe this request a real write: + * a workflow-group run whose log is already `cancelled` can carry a sidecar left + * in `error`, and reconciling it is a durable cancellation that the entry + * snapshot cannot see. + * + * The status read at entry is not enough on its own in the other direction + * either: a run that finishes between that read and the claim leaves a stale + * non-terminal snapshot behind a cancel that wrote nothing. The claim's own row + * count settles that, and a plain post-read cannot: after a successful cancel + * the row reads `cancelled` too, so the state has to be attributed to whoever + * wrote it. A claim that moved no row against a non-terminal snapshot re-reads + * the row it lost the race to, through the same ownership query the entry read + * came from. + * + * Purely observational — it gates no effect, and a read failure falls back to + * the undifferentiated report rather than failing the cancel. + */ +async function resolveTerminalNoOpReason( + executionId: string, + workflowId: string, + priorTerminalStatus: TerminalExecutionStatus | null, + terminalWrite: TerminalWriteOutcome +): Promise { + if (terminalWrite === 'applied') return null + if (priorTerminalStatus !== null) return TERMINAL_NO_OP_REASONS[priorTerminalStatus] + if (terminalWrite !== 'no_row') return null + try { + const { priorStatus } = await resolveWorkflowExecutionOwnership(executionId, workflowId) + const terminalStatus = toTerminalExecutionStatus(priorStatus) + return terminalStatus !== null ? TERMINAL_NO_OP_REASONS[terminalStatus] : null + } catch (error) { + logger.warn('Failed to re-read execution status after an unmatched cancel claim', { + executionId, + error, + }) + return null + } +} + export interface CancelWorkflowExecutionResult { success: boolean executionId: string @@ -173,11 +266,10 @@ export async function cancelWorkflowExecution( ): Promise { const { executionId, workflowId, userId, workspaceId } = input - const { belongsToWorkflow, workflowGroupWorkspaceId } = await resolveWorkflowExecutionOwnership( - executionId, - workflowId - ) + const { belongsToWorkflow, workflowGroupWorkspaceId, priorStatus } = + await resolveWorkflowExecutionOwnership(executionId, workflowId) if (!belongsToWorkflow) throw new WorkflowExecutionNotFoundError() + const priorTerminalStatus = toTerminalExecutionStatus(priorStatus) let pausedCancellationStarted = false let pausedCancelled = false @@ -372,14 +464,20 @@ export async function cancelWorkflowExecution( ? groupCancellation : null - if ( - groupCancellation === null && + /** + * The claim's row count is read back only to report it — `returning` changes + * what the statement returns, never the row it writes or the rows it matches. + */ + let terminalWrite: TerminalWriteOutcome = 'unknown' + if (groupCancellation !== null) { + terminalWrite = toTerminalWriteOutcome(groupCancellation.writes) + } else if ( (cancellation.durablyRecorded || queuedJobCancelled || locallyAborted) && !pausedCancelled ) { try { const cancelledAt = new Date() - await db + const claimedRows = await db .update(workflowExecutionLogs) .set(cancelledExecutionLogFields(cancelledAt)) .where( @@ -388,6 +486,8 @@ export async function cancelWorkflowExecution( eq(workflowExecutionLogs.status, 'running') ) ) + .returning({ id: workflowExecutionLogs.id }) + terminalWrite = claimedRows.length > 0 ? 'applied' : 'no_row' } catch (dbError) { logger.warn('Failed to update execution log status directly', { executionId, @@ -426,16 +526,36 @@ export async function cancelWorkflowExecution( ? 'recorded' : cancellation.reason + /** + * A run that was already terminal when the request arrived cannot be + * cancelled again: the claim's `status = 'running'` predicate matched no row + * and no terminal metadata moved, so `recorded`/`durablyRecorded: true` would + * claim a durable write that never happened. Every effect above still ran + * exactly as before — only the report changes. The request is still satisfied, + * because the run is not running, so `success` stays `true`. + * + * Reinterpreting is only ever right when nothing else went wrong. A terminal + * run — cancelled, or force-failed with paused state left behind — can still + * carry real paused-HITL reconciliation work, and a genuine failure there owes + * the caller the step that failed, not a no-op. So only an otherwise-clean + * `recorded` is a candidate, whatever the prior status was — and only when + * this request wrote nothing durable on any path. + */ + const terminalNoOpReason = + reason === 'recorded' && !pausedCancelled + ? await resolveTerminalNoOpReason(executionId, workflowId, priorTerminalStatus, terminalWrite) + : null + return { - success, + success: terminalNoOpReason ? true : success, executionId, redisAvailable: isPausedCancellationPath || pausedCancelled ? pausedCancellationPublished : cancellation.reason !== 'redis_unavailable', - durablyRecorded, + durablyRecorded: terminalNoOpReason ? false : durablyRecorded, locallyAborted, pausedCancelled, - reason, + reason: terminalNoOpReason ?? reason, } } diff --git a/apps/sim/lib/execution/event-buffer.ts b/apps/sim/lib/execution/event-buffer.ts index 3e75a7263c8..b555c23d2b3 100644 --- a/apps/sim/lib/execution/event-buffer.ts +++ b/apps/sim/lib/execution/event-buffer.ts @@ -628,7 +628,7 @@ export async function readExecutionMetaState( if (canUseMemoryEventBuffer()) { return readMemoryMeta(executionId) } - logger.warn('getExecutionMeta: Redis client unavailable', { executionId }) + logger.warn('readExecutionMetaState: Redis client unavailable', { executionId }) return { status: 'unavailable', error: 'Redis client unavailable' } } try { @@ -659,23 +659,6 @@ export async function readExecutionMetaState( } } -export async function getExecutionMeta(executionId: string): Promise { - const result = await readExecutionMetaState(executionId) - if (result.status === 'found') return result.meta - if (result.status === 'unavailable') { - return null - } - return null -} - -export async function readExecutionEvents( - executionId: string, - afterEventId: number -): Promise { - const result = await readExecutionEventsState(executionId, afterEventId) - return result.status === 'ok' ? result.events : [] -} - export async function readExecutionEventsState( executionId: string, afterEventId: number diff --git a/apps/sim/lib/execution/model-input-provenance.ts b/apps/sim/lib/execution/model-input-provenance.ts index 40b04e852ef..6bc03c62bce 100644 --- a/apps/sim/lib/execution/model-input-provenance.ts +++ b/apps/sim/lib/execution/model-input-provenance.ts @@ -1,4 +1,4 @@ -import { isPlainRecord } from '@sim/utils/object' +import { isPlainRecord, isRecordLike } from '@sim/utils/object' import { PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, PRIVATE_SECRET_PROVENANCE_FIELD, @@ -473,10 +473,7 @@ export function inspectModelInputProvenanceRequest( headers: HeaderReader, payload: unknown ): ModelInputProvenanceInspection { - const record = - payload !== null && typeof payload === 'object' && !Array.isArray(payload) - ? (payload as Record) - : undefined + const record = isRecordLike(payload) ? (payload as Record) : undefined const hasProvenance = record ? Object.hasOwn(record, RESOLVED_SECRET_PROVENANCE_FIELD) : false const receivedType = headers.get(PRIVATE_MODEL_INPUT_PROVENANCE_HEADER) @@ -493,10 +490,7 @@ export function inspectPrivateSecretProvenanceRequest( headers: HeaderReader, payload: unknown ): ModelInputProvenanceInspection { - const record = - payload !== null && typeof payload === 'object' && !Array.isArray(payload) - ? (payload as Record) - : undefined + const record = isRecordLike(payload) ? (payload as Record) : undefined const hasProvenance = record ? Object.hasOwn(record, PRIVATE_SECRET_PROVENANCE_FIELD) : false const receivedType = headers.get(PRIVATE_SECRET_PROVENANCE_HEADER) diff --git a/apps/sim/lib/execution/private-tool-metadata.ts b/apps/sim/lib/execution/private-tool-metadata.ts index 436408ac4be..d760522de74 100644 --- a/apps/sim/lib/execution/private-tool-metadata.ts +++ b/apps/sim/lib/execution/private-tool-metadata.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' export const PRIVATE_TOOL_METADATA_REQUEST_HEADER = 'x-sim-request-private-tool-metadata' export const PRIVATE_TOOL_METADATA_RESPONSE_HEADER = 'x-sim-private-tool-metadata' export const MAX_PRIVATE_TOOL_METADATA_OVERHEAD_BYTES = 10 * 1024 * 1024 @@ -109,10 +110,7 @@ export function inspectPrivateToolMetadataEnvelope( expectedType: PrivateToolMetadataType ): PrivateToolMetadataEnvelopeInspection { const capability = inspectPrivateToolMetadataResponseCapability(headers, expectedType) - const record = - payload !== null && typeof payload === 'object' && !Array.isArray(payload) - ? (payload as Record) - : undefined + const record = isRecordLike(payload) ? (payload as Record) : undefined const hasNames = record ? Object.hasOwn(record, RESOLVED_SECRET_NAMES_FIELD) : false const hasProvenance = record ? Object.hasOwn(record, RESOLVED_SECRET_PROVENANCE_FIELD) : false diff --git a/apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts b/apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts index 196d76e6503..8feee1bf9a2 100644 --- a/apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts +++ b/apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts @@ -13,8 +13,6 @@ import { sandboxCliToolRecipes } from '@/lib/execution/remote-sandbox/cli-tools. */ export type SandboxLanguage = `${CodeLanguage.JavaScript}` | `${CodeLanguage.Python}` -export const SANDBOX_LANGUAGES = [CodeLanguage.JavaScript, CodeLanguage.Python] as const - export function isSandboxLanguage(value: string): value is SandboxLanguage { return value === CodeLanguage.JavaScript || value === CodeLanguage.Python } diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index dda1f63b436..6fc65721a5e 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -92,34 +92,6 @@ export async function findActiveFolder( return row ?? null } -/** - * A folder in a workspace's tree regardless of archive state. - * - * {@link findActiveFolder} answers "is this a valid destination"; this answers "does this row - * exist here at all". Delete needs the second question — `deleteFolder` reuses an already - * archived folder's own `deletedAt` so a cascade that failed partway can be retried, and - * filtering archived rows out would strand those stragglers. - */ -export async function findFolderInWorkspace( - folderId: string, - workspaceId: string, - resourceType: FolderResourceType -): Promise { - const [row] = await db - .select() - .from(folder) - .where( - and( - eq(folder.id, folderId), - eq(folder.workspaceId, workspaceId), - eq(folder.resourceType, resourceType) - ) - ) - .limit(1) - - return row ?? null -} - /** * Where a restored resource should land: its original folder when that folder is reachable, * otherwise the workspace root. diff --git a/apps/sim/lib/guardrails/pii-entities.ts b/apps/sim/lib/guardrails/pii-entities.ts index 54976c0e266..321120e276b 100644 --- a/apps/sim/lib/guardrails/pii-entities.ts +++ b/apps/sim/lib/guardrails/pii-entities.ts @@ -55,13 +55,6 @@ export const SUPPORTED_PII_ENTITIES = { export type PIIEntityType = keyof typeof SUPPORTED_PII_ENTITIES -/** Flat `{ value, label }` options for entity-type pickers, in catalog order. */ -export const PII_ENTITY_OPTIONS: ReadonlyArray<{ value: PIIEntityType; label: string }> = - Object.entries(SUPPORTED_PII_ENTITIES).map(([value, label]) => ({ - value: value as PIIEntityType, - label, - })) - /** Entity types grouped by region, for a grouped checkbox picker. */ export const PII_ENTITY_GROUPS: ReadonlyArray<{ label: string @@ -325,13 +318,6 @@ export const PII_STAGE_META: ReadonlyArray<{ }, ] -/** Recognizers that over-redact (loose, no checksum); surfaced as UI guidance. */ -export const RISKY_PII_ENTITIES: ReadonlySet = new Set([ - 'US_SSN', - 'US_BANK_NUMBER', - 'DATE_TIME', -]) - /** A fully-disabled stage policy for new drafts. */ export function emptyStagePolicy(): PiiStagePolicy { return { enabled: false, entityTypes: [], language: DEFAULT_PII_LANGUAGE, customPatterns: [] } diff --git a/apps/sim/lib/integrations/availability.server.ts b/apps/sim/lib/integrations/availability.server.ts index 8de88f590ed..a566ffccce8 100644 --- a/apps/sim/lib/integrations/availability.server.ts +++ b/apps/sim/lib/integrations/availability.server.ts @@ -16,7 +16,6 @@ export type { IntegrationAvailabilityState, } from '@/lib/integrations/availability' -let unavailableIntegrationTypes: ReadonlySet | null = null let integrationAvailabilityByType: ReadonlyMap | null = null const oauthServiceAvailability = new Map() @@ -24,20 +23,6 @@ export function getIntegrationAvailability() { return resolveIntegrationAvailability(env) } -export function getUnavailableIntegrationTypes(): ReadonlySet { - if (!unavailableIntegrationTypes) { - unavailableIntegrationTypes = new Set( - getIntegrationAvailability() - .filter( - (integration) => - integration.state === 'unavailable' || integration.state === 'misconfigured' - ) - .map((integration) => integration.type.toLowerCase()) - ) - } - return unavailableIntegrationTypes -} - function getIntegrationAvailabilityByType(): ReadonlyMap { if (!integrationAvailabilityByType) { integrationAvailabilityByType = new Map( diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 22af283068d..5332c38e5c4 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -3,7 +3,7 @@ // Maps block types to their icon component references for the integrations page import type { ComponentType, SVGProps } from 'react' -import { Library, Rocket } from '@sim/emcn/icons' +import { Library, Rocket, Table } from '@sim/emcn/icons' import { A2AIcon, AgentMailIcon, @@ -22,6 +22,7 @@ import { AshbyIcon, AthenaIcon, AttioIcon, + AzureDataExplorerIcon, AzureIcon, BoxCompanyIcon, BrainIcon, @@ -180,6 +181,7 @@ import { QdrantIcon, QuartrIcon, QuiverIcon, + RabbitmqIcon, RailwayIcon, RB2BIcon, RDSIcon, @@ -221,7 +223,6 @@ import { StagehandIcon, StripeIcon, SupabaseIcon, - TableIcon, TailscaleIcon, TavilyIcon, TelegramIcon, @@ -277,6 +278,7 @@ export const blockTypeToIconMap: Record = { ashby: AshbyIcon, athena: AthenaIcon, attio: AttioIcon, + azure_data_explorer: AzureDataExplorerIcon, azure_devops: AzureIcon, box: BoxCompanyIcon, brandfetch: BrandfetchIcon, @@ -451,6 +453,7 @@ export const blockTypeToIconMap: Record = { qdrant: QdrantIcon, quartr: QuartrIcon, quiver: QuiverIcon, + rabbitmq: RabbitmqIcon, railway: RailwayIcon, rb2b: RB2BIcon, rds: RDSIcon, @@ -493,7 +496,7 @@ export const blockTypeToIconMap: Record = { sts: STSIcon, stt_v2: STTIcon, supabase: SupabaseIcon, - table: TableIcon, + table: Table, tailscale: TailscaleIcon, tavily: TavilyIcon, telegram: TelegramIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 6512deee5e0..9068d056e2b 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-13", + "updatedAt": "2026-08-14", "integrations": [ { "type": "onepassword", @@ -1093,7 +1093,7 @@ "slug": "ashby", "name": "Ashby", "description": "Manage candidates, jobs, and applications in Ashby", - "longDescription": "Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).", + "longDescription": "Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag, anonymize), applications (list, get, create, delete, change stage, change source), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), custom field values (set one or many), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).", "bgColor": "#5D4ED6", "iconName": "AshbyIcon", "docsUrl": "https://docs.sim.ai/integrations/ashby", @@ -1146,6 +1146,10 @@ "name": "Create Application", "description": "Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user." }, + { + "name": "Delete Application", + "description": "Permanently deletes an application in Ashby. Requires the candidatesDelete permission, which is a separate module permission from candidatesWrite - a read and write key returns 403 here. There is no equivalent endpoint for deleting a candidate; candidate deletion is UI-only." + }, { "name": "List Offers", "description": "Lists all offers with their latest version in an Ashby organization." @@ -1154,6 +1158,14 @@ "name": "Change Application Stage", "description": "Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage." }, + { + "name": "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." + }, + { + "name": "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." + }, { "name": "Add Candidate Tag", "description": "Adds a tag to a candidate in Ashby and returns the updated candidate." @@ -1182,6 +1194,14 @@ "name": "List Custom Fields", "description": "Lists all custom field definitions configured in Ashby." }, + { + "name": "Set Custom Field Value", + "description": "Sets the value of a single custom field on an Ashby Application, Candidate, Job, or Opening. Custom fields are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Requires the candidatesWrite permission." + }, + { + "name": "Set Custom Field Values", + "description": "Sets several custom field values on one Ashby Application, Candidate, Job, or Opening in a single call. Prefer this over repeated single-field writes to the same object - Ashby recommends it because concurrent single-field calls can race and overwrite each other. Requires the candidatesWrite permission." + }, { "name": "List Departments", "description": "Lists all departments in Ashby." @@ -1211,7 +1231,7 @@ "description": "Lists interview schedules in Ashby, optionally filtered by application or interview stage." } ], - "operationCount": 28, + "operationCount": 33, "triggers": [ { "id": "ashby_application_submit", @@ -2217,6 +2237,81 @@ "integrationType": "security", "tags": ["identity", "microsoft-365"] }, + { + "type": "azure_data_explorer", + "slug": "azure-data-explorer", + "name": "Azure Data Explorer", + "description": "Query and manage Azure Data Explorer (Kusto) clusters with KQL", + "longDescription": "Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed.", + "bgColor": "#FFFFFF", + "iconName": "AzureDataExplorerIcon", + "docsUrl": "https://docs.sim.ai/integrations/azure_data_explorer", + "operations": [ + { + "name": "Run Query", + "description": "Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table." + }, + { + "name": "List Databases", + "description": "List the databases on an Azure Data Explorer cluster that the service principal can access." + }, + { + "name": "List Tables", + "description": "List the tables in an Azure Data Explorer database, with their folder and docstring." + }, + { + "name": "Show Table Schema", + "description": "Read the column schema of an Azure Data Explorer table in CSL form (e.g., \"Timestamp:datetime,Level:string\"). Use this before writing a KQL query against an unfamiliar table." + }, + { + "name": "Show Database Schema", + "description": "Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call." + }, + { + "name": "Show Table Details", + "description": "Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it." + }, + { + "name": "List Functions", + "description": "List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it." + }, + { + "name": "Ingest Rows Inline", + "description": "Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes." + }, + { + "name": "Ingest From Query", + "description": "Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns." + }, + { + "name": "Create Table", + "description": "Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists." + }, + { + "name": "Drop Table", + "description": "Drop a table from an Azure Data Explorer database. This permanently deletes the table and its data, and returns the tables that remain." + }, + { + "name": "Show Ingestion Failures", + "description": "List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days." + }, + { + "name": "Show Operations", + "description": "Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks." + }, + { + "name": "Management Command (advanced)", + "description": "Run an Azure Data Explorer management command (a control command starting with \".\") such as .show, .create, .alter, or .drop. Write commands change cluster state permanently; use the Query operation for reads." + } + ], + "operationCount": 14, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "databases", + "tags": ["data-analytics", "data-warehouse", "cloud", "monitoring"] + }, { "type": "azure_devops", "slug": "azure-devops", @@ -8931,6 +9026,10 @@ "name": "Get Alert Rule", "description": "Get a specific alert rule by its UID" }, + { + "name": "Get Alert Rule Group", + "description": "Read an alert rule group: its evaluation interval and every rule in it. The interval is the group-level knob that decides how often those rules are evaluated, which the individual alert rule operations do not expose." + }, { "name": "Create Alert Rule", "description": "Create a new alert rule" @@ -8951,6 +9050,14 @@ "name": "Create Contact Point", "description": "Create a notification contact point (e.g., Slack, email, PagerDuty)" }, + { + "name": "Update Contact Point", + "description": "Replace a contact point by its UID. Grafana has no partial update for contact points, so every field is rewritten — resend the name, type, and full settings, or the omitted ones are reset." + }, + { + "name": "Delete Contact Point", + "description": "Permanently delete a contact point by its UID. Grafana refuses the delete while the contact point is still referenced by the notification policy tree or by an alert rule." + }, { "name": "Create Annotation", "description": "Create an annotation on a dashboard or as a global annotation" @@ -8975,6 +9082,10 @@ "name": "Get Data Source", "description": "Get a data source by its ID or UID" }, + { + "name": "Query Data Source", + "description": "Run one or more queries against a Grafana data source that has a backend implementation, and read the values back. This is how you get actual metric numbers out of Grafana rather than dashboard or alert configuration." + }, { "name": "Check Data Source Health", "description": "Test connectivity to a data source by its UID" @@ -8999,12 +9110,16 @@ "name": "Delete Folder", "description": "Delete a folder by its UID" }, + { + "name": "Move Folder", + "description": "Move a folder under a different parent folder, or to the root by leaving the parent empty. Returns the folder with its new ancestry." + }, { "name": "Get Health", "description": "Check the health of the Grafana instance (version, database status)" } ], - "operationCount": 25, + "operationCount": 30, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -15203,6 +15318,125 @@ "integrationType": "ai", "tags": ["image-generation"] }, + { + "type": "rabbitmq", + "slug": "rabbitmq", + "name": "RabbitMQ", + "description": "Publish and read messages and manage queues in RabbitMQ", + "longDescription": "Connect agents to a RabbitMQ broker through its Management HTTP API. Publish messages to exchanges, read messages off queues, declare queues, exchanges, bindings, and policies, and inspect broker health, queue depth, consumers, connections, and cluster nodes. Works with self-hosted brokers and managed offerings such as CloudAMQP as long as the management plugin is reachable.", + "bgColor": "#FFFFFF", + "iconName": "RabbitmqIcon", + "docsUrl": "https://docs.sim.ai/integrations/rabbitmq", + "operations": [ + { + "name": "Publish Message", + "description": "Publish a message to a RabbitMQ exchange with a routing key. Reports whether the message was routed to at least one queue." + }, + { + "name": "Get Messages", + "description": "Retrieve messages from a RabbitMQ queue. Defaults to requeueing the messages so they stay available to real consumers." + }, + { + "name": "List Queues", + "description": "List queues in a RabbitMQ virtual host with their depth, consumer count, and configuration." + }, + { + "name": "Get Queue", + "description": "Read a single RabbitMQ queue, including its depth, consumer count, and declaration settings." + }, + { + "name": "Create Queue", + "description": "Declare a RabbitMQ queue. Declaring a queue that already exists with the same settings succeeds without changing it." + }, + { + "name": "Delete Queue", + "description": "Delete a RabbitMQ queue and every message still in it. Can be guarded so the delete only happens when the queue is unused or empty." + }, + { + "name": "Purge Queue", + "description": "Discard every ready message in a RabbitMQ queue while leaving the queue itself in place." + }, + { + "name": "List Exchanges", + "description": "List exchanges in a RabbitMQ virtual host with their type and declaration settings." + }, + { + "name": "Get Exchange", + "description": "Read a single RabbitMQ exchange and the settings it was declared with." + }, + { + "name": "Create Exchange", + "description": "Declare a RabbitMQ exchange. Declaring an exchange that already exists with the same settings succeeds without changing it." + }, + { + "name": "Delete Exchange", + "description": "Delete a RabbitMQ exchange and every binding attached to it. Publishers targeting it will fail afterwards." + }, + { + "name": "List Queue Bindings", + "description": "List the bindings that route messages into a RabbitMQ queue, including the implicit default-exchange binding." + }, + { + "name": "List Exchange Bindings", + "description": "List everything an exchange routes to, so you can see which routing keys reach which queues." + }, + { + "name": "Create Binding", + "description": "Bind a queue or another exchange to a RabbitMQ exchange so messages matching a routing key are routed to it." + }, + { + "name": "Delete Binding", + "description": "Remove a binding so an exchange stops routing its matching messages to that destination." + }, + { + "name": "Get Overview", + "description": "Read broker-wide RabbitMQ status: version, cluster name, object totals, queue depth totals, and message rates." + }, + { + "name": "Health Check", + "description": "Run one of the broker health checks and report whether it passed. A failing check is a normal result, not a tool error." + }, + { + "name": "List Nodes", + "description": "List the cluster nodes with memory, disk, file-descriptor, and alarm state. A fired alarm blocks publishers broker-wide." + }, + { + "name": "List Virtual Hosts", + "description": "List the virtual hosts on the broker with their message totals, so you can discover which scopes exist." + }, + { + "name": "List Connections", + "description": "List client connections to the broker with their user, state, and channel count. Connections are cluster-wide, not scoped to one virtual host." + }, + { + "name": "List Channels", + "description": "List open channels with their prefetch limit and unacknowledged message count, which is where stalled consumers show up. Channels are cluster-wide, not scoped to one virtual host." + }, + { + "name": "List Consumers", + "description": "List the consumers subscribed in a virtual host. An empty result for a queue with a backlog means nothing is processing it." + }, + { + "name": "List Policies", + "description": "List the policies in a virtual host. Policies are how dead-lettering, TTLs, and length limits get applied to matching queues and exchanges." + }, + { + "name": "Create Policy", + "description": "Create or replace a RabbitMQ policy, applying settings such as dead-lettering, TTLs, or length limits to every queue or exchange whose name matches a pattern." + }, + { + "name": "Delete Policy", + "description": "Delete a RabbitMQ policy. Every queue and exchange it matched immediately loses the settings it applied." + } + ], + "operationCount": 25, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "devops", + "tags": ["messaging", "automation"] + }, { "type": "railway", "slug": "railway", diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index ae6565b3d13..5d49cb0bd3e 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -12,7 +12,7 @@ import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' -import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, sql } from 'drizzle-orm' import { getEmailSubject, renderBatchInvitationEmail, @@ -465,20 +465,6 @@ export async function revertPendingInvitationGrants(params: { }) } -async function countPendingInvitationsForOrganization(organizationId: string): Promise { - const [row] = await db - .select({ count: sql`count(*)::int` }) - .from(invitation) - .where( - and( - eq(invitation.organizationId, organizationId), - eq(invitation.status, 'pending'), - ne(invitation.membershipIntent, 'external') - ) - ) - return row?.count ?? 0 -} - /** * Workspaces this email already holds a pending grant for, across every * pending invitation. Callers use it to drop workspaces from a new invite diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index c7e501d9dc6..536c905e8f4 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -173,12 +173,16 @@ async function validateConnectorSourceConfig(input: { let accessToken: string | null = null if (connectorConfig.auth.mode === 'apiKey') { if (!input.connector.encryptedApiKey) { - return { - message: 'API key not found. Please reconfigure the connector.', - errorCode: 'validation', + if (!connectorConfig.auth.optional) { + return { + message: 'API key not found. Please reconfigure the connector.', + errorCode: 'validation', + } } + accessToken = '' + } else { + accessToken = (await decryptApiKey(input.connector.encryptedApiKey)).decrypted } - accessToken = (await decryptApiKey(input.connector.encryptedApiKey)).decrypted } else { if (!input.connector.credentialId) { return { diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index b4d3a162480..2036c417664 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -4,6 +4,11 @@ import { authOAuthUtilsMock } from '@sim/testing' import { generateShortId } from '@sim/utils/id' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + classifySuspectListing, + evaluateListingSafety, + type PreviousListingObservation, +} from '@/lib/knowledge/connectors/sync-engine' vi.mock('drizzle-orm', () => ({ and: vi.fn(), @@ -23,7 +28,7 @@ vi.mock('@/background/knowledge-connector-sync', () => ({ knowledgeConnectorSync: { trigger: vi.fn() }, })) -const mockMapTags = vi.fn() +const { mockMapTags } = vi.hoisted(() => ({ mockMapTags: vi.fn() })) vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: { @@ -537,3 +542,88 @@ describe('chunkOpsByByteBudget', () => { expect(chunks).toHaveLength(1) }) }) + +describe('classifySuspectListing', () => { + it('trusts a healthy listing', () => { + expect(classifySuspectListing(100, 100)).toBeNull() + expect(classifySuspectListing(90, 100)).toBeNull() + }) + + it('flags an empty listing against a real corpus', () => { + expect(classifySuspectListing(0, 3)).toBe('empty') + expect(classifySuspectListing(0, 10_000)).toBe('empty') + }) + + it('ignores an empty listing on a trivially small corpus', () => { + expect(classifySuspectListing(0, 0)).toBeNull() + expect(classifySuspectListing(0, 2)).toBeNull() + }) + + it('flags a near-total collapse on a large corpus', () => { + expect(classifySuspectListing(3, 10_000)).toBe('collapsed') + expect(classifySuspectListing(49, 500)).toBe('collapsed') + }) + + it('allows an ordinary bulk deletion through', () => { + expect(classifySuspectListing(1000, 10_000)).toBeNull() + expect(classifySuspectListing(1, 8)).toBeNull() + expect(classifySuspectListing(4, 49)).toBeNull() + }) +}) + +describe('evaluateListingSafety', () => { + const previous = ( + listedCount: number, + ownedCount: number, + trustworthy = true + ): PreviousListingObservation => ({ listedCount, ownedCount, trustworthy }) + + it('leaves a healthy listing untouched', () => { + expect(evaluateListingSafety(100, 100, null, undefined)).toEqual({ + reason: null, + blocked: false, + corroborated: false, + }) + }) + + it('blocks the first suspect empty listing', () => { + expect(evaluateListingSafety(0, 500, previous(500, 500), undefined)).toEqual({ + reason: 'empty', + blocked: true, + corroborated: false, + }) + }) + + it('blocks when there is no previous completed sync to corroborate', () => { + expect(evaluateListingSafety(0, 500, null, undefined).blocked).toBe(true) + }) + + it('reconciles once a consecutive sync sees the same empty listing', () => { + expect(evaluateListingSafety(0, 500, previous(0, 500), undefined)).toEqual({ + reason: 'empty', + blocked: false, + corroborated: true, + }) + }) + + it('refuses to be corroborated by a possibly-incremental previous run', () => { + expect(evaluateListingSafety(0, 500, previous(0, 500, false), undefined).blocked).toBe(true) + }) + + it('blocks then allows a proportional collapse across two syncs', () => { + expect(evaluateListingSafety(3, 10_000, previous(10_000, 10_000), undefined).blocked).toBe(true) + expect(evaluateListingSafety(3, 10_000, previous(2, 10_000), undefined)).toEqual({ + reason: 'collapsed', + blocked: false, + corroborated: true, + }) + }) + + it('lets an explicit fullSync override the guard', () => { + expect(evaluateListingSafety(0, 500, null, true)).toEqual({ + reason: 'empty', + blocked: false, + corroborated: false, + }) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index d3197f88362..ccd804a50df 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -10,7 +10,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { randomInt } from '@sim/utils/random' -import { and, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm' +import { and, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm' import { decryptApiKey } from '@/lib/api-key/crypto' import { assertBillingAttributionSnapshot, @@ -246,6 +246,148 @@ export function shouldReconcileDeletions( return !syncContext?.listingCapped || Boolean(fullSync) } +/** + * Minimum number of documents a connector must still own before an empty + * listing is treated as suspect. Below it, an empty listing is far more likely + * to be a genuinely emptied source than a broken one, the blast radius of + * reconciling is a handful of documents, and any ratio-based judgement is + * statistically meaningless. + */ +const SUSPECT_LISTING_MIN_OWNED_DOCS = 3 +/** + * Minimum owned-document count before the proportional (collapse) guard + * applies. A source can legitimately shrink hard when it is small — going from + * 8 documents to 1 is ordinary editing — so the collapse guard only engages on + * corpora large enough that a near-total disappearance in a single sync is + * implausible without an upstream fault. + */ +const SUSPECT_COLLAPSE_MIN_OWNED_DOCS = 50 +/** + * A listing covering less than this fraction of the documents the connector + * still owns is treated as suspect. Deliberately far below any plausible + * bulk edit (10% means 10,000 documents collapsing to under 1,000) so normal + * housekeeping never trips it, while the partial-outage shapes seen in the + * wild — an auth wall or an interstitial served for most of a source — do. + */ +const SUSPECT_COLLAPSE_MAX_RATIO = 0.1 + +/** Why a listing is considered untrustworthy evidence of deletion. */ +export type SuspectListingReason = 'empty' | 'collapsed' + +/** + * A prior sync's listing, reconstructed from its sync-log counters. + * + * `trustworthy` is false when that run could have been an incremental listing: + * an incremental run that observed no changes is indistinguishable from a full + * run that observed nothing, and treating the former as corroboration would let + * a single bad listing confirm itself. + */ +export interface PreviousListingObservation { + listedCount: number + ownedCount: number + trustworthy: boolean +} + +/** + * Classifies a listing as untrustworthy evidence that documents were deleted. + * + * A connector that returns nothing (or almost nothing) while the knowledge base + * still holds a real corpus for it is far more likely to be broken than to be + * reporting a genuinely emptied source: observed causes include an HTTP 200 + * interstitial served instead of an index, and a source moved behind auth. + * Neither surfaces as an error, so the sync looks clean and the listing looks + * authoritative. + */ +export function classifySuspectListing( + listedCount: number, + ownedCount: number +): SuspectListingReason | null { + if (ownedCount < SUSPECT_LISTING_MIN_OWNED_DOCS) return null + if (listedCount === 0) return 'empty' + if ( + ownedCount >= SUSPECT_COLLAPSE_MIN_OWNED_DOCS && + listedCount < ownedCount * SUSPECT_COLLAPSE_MAX_RATIO + ) { + return 'collapsed' + } + return null +} + +/** + * Decides whether a suspect listing may still reconcile deletions. + * + * A suspect listing is only acted on once the *same* observation repeats on a + * consecutive sync, so a single transient upstream fault can never remove + * documents — not even reversibly, since a soft delete hides them from search + * immediately. A genuinely emptied source keeps reconciling: its second sync + * corroborates the first, tombstones everything, and the third sync completes + * the existing two-strike purge. + * + * A forced `fullSync` overrides the guard, matching its existing meaning + * elsewhere here — an explicit human request to reconcile against this listing + * right now. + */ +export function evaluateListingSafety( + listedCount: number, + ownedCount: number, + previous: PreviousListingObservation | null, + fullSync: boolean | undefined +): { reason: SuspectListingReason | null; blocked: boolean; corroborated: boolean } { + const reason = classifySuspectListing(listedCount, ownedCount) + if (!reason) return { reason: null, blocked: false, corroborated: false } + if (fullSync) return { reason, blocked: false, corroborated: false } + + const corroborated = Boolean( + previous?.trustworthy && classifySuspectListing(previous.listedCount, previous.ownedCount) + ) + return { reason, blocked: !corroborated, corroborated } +} + +/** + * Reconstructs the previous completed sync's listing from its log counters. + * + * No schema change is needed: every document the previous run listed landed in + * exactly one of added/updated/unchanged/failed, and `lastSyncDocCount` records + * how many documents the connector owned when that run finished. Documents the + * user excluded also land in `docsUnchanged`, which can only inflate the + * reconstructed listing — erring toward "the previous listing looked healthy", + * i.e. toward blocking deletions. + */ +async function loadPreviousListingObservation( + connectorId: string, + currentSyncLogId: string, + previousOwnedCount: number, + trustworthy: boolean +): Promise { + const rows = await db + .select({ + docsAdded: knowledgeConnectorSyncLog.docsAdded, + docsUpdated: knowledgeConnectorSyncLog.docsUpdated, + docsUnchanged: knowledgeConnectorSyncLog.docsUnchanged, + docsFailed: knowledgeConnectorSyncLog.docsFailed, + }) + .from(knowledgeConnectorSyncLog) + .where( + and( + eq(knowledgeConnectorSyncLog.connectorId, connectorId), + eq(knowledgeConnectorSyncLog.status, 'completed'), + ne(knowledgeConnectorSyncLog.id, currentSyncLogId) + ) + ) + .orderBy(desc(knowledgeConnectorSyncLog.startedAt)) + .limit(1) + + const previous = rows[0] + if (!previous) return null + + return { + listedCount: + previous.docsAdded + previous.docsUpdated + previous.docsUnchanged + previous.docsFailed, + ownedCount: previousOwnedCount, + trustworthy, + } +} + /** * Decides whether a sync should use the connector's incremental listing. * @@ -391,6 +533,9 @@ async function resolveAccessToken( ): Promise { if (connectorConfig.auth.mode === 'apiKey') { if (!connector.encryptedApiKey) { + if (connectorConfig.auth.optional) { + return '' + } throw new Error('API key connector is missing encrypted API key') } const { decrypted } = await decryptApiKey(connector.encryptedApiKey) @@ -1004,11 +1149,51 @@ export async function executeSync( options?.fullSync ) - const reconcileDeletionsAllowed = shouldReconcileDeletions( + let reconcileDeletionsAllowed = shouldReconcileDeletions( isIncremental, syncContext, options?.fullSync ) + + /** + * Backstop shared by every connector: a listing that reports (almost) + * nothing while this connector still owns a real corpus is treated as a + * fault, not as evidence of deletion, until a consecutive sync sees the + * same thing. Only evaluated when reconciliation would otherwise run, so + * healthy syncs pay nothing and no existing gate is loosened. + */ + const ownedDocCount = existingDocs.length + tombstonedDocs.length + if (reconcileDeletionsAllowed && classifySuspectListing(seenExternalIds.size, ownedDocCount)) { + const previousObservation = await loadPreviousListingObservation( + connectorId, + syncLogId, + connector.lastSyncDocCount ?? ownedDocCount, + !connectorConfig.supportsIncrementalSync || connector.syncMode === 'full' + ) + const listingSafety = evaluateListingSafety( + seenExternalIds.size, + ownedDocCount, + previousObservation, + options?.fullSync + ) + logger.warn('Suspect connector listing detected', { + connectorId, + connectorType: connector.connectorType, + reason: listingSafety.reason, + listedDocs: seenExternalIds.size, + ownedDocs: ownedDocCount, + liveDocs: existingDocs.length, + tombstonedDocs: tombstonedDocs.length, + previousListedDocs: previousObservation?.listedCount ?? null, + previousObservationTrusted: previousObservation?.trustworthy ?? false, + deletionReconciliation: listingSafety.blocked ? 'skipped' : 'proceeding', + syncRunId: syncContext.syncRunId, + }) + if (listingSafety.blocked) { + reconcileDeletionsAllowed = false + } + } + const gatedSoftDeleteIds = reconcileDeletionsAllowed ? softDeleteIds : [] const gatedHardDeleteIds = reconcileDeletionsAllowed ? hardDeleteIds : [] diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 16ca5d23d8c..750c2a41bcc 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -155,10 +155,10 @@ export async function performCreateKnowledgeConnector( let accessToken: string if (connectorConfig.auth.mode === 'apiKey') { - if (!apiKey) { + if (!apiKey && !connectorConfig.auth.optional) { return fail('API key is required', 'validation') } - accessToken = apiKey + accessToken = apiKey ?? '' } else { if (!credentialId) { return fail('Credential is required', 'validation') diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index b7f559da7e9..bee395eace1 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -403,60 +403,6 @@ export async function getTagDefinitionById( } } -/** - * Update tags on all documents and chunks when a tag value is changed - */ -async function updateTagValuesInDocumentsAndChunks( - knowledgeBaseId: string, - tagSlot: string, - oldValue: string | null, - newValue: string | null, - requestId: string -): Promise<{ documentsUpdated: number; chunksUpdated: number }> { - validateTagSlot(tagSlot) - - let documentsUpdated = 0 - let chunksUpdated = 0 - - await db.transaction(async (tx) => { - if (oldValue) { - await tx - .update(document) - .set({ - [tagSlot]: newValue, - }) - .where( - and( - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(sql.raw(`${document}.${tagSlot}`), oldValue) - ) - ) - documentsUpdated = 1 - } - - if (oldValue) { - await tx - .update(embedding) - .set({ - [tagSlot]: newValue, - }) - .where( - and( - eq(embedding.knowledgeBaseId, knowledgeBaseId), - eq(sql.raw(`${embedding}.${tagSlot}`), oldValue) - ) - ) - chunksUpdated = 1 - } - }) - - logger.info( - `[${requestId}] Updated tag values: ${documentsUpdated} documents, ${chunksUpdated} chunks` - ) - - return { documentsUpdated, chunksUpdated } -} - /** * Cleanup unused tag definitions for a knowledge base */ diff --git a/apps/sim/lib/library/registry.ts b/apps/sim/lib/library/registry.ts index 6d642dc88ab..1e1fa79bba1 100644 --- a/apps/sim/lib/library/registry.ts +++ b/apps/sim/lib/library/registry.ts @@ -13,5 +13,3 @@ export const getAllPostMeta = libraryRegistry.getAllPostMeta export const getPostBySlug = libraryRegistry.getPostBySlug export const getAllTags = libraryRegistry.getAllTags export const getRelatedPosts = libraryRegistry.getRelatedPosts -export const getNavLibraryPosts = libraryRegistry.getNavPosts -export const invalidateLibraryCaches = libraryRegistry.invalidateCaches diff --git a/apps/sim/lib/logs/execution/cancellation.test.ts b/apps/sim/lib/logs/execution/cancellation.test.ts index 4c2485cb08e..d37c5c28cf9 100644 --- a/apps/sim/lib/logs/execution/cancellation.test.ts +++ b/apps/sim/lib/logs/execution/cancellation.test.ts @@ -11,7 +11,9 @@ vi.unmock('@sim/db/schema') process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/test' const { PgDialect } = await import('drizzle-orm/pg-core') -const { cancelledExecutionLogFields } = await import('@/lib/logs/execution/cancellation') +const { cancelledExecutionLogFields, terminalExecutionLogFields } = await import( + '@/lib/logs/execution/cancellation' +) describe('cancelledExecutionLogFields', () => { /** @@ -47,3 +49,40 @@ describe('cancelledExecutionLogFields', () => { expect(params).toContain(endedAt.toISOString()) }) }) + +describe('terminalExecutionLogFields', () => { + /** + * The force-fail boundaries — `LoggingSession.markExecutionAsFailed` and + * `PauseResumeManager.markResumeFailed` — leave the same row behind as a + * cancellation, only under a different status. Only the status may differ. + */ + it('writes the cancellation field set under the failed status', () => { + const endedAt = new Date('2026-08-13T12:00:05.000Z') + + const failed = terminalExecutionLogFields('failed', endedAt) + + expect(Object.keys(failed).sort()).toEqual( + Object.keys(cancelledExecutionLogFields(endedAt)).sort() + ) + expect(failed.status).toBe('failed') + expect(failed.endedAt).toBe(endedAt) + expect(failed.executionDeadlineAt).toBeNull() + + const { params } = new PgDialect().sqlToQuery(failed.totalDurationMs) + expect(params).toContain(endedAt.toISOString()) + }) + + /** The cancellation call sites must keep emitting exactly what they did. */ + it('is what the cancellation binding emits', () => { + const endedAt = new Date('2026-08-13T12:00:05.000Z') + const dialect = new PgDialect() + + const bound = cancelledExecutionLogFields(endedAt) + const direct = terminalExecutionLogFields('cancelled', endedAt) + + expect({ ...bound, totalDurationMs: dialect.sqlToQuery(bound.totalDurationMs) }).toEqual({ + ...direct, + totalDurationMs: dialect.sqlToQuery(direct.totalDurationMs), + }) + }) +}) diff --git a/apps/sim/lib/logs/execution/cancellation.ts b/apps/sim/lib/logs/execution/cancellation.ts index 09e68df1d12..6191fb18747 100644 --- a/apps/sim/lib/logs/execution/cancellation.ts +++ b/apps/sim/lib/logs/execution/cancellation.ts @@ -1,23 +1,46 @@ import { elapsedDurationMsSql } from '@/lib/logs/execution/duration' /** - * The fields every terminal cancellation sets on a `workflow_execution_logs` - * row, ready to spread into `.set()`. + * The statuses a terminal write outside `completeWorkflowExecution` can land a + * `workflow_execution_logs` row on: cancellation, and the force-fail boundaries + * that bypass the completion path. + */ +type TerminalExecutionLogStatus = 'cancelled' | 'failed' + +/** + * The fields every terminal write outside `completeWorkflowExecution` sets on a + * `workflow_execution_logs` row, ready to spread into `.set()`. + * + * The cancellation paths — direct, workflow-group with and without a sidecar, + * paused, and the async cancel route — and the two force-fail boundaries — + * `LoggingSession.markExecutionAsFailed` and `PauseResumeManager.markResumeFailed` + * — differ in their database handle, their claim predicate, whether they read + * the row back, and what they do when the claim is lost, so they remain separate + * statements. What they must not differ in is the row they leave behind, and + * hand-assembling this payload at each one had already dropped + * `executionDeadlineAt` at a single cancellation site and both the end timestamp + * and the duration at both force-fail sites, leaving a terminal run still + * carrying the deadline of an attempt that had stopped running and invisible to + * every `minDurationMs`/`maxDurationMs` query on `GET /api/v2/logs`. * - * The five cancellation paths — direct, workflow-group with and without a - * sidecar, paused, and the async cancel route — differ in their database - * handle, their claim predicate, whether they read the row back, and what they - * do when the claim is lost, so they remain separate statements. What they must - * not differ in is the row they leave behind, and hand-assembling this payload - * at each one had already dropped `executionDeadlineAt` at a single site, - * leaving a cancelled run still carrying the deadline of an attempt that had - * stopped running. + * A duration a paused run already recorded still wins — that guard lives in + * `elapsedDurationMsSql`, keyed on the row's own status, so a force-fail landing + * on a still-`pending` paused row keeps the checkpoint duration rather than + * redefining it to include the time the run sat waiting. */ -export function cancelledExecutionLogFields(endedAt: Date) { +export function terminalExecutionLogFields( + status: TStatus, + endedAt: Date +) { return { - status: 'cancelled' as const, + status, endedAt, totalDurationMs: elapsedDurationMsSql(endedAt), executionDeadlineAt: null, } } + +/** {@link terminalExecutionLogFields} bound to the cancellation status. */ +export function cancelledExecutionLogFields(endedAt: Date) { + return terminalExecutionLogFields('cancelled', endedAt) +} diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index 172c08c419c..3b11cbce4c7 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -1,10 +1,21 @@ +/** + * @vitest-environment node + */ + +import { workflowExecutionLogs } from '@sim/db/schema' import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const dbMocks = vi.hoisted(() => ({ eq: vi.fn(), and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), + { + /** `elapsedDurationMsSql` binds `ended_at` through the column's own mapper. */ + param: vi.fn((value: unknown, encoder?: unknown) => ({ value, encoder })), + } + ), })) const { @@ -1683,6 +1694,38 @@ describe('LoggingSession.markExecutionAsFailed workflowId scoping', () => { expect(statusGuards).toHaveLength(1) }) + it('terminalizes the row it force-fails: end timestamp, derived duration, deadline cleared', async () => { + await LoggingSession.markExecutionAsFailed('exec-terminal', 'boom', undefined, 'wf-1') + + const payload = dbChainMockFns.set.mock.calls[0]?.[0] as { + level: string + status: string + endedAt: Date + totalDurationMs: { strings: TemplateStringsArray; values: unknown[] } + executionDeadlineAt: Date | null + executionData: unknown + } + expect(payload.level).toBe('error') + expect(payload.status).toBe('failed') + expect(payload.endedAt).toBeInstanceOf(Date) + expect(payload.executionDeadlineAt).toBeNull() + + /** + * The duration is the derived SQL fragment, not a number the caller carried + * in — `elapsedDurationMsSql` measures against the row's own `started_at` + * and preserves what a paused row already banked. + */ + expect(String(Array.from(payload.totalDurationMs.strings))).toContain("= 'pending' THEN ") + expect(payload.totalDurationMs.values).toContain(workflowExecutionLogs.totalDurationMs) + + /** + * The end instant is bound through `started_at`'s encoder specifically. + * `endedAt`'s encoder renders identically and would silently subtract the + * timestamp from itself — a duration of zero on every force-failed run. + */ + expect(dbMocks.sql.param).toHaveBeenCalledWith(payload.endedAt, workflowExecutionLogs.startedAt) + }) + it('clears Redis markers when marking failed (terminal boundary outside completeWorkflowExecution)', async () => { await LoggingSession.markExecutionAsFailed('exec-3', 'boom', undefined, 'wf-3') expect(clearProgressMarkersMock).toHaveBeenCalledWith('exec-3') diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index 9102684ee6f..8536910302e 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -8,6 +8,7 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attr import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { RESERVATION_TTL_BUFFER_MS } from '@/lib/core/execution-limits' import type { LargeValueStoreContext } from '@/lib/execution/payloads/store' +import { terminalExecutionLogFields } from '@/lib/logs/execution/cancellation' import type { SecretSafeBlockLog } from '@/lib/logs/execution/display-types' import { executionLogger } from '@/lib/logs/execution/logger' import { @@ -1545,7 +1546,11 @@ export class LoggingSession { await execDb .update(workflowExecutionLogs) - .set({ level: 'error', status: 'failed', executionDeadlineAt: null, executionData }) + .set({ + level: 'error', + ...terminalExecutionLogFields('failed', new Date()), + executionData, + }) .where( and( eq(workflowExecutionLogs.executionId, executionId), diff --git a/apps/sim/lib/logs/execution/progress-markers.ts b/apps/sim/lib/logs/execution/progress-markers.ts index 8499f9e9fed..32dcb11547e 100644 --- a/apps/sim/lib/logs/execution/progress-markers.ts +++ b/apps/sim/lib/logs/execution/progress-markers.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { isRecordLike as isRecord } from '@sim/utils/object' +import { isRecordLike } from '@sim/utils/object' import { getRedisClient } from '@/lib/core/config/redis' import { getExecutionReservationTtlMs } from '@/lib/core/execution-limits' import type { ExecutionLastCompletedBlock, ExecutionLastStartedBlock } from '@/lib/logs/types' @@ -167,7 +167,7 @@ function safeJsonParse(raw: string | undefined): unknown { */ function parseStartedMarker(raw: string | undefined): ExecutionLastStartedBlock | undefined { const v = safeJsonParse(raw) - if (!isRecord(v)) return undefined + if (!isRecordLike(v)) return undefined const { blockId, blockName, blockType, startedAt } = v if ( typeof blockId === 'string' && @@ -186,7 +186,7 @@ function parseStartedMarker(raw: string | undefined): ExecutionLastStartedBlock */ function parseCompletedMarker(raw: string | undefined): ExecutionLastCompletedBlock | undefined { const v = safeJsonParse(raw) - if (!isRecord(v)) return undefined + if (!isRecordLike(v)) return undefined const { blockId, blockName, blockType, endedAt, success } = v if ( typeof blockId === 'string' && diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 38d62dcdf8c..fc3306d597c 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' +import { isRecordLike, omit } from '@sim/utils/object' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs' @@ -343,9 +343,7 @@ export async function materializeExecutionDataForDisplayWithBlockOutputs( } function readRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined + return isRecordLike(value) ? (value as Record) : undefined } async function importResolvedSecretTraceRegistry( diff --git a/apps/sim/lib/logs/get-trigger-options.ts b/apps/sim/lib/logs/get-trigger-options.ts index 5ff23e804ca..b5f88ced199 100644 --- a/apps/sim/lib/logs/get-trigger-options.ts +++ b/apps/sim/lib/logs/get-trigger-options.ts @@ -10,14 +10,6 @@ export interface TriggerOption { let cachedTriggerOptions: TriggerOption[] | null = null let cachedTriggerMetadataMap: Map | null = null -/** - * Reset cache - useful for HMR in development or testing - */ -export function resetTriggerOptionsCache() { - cachedTriggerOptions = null - cachedTriggerMetadataMap = null -} - /** * Dynamically generates trigger filter options from the trigger registry and block definitions. * Results are cached after first call for performance (~98% faster on subsequent calls). diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index aa3a6c7643e..388aab8e4a6 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' /** * Provider-neutral HTTP client for the Claude Platform Managed Agents API. * @@ -743,7 +744,7 @@ export function parseSessionSnapshot(raw: unknown): SessionSnapshot { } if (typeof body.title === 'string') snapshot.title = body.title - if (body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)) { + if (isRecordLike(body.metadata)) { const metadata: Record = {} for (const [key, value] of Object.entries(body.metadata as Record)) { if (typeof value === 'string') metadata[key] = value diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 9c2475a9471..7a32eb8292b 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -8,7 +8,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' -import { isTest } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' @@ -1226,24 +1225,3 @@ class McpService { } export const mcpService = new McpService() - -/** - * Setup process signal handlers for graceful shutdown - */ -export function setupMcpServiceCleanup() { - if (isTest) { - return - } - - const cleanup = () => { - mcpService.dispose() - } - - process.on('SIGTERM', cleanup) - process.on('SIGINT', cleanup) - - return () => { - process.removeListener('SIGTERM', cleanup) - process.removeListener('SIGINT', cleanup) - } -} diff --git a/apps/sim/lib/mcp/storage/factory.ts b/apps/sim/lib/mcp/storage/factory.ts index ad15af22fc4..cdbb51df8d1 100644 --- a/apps/sim/lib/mcp/storage/factory.ts +++ b/apps/sim/lib/mcp/storage/factory.ts @@ -40,14 +40,3 @@ export function getMcpCacheType(): 'redis' | 'memory' { const redis = getRedisClient() return redis ? 'redis' : 'memory' } - -/** - * Reset the cached adapter. - * Only use for testing purposes. - */ -export function resetMcpCacheAdapter(): void { - if (cachedAdapter) { - cachedAdapter.dispose() - cachedAdapter = null - } -} diff --git a/apps/sim/lib/mcp/workflow-tool-schema.ts b/apps/sim/lib/mcp/workflow-tool-schema.ts index 3147901000d..31b801d3f1a 100644 --- a/apps/sim/lib/mcp/workflow-tool-schema.ts +++ b/apps/sim/lib/mcp/workflow-tool-schema.ts @@ -281,23 +281,6 @@ export function getMeaningfulWorkflowDescription( return trimmed } -/** - * Generate a complete MCP tool definition from workflow metadata and input format. - */ -export function generateToolDefinition( - workflowName: string, - workflowDescription: string | undefined | null, - inputFormat: InputFormatField[], - customToolName?: string, - customDescription?: string -): McpToolDefinition { - return { - name: customToolName || sanitizeToolName(workflowName), - description: customDescription || workflowDescription || `Execute ${workflowName} workflow`, - inputSchema: generateToolInputSchema(inputFormat), - } -} - /** * Extract input format from a workflow's blocks. * Looks for any valid start block and extracts its inputFormat configuration. diff --git a/apps/sim/lib/messaging/sms/service.ts b/apps/sim/lib/messaging/sms/service.ts index 831c8e8fdea..eb07441e96e 100644 --- a/apps/sim/lib/messaging/sms/service.ts +++ b/apps/sim/lib/messaging/sms/service.ts @@ -146,40 +146,3 @@ async function sendSingleSMS(to: string, body: string, from: string): Promise { - try { - const results: SendSMSResult[] = [] - - logger.info('Sending batch SMS messages') - for (const smsOptions of options.messages) { - try { - const result = await sendSMS(smsOptions) - results.push(result) - } catch (error) { - results.push({ - success: false, - message: getErrorMessage(error, 'Failed to send SMS'), - }) - } - } - - const successCount = results.filter((r) => r.success).length - return { - success: successCount === results.length, - message: - successCount === results.length - ? 'All batch SMS messages sent successfully' - : `${successCount}/${results.length} SMS messages sent successfully`, - results, - data: { count: successCount }, - } - } catch (error) { - logger.error('Error in batch SMS sending:', error) - return { - success: false, - message: 'Failed to send batch SMS messages', - results: [], - } - } -} diff --git a/apps/sim/lib/mothership/inbox/agentmail-client.ts b/apps/sim/lib/mothership/inbox/agentmail-client.ts index 1f1abbfb51f..3f7a0dfe5e4 100644 --- a/apps/sim/lib/mothership/inbox/agentmail-client.ts +++ b/apps/sim/lib/mothership/inbox/agentmail-client.ts @@ -69,10 +69,6 @@ export async function deleteInbox(inboxId: string): Promise { }) } -export async function getInbox(inboxId: string): Promise { - return request(`/inboxes/${encodeURIComponent(inboxId)}`) -} - export async function createWebhook(opts: { url: string eventTypes: string[] diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 7a1cf470d4b..84eaf0fc674 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -114,6 +114,17 @@ export async function resolveOAuthAccountId( } } + if (credentialRow.type === 'managed_oauth') { + return { + accountId: '', + credentialId: credentialRow.id, + credentialType: 'managed_oauth', + workspaceId: credentialRow.workspaceId, + providerId: credentialRow.providerId ?? undefined, + usedCredentialTable: true, + } + } + if (credentialRow.type !== 'oauth' || !credentialRow.accountId) { return null } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index a98e9361e65..125f3de697c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -259,6 +259,11 @@ export const OAUTH_PROVIDERS: Record = { 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/ediscovery', + // Least-privilege scope for read-only consumers. The knowledge base + // connector only lists matters, holds, and saved queries, all of which + // accept ediscovery.readonly; the block's export tools still need the + // read-write scope above. + 'https://www.googleapis.com/auth/ediscovery.readonly', 'https://www.googleapis.com/auth/devstorage.read_only', ], serviceAccountProviderId: 'google-service-account', @@ -1184,6 +1189,13 @@ export const OAUTH_PROVIDERS: Record = { 'Desk.tickets.READ', 'Desk.tickets.UPDATE', 'Desk.contacts.READ', + // READ only: the knowledge base connector syncs Help Center articles + // via GET /articles and GET /articles/{id}; nothing authors one. + 'Desk.articles.READ', + // GET /organizations documents `Desk.organization.READ , Desk.basic.READ`. + // Sibling endpoints spell the same construction "requires X and Y" + // (dependencyMappings, roles), so the comma is AND, not OR. + 'Desk.organization.READ', // READ only: the agent picker for `assigneeId` lists agents, and no // tool creates, edits or deletes one. 'Desk.agents.READ', @@ -1991,7 +2003,6 @@ export async function refreshOAuthToken( hasClientId: !!config.clientId, hasClientSecret: !!config.clientSecret, hasRefreshToken: !!refreshToken, - refreshTokenPrefix: refreshToken ? `${refreshToken.substring(0, 10)}...` : 'none', }) return { ok: false, diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 4ee18823f8b..298ccd29592 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -9,6 +9,7 @@ import type { AuthResult } from '@/lib/auth/hybrid' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getCredential, + type ResolvedCredential, refreshTokenIfNeeded, resolveOAuthAccountId, resolveServiceAccountToken, @@ -45,6 +46,8 @@ export interface ResolveCredentialTokenInput { */ callerUserId?: string auditRequest?: CredentialAuditRequest + /** Reuses a credential lookup already performed by the route's managed-OAuth dispatch. */ + resolvedCredential?: ResolvedCredential | null } export type ResolveCredentialTokenResult = @@ -187,7 +190,9 @@ export async function resolveCredentialToken( * on the other, so they resolve together — this runs per credentialed tool call. */ const [resolved, authz] = await Promise.all([ - resolveOAuthAccountId(credentialId), + input.resolvedCredential === undefined + ? resolveOAuthAccountId(credentialId) + : input.resolvedCredential, authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), ]) diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index eef2b12d759..ecd0846ccdb 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -36,6 +36,8 @@ export const SCOPE_DESCRIPTIONS: Record = { 'https://www.googleapis.com/auth/adwords': 'Manage Google Ads campaigns and reporting', 'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery', 'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery', + 'https://www.googleapis.com/auth/ediscovery.readonly': + 'View Google Vault matters, holds, and saved queries', 'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage', 'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups', 'https://www.googleapis.com/auth/admin.directory.group.member': @@ -686,12 +688,27 @@ export function getMissingRequiredScopes( for (const s of requiredScopes) { if (IGNORED_SCOPES.has(s)) continue - if (!granted.has(s)) missing.push(s) + if (!granted.has(s) && !isScopeSatisfiedBy(s, granted)) missing.push(s) } return missing } +/** + * Whether a granted scope already covers `required` despite not matching it verbatim. + * + * A read-write scope subsumes its `.readonly` sibling — a credential holding + * `.../auth/ediscovery` is accepted by every method that documents + * `.../auth/ediscovery.readonly`. Without this, narrowing a consumer to the + * least-privileged scope would report every already-connected credential as + * missing it and prompt a re-consent that grants nothing new. + */ +function isScopeSatisfiedBy(required: string, granted: ReadonlySet): boolean { + const readonlySuffix = '.readonly' + if (!required.endsWith(readonlySuffix)) return false + return granted.has(required.slice(0, -readonlySuffix.length)) +} + /** * Build a mapping of providerId -> { baseProvider, serviceKey } from OAUTH_PROVIDERS * This is computed once at module load time diff --git a/apps/sim/lib/permission-groups/operation-access.test.ts b/apps/sim/lib/permission-groups/operation-access.test.ts new file mode 100644 index 00000000000..457cd0815dc --- /dev/null +++ b/apps/sim/lib/permission-groups/operation-access.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + collectDeniedOperationIds, + isOperationAllowed, + type OperationGateBlock, + pickDefaultOperation, +} from '@/lib/permission-groups/operation-access' + +/** A block that resolves its tool from the operation, like most integrations. */ +const selectorBlock: OperationGateBlock = { + tools: { + access: ['slack_message', 'slack_canvas', 'slack_read'], + config: { + tool: (params) => { + const map: Record = { + send: 'slack_message', + canvas: 'slack_canvas', + read: 'slack_read', + } + const toolId = map[params.operation as string] + if (!toolId) throw new Error(`unknown operation: ${params.operation}`) + return toolId + }, + }, + }, +} + +/** A block with no selector, whose operation ids are its tool ids. */ +const bareBlock: OperationGateBlock = { + tools: { access: ['sqs_send', 'sqs_receive'] }, +} + +const singleToolBlock: OperationGateBlock = { + tools: { access: ['dropcontact_enrich_contact'] }, +} + +const denyAll = () => false +const allowAll = () => true +const deny = (...toolIds: string[]) => { + const denied = new Set(toolIds) + return (toolId: string) => !denied.has(toolId) +} + +describe('operation-to-tool resolution', () => { + it('resolves through the block tool selector', () => { + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_canvas'))).toBe(false) + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_message'))).toBe(true) + }) + + it('gates on the only tool when the block has no selection to make', () => { + expect( + isOperationAllowed(singleToolBlock, 'anything', deny('dropcontact_enrich_contact')) + ).toBe(false) + }) + + it('treats an operation id as a tool id when the block has no selector', () => { + expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_receive'))).toBe(false) + expect(isOperationAllowed(bareBlock, 'sqs_receive', deny('sqs_send'))).toBe(true) + }) + + it('allows rather than guessing when a block has no tools at all', () => { + expect(isOperationAllowed({ tools: { access: [] } }, 'send', denyAll)).toBe(true) + expect(isOperationAllowed(null, 'send', denyAll)).toBe(true) + expect(isOperationAllowed(undefined, 'send', denyAll)).toBe(true) + }) +}) + +describe('isOperationAllowed', () => { + it('denies an operation whose tool the group denies', () => { + expect(isOperationAllowed(selectorBlock, 'canvas', deny('slack_canvas'))).toBe(false) + expect(isOperationAllowed(selectorBlock, 'send', deny('slack_canvas'))).toBe(true) + }) + + it('allows an unresolvable operation, leaving the server as the gate', () => { + expect(isOperationAllowed(selectorBlock, 'not-an-operation', denyAll)).toBe(true) + }) +}) + +describe('collectDeniedOperationIds', () => { + it('collects only the operations whose tools are denied', () => { + const denied = collectDeniedOperationIds( + selectorBlock, + ['send', 'canvas', 'read'], + deny('slack_message', 'slack_read') + ) + expect([...denied]).toEqual(['send', 'read']) + }) + + it('is empty when nothing is denied', () => { + expect(collectDeniedOperationIds(selectorBlock, ['send', 'canvas'], allowAll).size).toBe(0) + }) +}) + +describe('pickDefaultOperation', () => { + const candidates = ['send', 'canvas', 'read'] + + it('keeps the preferred operation when the group allows it', () => { + expect(pickDefaultOperation(selectorBlock, candidates, allowAll, 'canvas')).toBe('canvas') + }) + + it('falls back to the first allowed operation when the preferred one is denied', () => { + expect(pickDefaultOperation(selectorBlock, candidates, deny('slack_message'), 'send')).toBe( + 'canvas' + ) + }) + + it('takes the first allowed operation when there is no preference', () => { + expect(pickDefaultOperation(selectorBlock, candidates, deny('slack_message'))).toBe('canvas') + }) + + it('returns undefined when every candidate is denied', () => { + expect( + pickDefaultOperation( + selectorBlock, + candidates, + deny('slack_message', 'slack_canvas', 'slack_read'), + 'send' + ) + ).toBeUndefined() + }) + + it('keeps a preferred operation it cannot resolve, matching the permissive gate', () => { + expect(pickDefaultOperation(selectorBlock, candidates, denyAll, 'not-an-operation')).toBe( + 'not-an-operation' + ) + }) +}) diff --git a/apps/sim/lib/permission-groups/operation-access.ts b/apps/sim/lib/permission-groups/operation-access.ts new file mode 100644 index 00000000000..6856cb06976 --- /dev/null +++ b/apps/sim/lib/permission-groups/operation-access.ts @@ -0,0 +1,127 @@ +import type { BlockConfig } from '@/blocks/types' + +/** + * The subblock id that carries a block's operation. + * + * Singular only. Four blocks (`elasticsearch`, `mailchimp`, `onepassword`, + * `typeform`) also declare an `operations` subblock, but it holds a JSON-patch + * payload rather than an operation selector — matching it could only ever key + * a gate off the wrong value. + */ +export const OPERATION_SUBBLOCK_ID = 'operation' + +export const MODEL_SUBBLOCK_ID = 'model' + +/** Shared empty result, so a caller's memo sees a stable identity. */ +export const NO_DENIED_OPERATIONS: ReadonlySet = new Set() + +/** The slice of a block config the operation gate reads. */ +export type OperationGateBlock = Pick + +/** Decides whether the caller's permission group allows a concrete tool id. */ +export type IsToolAllowed = (toolId: string) => boolean + +/** + * Vetoes a subblock's declared default when the caller's permission group does + * not allow it — or when the group config is not known yet, since a default + * written during block creation is never revisited. + */ +export type SeedValueGate = (subBlockId: string, value: string) => boolean + +/** + * The tool id a block operation maps to, or `null` when it cannot be resolved + * from the operation alone. + * + * Deliberately not `getToolIdForOperation` from `@/tools/params`: that one ends + * with an unconditional `access[0]` fallback, which for a gate would authorize + * an unrecognized operation against a tool it has nothing to do with. It also + * logs on every selector throw, and this runs once per option of every block + * offered. + * + * Never guesses. A selector that also reads sibling fields throws when handed + * an operation on its own, and an operation the block does not recognize has + * no tool — both yield `null`, which callers read as "not gateable here". The + * server-side gate in `assertPermissionsAllowed` stays authoritative either + * way, so a `null` only ever costs a denied option staying visible, never a + * permitted option disappearing. + */ +function resolveOperationToolId( + block: OperationGateBlock | null | undefined, + operationId: string +): string | null { + const access = block?.tools?.access + if (!access || access.length === 0) return null + + /* One tool means there is nothing to select: the block runs that tool + whatever its operation dropdown says. */ + if (access.length === 1) return access[0] + + const selectTool = block?.tools?.config?.tool + if (selectTool) { + try { + const toolId = selectTool({ operation: operationId }) + if (toolId) return toolId + } catch { + /* Unresolvable from the operation alone; see the TSDoc above. */ + } + } + + /* Blocks with no selector list their tool ids as their operation ids. */ + return access.includes(operationId) ? operationId : null +} + +/** Whether the caller's permission group allows a block operation. */ +export function isOperationAllowed( + block: OperationGateBlock | null | undefined, + operationId: string, + isToolAllowed: IsToolAllowed +): boolean { + const toolId = resolveOperationToolId(block, operationId) + if (!toolId) return true + return isToolAllowed(toolId) +} + +/** + * The operation ids of `block` whose tool the caller's permission group denies. + * + * Denied operations are hidden from pickers rather than removed from the model, + * so a workflow that already references one keeps resolving its label. + */ +export function collectDeniedOperationIds( + block: OperationGateBlock | null | undefined, + operationIds: Iterable, + isToolAllowed: IsToolAllowed +): ReadonlySet { + const denied = new Set() + for (const operationId of operationIds) { + if (!isOperationAllowed(block, operationId, isToolAllowed)) { + denied.add(operationId) + } + } + return denied +} + +/** + * The operation an unset field should be seeded with: `preferred` when the + * group allows it, otherwise the first candidate it does allow. + * + * Returns `undefined` when every candidate is denied. `useOperationAccess` + * additionally returns `undefined` while the permission config is loading, so a + * caller that seeds only on a defined value can never persist a denied default. + */ +export function pickDefaultOperation( + block: OperationGateBlock | null | undefined, + candidates: Iterable, + isToolAllowed: IsToolAllowed, + preferred?: string +): string | undefined { + if (preferred !== undefined && isOperationAllowed(block, preferred, isToolAllowed)) { + return preferred + } + + for (const candidate of candidates) { + if (isOperationAllowed(block, candidate, isToolAllowed)) return candidate + } + + return undefined +} diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 3b1a9049bfe..15bdb9773f9 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -16,10 +16,6 @@ export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { groupUser: 'permission_group_member_group_user_unique', } as const -export const PERMISSION_GROUP_WORKSPACE_CONSTRAINTS = { - groupWorkspace: 'permission_group_workspace_group_workspace_unique', -} as const - export const permissionGroupConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable().optional(), allowedModelProviders: z.array(z.string()).nullable().optional(), diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index c822757165b..c0d31b0d5c7 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -334,25 +334,45 @@ export interface PostHogEventMap { } credential_connected: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id: string } credential_deleted: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id: string } credential_shared: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' role: 'admin' | 'member' workspace_id: string } credential_unshared: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' workspace_id: string } @@ -733,7 +753,12 @@ export interface PostHogEventMap { /** A stored credential's plaintext secret was deliberately retrieved via the token API. */ credential_used: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id?: string } diff --git a/apps/sim/lib/pptx-renderer/parser/units.ts b/apps/sim/lib/pptx-renderer/parser/units.ts index e8782fa2bf9..1dd7676db89 100644 --- a/apps/sim/lib/pptx-renderer/parser/units.ts +++ b/apps/sim/lib/pptx-renderer/parser/units.ts @@ -14,11 +14,6 @@ export function emuToPx(emu: number): number { return (emu / 914400) * 96 } -/** EMU to points. */ -export function emuToPt(emu: number): number { - return emu / 12700 -} - /** OOXML angle (60000ths of a degree) to degrees. */ export function angleToDeg(angle: number): number { return angle / 60000 @@ -29,11 +24,6 @@ export function pctToDecimal(pct: number): number { return pct / 100000 } -/** Hundredths of a point to points (used for font sizes in OOXML). */ -export function hundredthPtToPt(val: number): number { - return val / 100 -} - /** Points to pixels (at 96 DPI). */ export function ptToPx(pt: number): number { return (pt * 96) / 72 @@ -46,14 +36,3 @@ export function ptToPx(pt: number): number { export function detectUnit(value: number): 'emu' | 'point' { return Math.abs(value) > 20000 ? 'emu' : 'point' } - -/** - * Smart conversion to pixels: auto-detects whether the value is EMU or points - * and converts accordingly. - */ -export function smartToPx(value: number): number { - if (detectUnit(value) === 'emu') { - return emuToPx(value) - } - return ptToPx(value) -} diff --git a/apps/sim/lib/pptx-renderer/renderer/predefined-table-styles.ts b/apps/sim/lib/pptx-renderer/renderer/predefined-table-styles.ts index 818b9088549..a338323b9ca 100644 --- a/apps/sim/lib/pptx-renderer/renderer/predefined-table-styles.ts +++ b/apps/sim/lib/pptx-renderer/renderer/predefined-table-styles.ts @@ -783,11 +783,3 @@ export function getPredefinedTableStyle(styleId: string): SafeXmlNode | undefine cache.set(styleId, node) return node } - -/** Exported for testing: number of known predefined style UUIDs. */ -export const PREDEFINED_STYLE_COUNT = styleIdMap.size - -/** Exported for testing: all known style IDs. */ -export function getAllPredefinedStyleIds(): string[] { - return Array.from(styleIdMap.keys()) -} diff --git a/apps/sim/lib/pptx-renderer/shapes/presets.ts b/apps/sim/lib/pptx-renderer/shapes/presets.ts index 00f969a87ce..250bc0b155c 100644 --- a/apps/sim/lib/pptx-renderer/shapes/presets.ts +++ b/apps/sim/lib/pptx-renderer/shapes/presets.ts @@ -26,24 +26,6 @@ function adj( return raw / 100000 } -/** Helper: generate a regular polygon path (inscribed in bounding box). */ -function _regularPolygon(w: number, h: number, sides: number): string { - const cx = w / 2 - const cy = h / 2 - const rx = w / 2 - const ry = h / 2 - const parts: string[] = [] - for (let i = 0; i < sides; i++) { - // Start from top center (-90 degrees) - const angle = (2 * Math.PI * i) / sides - Math.PI / 2 - const x = cx + rx * Math.cos(angle) - const y = cy + ry * Math.sin(angle) - parts.push(i === 0 ? `M${x},${y}` : `L${x},${y}`) - } - parts.push('Z') - return parts.join(' ') -} - /** Raw adj helper: get adjustment value without dividing by 100000. */ function adjRaw( adjustments: Map | undefined, @@ -4420,21 +4402,6 @@ presetOverlays.set('can', (w, h) => { ] }) -/** - * Get overlay paths for a preset shape (3D top faces, etc.). - * Returns empty array if the shape has no overlays. - */ -export function getPresetOverlays( - shapeType: string, - w: number, - h: number, - adjustments?: Map -): PresetOverlay[] { - const key = shapeType.toLowerCase() - const gen = presetOverlays.get(key) ?? presetOverlays.get(shapeType) - return gen ? gen(w, h, adjustments) : [] -} - // Multi-path preset shapes — complex shapes with multiple SVG paths // Each path has its own fill modifier and stroke behavior, matching OOXML spec. diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index 17cee4f63a1..4a87854b7fb 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -219,6 +219,21 @@ describe('queryRows byte budget', () => { updatedAt: new Date('2024-01-01'), }) + const mockRowsPastFormerBatchSafetyLimit = () => { + const largeRow = row(1, TABLE_LIMITS.MAX_ROW_SIZE_BYTES) + const smallRow = row(2, 0) + const state = { drainBatch: 0 } + dbChainMockFns.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockImplementation(async (ask: number) => { + state.drainBatch++ + if (state.drainBatch > 1001) return [] + const rows = Array.from({ length: ask }, () => smallRow) + if (state.drainBatch === 1) rows[0] = largeRow + return rows + }) + return state + } + it('returns an empty page with a null cursor', async () => { const result = await queryRows(TABLE, { includeTotal: false, withExecutions: false }, 'req-1') expect(result.rows).toEqual([]) @@ -246,6 +261,16 @@ describe('queryRows byte budget', () => { expect(result.nextCursor).toBeNull() }) + it('returns an entire under-budget result past the former batch safety limit', async () => { + const state = mockRowsPastFormerBatchSafetyLimit() + + const result = await queryRows(TABLE, { includeTotal: false, withExecutions: false }, 'req-1') + + expect(state.drainBatch).toBe(1002) + expect(result.rows.length).toBeGreaterThan(TABLE_LIMITS.MAX_QUERY_LIMIT) + expect(result.nextCursor).toBeNull() + }) + it('byte-cuts a BOUNDED page and returns a resume cursor instead of throwing', async () => { const perRow = Math.floor(TABLE_LIMITS.MAX_QUERY_RESULT_BYTES * 0.6) dbChainMockFns.limit.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index 56a2118438c..8623b06f7cf 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -1038,3 +1038,292 @@ describe('legacy compiler rejects a v2 predicate (version-mismatch fail-fast)', ).not.toThrow() }) }) + +/** + * Equality/membership compiles to exact JSONB containment, which is untyped: + * `{"score":8} @> {"score":"8"}` is simply FALSE. Before the operand was read + * through the column type, a wrongly-typed `eq`/`ne`/`in`/`nin` returned an + * empty 200 that a caller could not tell apart from a genuinely empty table — + * while the range operators on the same column answered with a descriptive 400. + */ +describe('containment operators — operand is read through the column type', () => { + const NUM: ColumnDefinition[] = [{ id: 'score', name: 'score', type: 'number' }] + const BOOL: ColumnDefinition[] = [{ id: 'flag', name: 'flag', type: 'boolean' }] + const STR: ColumnDefinition[] = [{ id: 'title', name: 'title', type: 'string' }] + const DATE: ColumnDefinition[] = [{ id: 'due', name: 'due', type: 'date' }] + const MONEY: ColumnDefinition[] = [{ id: 'price', name: 'price', type: 'currency' }] + + describe('coerces an unambiguous operand the way a write would', () => { + it.each([ + ['eq', { all: [{ field: 'score', op: 'eq', value: '8' }] }, '"score":8'], + ['ne', { all: [{ field: 'score', op: 'ne', value: '8' }] }, '"score":8'], + ['in', { all: [{ field: 'score', op: 'in', value: ['8'] }] }, '"score":8'], + ['nin', { all: [{ field: 'score', op: 'nin', value: ['8'] }] }, '"score":8'], + ] as Array<[string, TablePredicate, string]>)('%s on a number column', (_op, p, expected) => { + const out = render(buildPredicateClause(p, TABLE, NUM)) + expect(out).toContain(expected) + expect(out).not.toContain('"score":"8"') + }) + + it('reads "false" as the boolean false', () => { + const p: TablePredicate = { all: [{ field: 'flag', op: 'eq', value: 'false' }] } + const out = render(buildPredicateClause(p, TABLE, BOOL)) + expect(out).toContain('"flag":false') + expect(out).not.toContain('"flag":"false"') + }) + + it('reads a number as text on a string column', () => { + const p: TablePredicate = { all: [{ field: 'title', op: 'eq', value: 8 }] } + const out = render(buildPredicateClause(p, TABLE, STR)) + expect(out).toContain('"title":"8"') + }) + + it('reads a formatted amount on a currency column', () => { + const p: TablePredicate = { all: [{ field: 'price', op: 'eq', value: '$1,234.56' }] } + const out = render(buildPredicateClause(p, TABLE, MONEY)) + expect(out).toContain('"price":1234.56') + }) + + it('applies to the legacy $-grammar too', () => { + const out = render(buildFilterClause({ score: { $eq: '8' } }, TABLE, NUM)) + expect(out).toContain('"score":8') + expect(out).not.toContain('"score":"8"') + }) + + it('applies to the legacy equality shorthand', () => { + const out = render(buildFilterClause({ score: '8' }, TABLE, NUM)) + expect(out).toContain('"score":8') + expect(out).not.toContain('"score":"8"') + }) + }) + + /** + * Coercion is best-effort, never fatal. The v2 predicate grammar is not + * operand-type-checked at the boundary (leaf `value` is `z.unknown()`), so a + * throw here would land inside the background runners that compile the same + * predicate later — including a filter-scoped cancel, which would leave those + * cells uncancellable. An operand the column type refuses therefore compiles + * exactly as it did before: byte-exact, matching nothing. + */ + describe('passes through an operand the column could never hold', () => { + it.each(['eq', 'ne'] as const)('%s with an unparseable number', (op) => { + const p = { all: [{ field: 'score', op, value: 'eight' }] } as TablePredicate + const out = render(buildPredicateClause(p, TABLE, NUM)) + expect(out).toContain('"score":"eight"') + }) + + it.each(['in', 'nin'] as const)('%s with a bad element', (op) => { + const p = { all: [{ field: 'score', op, value: ['eight'] }] } as TablePredicate + expect(render(buildPredicateClause(p, TABLE, NUM))).toContain('"score":"eight"') + }) + + it.each(['in', 'nin'] as const)('%s mixing coercible and uncoercible members', (op) => { + const p = { all: [{ field: 'score', op, value: ['8', 'eight'] }] } as TablePredicate + let out = '' + expect(() => { + out = render(buildPredicateClause(p, TABLE, NUM)) + }).not.toThrow() + expect(out).toContain('"score":8') + expect(out).toContain('"score":"eight"') + }) + + it('passes through a non-boolean on a boolean column', () => { + const p: TablePredicate = { all: [{ field: 'flag', op: 'eq', value: 'yes' }] } + expect(render(buildPredicateClause(p, TABLE, BOOL))).toContain('"flag":"yes"') + }) + + it('passes through an unparseable date', () => { + const p: TablePredicate = { all: [{ field: 'due', op: 'eq', value: 'not-a-date' }] } + expect(render(buildPredicateClause(p, TABLE, DATE))).toContain('"due":"not-a-date"') + }) + + it('passes through an object on a string column', () => { + const p = { + all: [{ field: 'title', op: 'eq', value: { a: 1 } }], + } as unknown as TablePredicate + expect(() => buildPredicateClause(p, TABLE, STR)).not.toThrow() + }) + + it('passes through on the legacy $-grammar too', () => { + expect(render(buildFilterClause({ score: { $eq: 'eight' } }, TABLE, NUM))).toContain( + '"score":"eight"' + ) + }) + }) + + /** + * `date` is excluded from containment coercion because `date.coerce` is not + * idempotent — `normalizeDateCellValue` drops the sub-second part, so the + * `.000Z` form the write path stores would be rewritten to a string that no + * longer matches the stored bytes. The same leaf compiles the unique and + * upsert probes, whose operands were already coerced once upstream, so a + * rewrite there would silently admit duplicates inside the write transaction. + */ + describe('never rewrites a date operand', () => { + it.each(['eq', 'ne'] as const)('%s keeps the stored .000Z form byte-exact', (op) => { + const p = { + all: [{ field: 'due', op, value: '2024-01-31T10:00:00.000Z' }], + } as TablePredicate + const out = render(buildPredicateClause(p, TABLE, DATE)) + expect(out).toContain('"due":"2024-01-31T10:00:00.000Z"') + expect(out).not.toContain('"due":"2024-01-31T10:00:00Z"') + }) + + it.each(['in', 'nin'] as const)('%s keeps every member byte-exact', (op) => { + const p = { + all: [{ field: 'due', op, value: ['2024-01-31T10:00:00.000Z', ' 2024-01-31 '] }], + } as TablePredicate + const out = render(buildPredicateClause(p, TABLE, DATE)) + expect(out).toContain('"due":"2024-01-31T10:00:00.000Z"') + expect(out).toContain('"due":" 2024-01-31 "') + }) + + it('does not trim or normalize a loose date operand', () => { + const p: TablePredicate = { all: [{ field: 'due', op: 'eq', value: ' 2024-01-31 ' }] } + const out = render(buildPredicateClause(p, TABLE, DATE)) + expect(out).toContain('"due":" 2024-01-31 "') + expect(out).not.toContain('"due":"2024-01-31"') + }) + + it('keeps the legacy $-grammar byte-exact too', () => { + const out = render( + buildFilterClause({ due: { $eq: '2024-01-31T10:00:00.000Z' } }, TABLE, DATE) + ) + expect(out).toContain('"due":"2024-01-31T10:00:00.000Z"') + }) + }) + + describe('leaves the operands that are not type assertions alone', () => { + it('keeps null — a real containment query for a JSON-null cell', () => { + const p: TablePredicate = { all: [{ field: 'score', op: 'eq', value: null }] } + expect(render(buildPredicateClause(p, TABLE, NUM))).toContain('"score":null') + }) + + it('keeps the empty string — the cleared-cell sentinel', () => { + const p: TablePredicate = { all: [{ field: 'score', op: 'eq', value: '' }] } + expect(render(buildPredicateClause(p, TABLE, NUM))).toContain('"score":""') + }) + + it('leaves a field with no schema entry untouched', () => { + const p: TablePredicate = { all: [{ field: 'adhoc', op: 'eq', value: '8' }] } + expect(render(buildPredicateClause(p, TABLE, NO_COLUMNS))).toContain('"adhoc":"8"') + }) + + /** + * `select` is excluded from containment coercion wholesale, and an operand + * that is already a declared option id cannot show that: `select.coerce` + * resolves it to itself, so the clause is identical with or without the + * exclusion. What discriminates is an operand `select.coerce` would + * *rewrite* — an option **name**, which `resolveSelectCellValue` turns into + * the option id. Names are already resolved upstream by + * `resolvePredicateSelectValues`, so a second resolution here is a rewrite + * of an operand that was deliberately left alone. + */ + describe('leaves a select column to its own name→id resolution', () => { + const statusCol: ColumnDefinition = { + id: 'col_status', + name: 'status', + type: 'select', + options: [{ id: 'opt_open', name: 'Open' }], + } + + it('keeps a declared option id', () => { + const p: TablePredicate = { all: [{ field: 'col_status', op: 'eq', value: 'opt_open' }] } + expect(render(buildPredicateClause(p, TABLE, [statusCol]))).toContain('"opt_open"') + }) + + it('does not re-resolve an option name into its id', () => { + const p: TablePredicate = { all: [{ field: 'col_status', op: 'eq', value: 'Open' }] } + const out = render(buildPredicateClause(p, TABLE, [statusCol])) + expect(out).toContain('"col_status":"Open"') + expect(out).not.toContain('"col_status":"opt_open"') + }) + + it('does not re-resolve names inside an $in list', () => { + const p: TablePredicate = { + all: [{ field: 'col_status', op: 'in', value: ['Open', 'opt_open'] }], + } + const out = render(buildPredicateClause(p, TABLE, [statusCol])) + expect(out).toContain('"col_status":"Open"') + }) + + /** + * A filter for an option deleted since the row was written must still + * compile — the row still stores the id, and the operand reaches the + * clause byte-exact rather than being dropped or refused. + */ + it('keeps an option id no longer in options', () => { + const p: TablePredicate = { all: [{ field: 'col_status', op: 'eq', value: 'opt_ghost' }] } + let out = '' + expect(() => { + out = render(buildPredicateClause(p, TABLE, [statusCol])) + }).not.toThrow() + expect(out).toContain('"col_status":"opt_ghost"') + }) + }) + }) +}) + +/** + * Filters reach the SQL builders storage-keyed — the boundaries translate + * column name → column id first — so a message interpolating the raw field + * reported a `col_…` id the caller never sent and cannot look up. + */ +describe('error messages name the caller-facing column, not the storage id', () => { + const multi: ColumnDefinition = { + id: 'col_934cea93275d46448b0d6c001554e146', + name: 'untitled_2', + type: 'select', + multiple: true, + options: [{ id: 'opt_a', name: 'Alpha' }], + } + const num: ColumnDefinition = { id: 'col_abc123', name: 'overall_score', type: 'number' } + const bool: ColumnDefinition = { id: 'col_def456', name: 'untitled', type: 'boolean' } + const str: ColumnDefinition = { id: 'col_ghi789', name: 'headline', type: 'string' } + + function expectNamed(fn: () => unknown, name: string, id: string) { + expect(fn).toThrow(new RegExp(`"${name}"`)) + expect(fn).not.toThrow(new RegExp(id)) + } + + it('names the column on an unsupported select operator (v2 grammar)', () => { + const p: TablePredicate = { all: [{ field: multi.id as string, op: 'eq', value: 'c' }] } + expectNamed(() => buildPredicateClause(p, TABLE, [multi]), 'untitled_2', 'col_934cea') + }) + + it('names the column on an unsupported select operator (legacy grammar)', () => { + expectNamed( + () => buildFilterClause({ [multi.id as string]: { $eq: 'c' } }, TABLE, [multi]), + 'untitled_2', + 'col_934cea' + ) + }) + + it('names the column on a range-operator type mismatch', () => { + const p: TablePredicate = { all: [{ field: 'col_abc123', op: 'gt', value: '7' }] } + expectNamed(() => buildPredicateClause(p, TABLE, [num]), 'overall_score', 'col_abc123') + }) + + it('names the column on an unorderable range operator', () => { + const p: TablePredicate = { all: [{ field: 'col_def456', op: 'gt', value: 1 }] } + expectNamed(() => buildPredicateClause(p, TABLE, [bool]), 'untitled', 'col_def456') + }) + + it('names the column on an empty pattern operand', () => { + const p: TablePredicate = { all: [{ field: 'col_ghi789', op: 'contains', value: '' }] } + expectNamed(() => buildPredicateClause(p, TABLE, [str]), 'headline', 'col_ghi789') + }) + + it('names the column on a bad $empty flag', () => { + expectNamed( + () => buildFilterClause({ col_ghi789: { $empty: 1 } } as unknown as Filter, TABLE, [str]), + 'headline', + 'col_ghi789' + ) + }) + + it('has no message to name on a containment type mismatch — it does not throw', () => { + const p: TablePredicate = { all: [{ field: 'col_abc123', op: 'eq', value: 'seven' }] } + expect(() => buildPredicateClause(p, TABLE, [num])).not.toThrow() + }) +}) diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index ba931ad0c93..3602737aba4 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -252,26 +252,6 @@ export async function assertRowCapacity(params: { return limit } -/** - * Checks if a workspace can create more tables based on its plan limits. - * - * @param workspaceId - The workspace ID to check - * @param currentTableCount - The current number of tables in the workspace - * @returns Object with canCreate boolean and limit info - */ -async function canCreateTable( - workspaceId: string, - currentTableCount: number -): Promise<{ canCreate: boolean; maxTables: number; currentCount: number }> { - const limits = await getWorkspaceTableLimits(workspaceId) - - return { - canCreate: currentTableCount < limits.maxTables, - maxTables: limits.maxTables, - currentCount: currentTableCount, - } -} - /** * Gets the maximum rows allowed per table for a workspace based on its plan. * diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index cf2b4e34373..789d8702b56 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -24,7 +24,6 @@ import { import type { RowData, RowExecutionMetadata, - RowExecutions, TableDefinition, TableRowSecretProvenanceWrite, WorkflowGroup, @@ -411,10 +410,3 @@ export function buildOutputsByBlockId( } return map } - -/** Type-narrowing helper used by readers that can't assume `executions` is set. */ -export function readExecutions( - row: { executions?: RowExecutions } | null | undefined -): RowExecutions { - return row?.executions ?? {} -} diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 6b2747e0e4d..9f9f200886d 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -781,18 +781,6 @@ export async function completeDispatchIfActive(dispatchId: string): Promise 0 } -export async function markDispatchCancelled(dispatchId: string): Promise { - await db - .update(tableRunDispatches) - .set({ status: 'cancelled', cancelledAt: new Date() }) - .where( - and( - eq(tableRunDispatches.id, dispatchId), - inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES]) - ) - ) -} - /** Mark every active dispatch on this table as cancelled. Single atomic * UPDATE so the dispatcher's next iteration observes the cancel. Returns the * dispatches that were cancelled so the caller can emit per-dispatch SSE diff --git a/apps/sim/lib/table/jobs/service.ts b/apps/sim/lib/table/jobs/service.ts index 24f1ec5cf39..c5f16114f8b 100644 --- a/apps/sim/lib/table/jobs/service.ts +++ b/apps/sim/lib/table/jobs/service.ts @@ -387,25 +387,6 @@ export async function getTableJob( return job ?? null } -/** - * Stamps an export job's generated-file storage key onto its payload (`{ resultKey }` merge). - * Scoped to the still-running job so a superseded attempt can't clobber a newer run's result. - * The download route reads it; the janitor deletes the file when the terminal job is pruned. - */ -export async function setJobResultKey( - tableId: string, - jobId: string, - resultKey: string -): Promise { - await db - .update(tableJobs) - .set({ - payload: sql`coalesce(${tableJobs.payload}, '{}'::jsonb) || jsonb_build_object('resultKey', ${resultKey}::text)`, - updatedAt: new Date(), - }) - .where(ownsActiveJob(tableId, jobId)) -} - /** Stamps an export result only while the canonical workspace-scoped job is active. */ export async function setJobResultKeyInWorkspace( tableId: string, diff --git a/apps/sim/lib/table/llm/enrichment.ts b/apps/sim/lib/table/llm/enrichment.ts index 3225fd8e5bc..007f5ca4c26 100644 --- a/apps/sim/lib/table/llm/enrichment.ts +++ b/apps/sim/lib/table/llm/enrichment.ts @@ -161,7 +161,7 @@ export function enrichTableToolParameters( if (enrichedProperties.limit && toolId === 'table_query_rows') { enrichedProperties.limit = { ...enrichedProperties.limit, - description: `Maximum rows to return (min: 1, max: 1000, default: 100). For ranking queries: use limit=1 for highest/lowest, limit=2 for second highest, etc.`, + description: `Maximum rows to return (min: 1). Omit to return every matching row; the query fails if the result exceeds 5MB. For ranking queries: use limit=1 for highest/lowest, limit=2 for second highest, etc.`, } } diff --git a/apps/sim/lib/table/mutation-locks.ts b/apps/sim/lib/table/mutation-locks.ts index 9a5c6e59cbd..dc38ab905a1 100644 --- a/apps/sim/lib/table/mutation-locks.ts +++ b/apps/sim/lib/table/mutation-locks.ts @@ -179,13 +179,3 @@ function patchTouchesOnlyWorkflowColumns( export function patchColumnIds(data: RowData): string[] { return Object.keys(data) } - -/** - * Escape hatch for tests and trusted system callers that legitimately invoke - * the low-level `rows/ordering.ts` primitives without a preceding assert - * (e.g. fixtures, or a call path already gated elsewhere). NOT for production - * mutation paths — those must assert so locks are enforced and violations logged. - */ -export function unsafeMutationProof(): MutationProof { - return proofFor() -} diff --git a/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts b/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts new file mode 100644 index 00000000000..9a38c70cb88 --- /dev/null +++ b/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts @@ -0,0 +1,178 @@ +/** + * @vitest-environment node + * + * "Filter by cell value" unit tests. The property that matters throughout: the + * row the user right-clicked must survive the filter its own cell produced. + */ +import { describe, expect, it } from 'vitest' +import { + cellValueFilterConditions, + withCellValueFilter, +} from '@/lib/table/query-builder/cell-filter' +import type { ColumnDefinition, TablePredicate } from '@/lib/table/types' + +function column(overrides: Partial = {}): ColumnDefinition { + return { id: 'col_a', name: 'Name', type: 'string', ...overrides } as ColumnDefinition +} + +describe('cellValueFilterConditions', () => { + it('offers nothing without a column', () => { + expect(cellValueFilterConditions(undefined, 'x')).toEqual([]) + }) + + it('keys conditions on the column id, not its display name', () => { + expect(cellValueFilterConditions(column({ id: 'col_a', name: 'Name' }), 'Ada')).toEqual([ + { field: 'col_a', op: 'eq', value: 'Ada' }, + ]) + }) + + it('falls back to the name when the column carries no id', () => { + const legacy = { name: 'Name', type: 'string' } as ColumnDefinition + expect(cellValueFilterConditions(legacy, 'Ada')).toEqual([ + { field: 'Name', op: 'eq', value: 'Ada' }, + ]) + }) + + it('carries scalars through untouched rather than via text', () => { + expect(cellValueFilterConditions(column({ type: 'number' }), 8)).toEqual([ + { field: 'col_a', op: 'eq', value: 8 }, + ]) + expect(cellValueFilterConditions(column({ type: 'boolean' }), false)).toEqual([ + { field: 'col_a', op: 'eq', value: false }, + ]) + // A numeric-looking string cell stays a string — text round-tripping would + // coerce it to 8 and stop matching the stored value. + expect(cellValueFilterConditions(column(), '8')).toEqual([ + { field: 'col_a', op: 'eq', value: '8' }, + ]) + }) + + it('keeps a date cell byte-exact', () => { + const stored = '2024-01-31T10:00:00.000Z' + expect(cellValueFilterConditions(column({ type: 'date' }), stored)).toEqual([ + { field: 'col_a', op: 'eq', value: stored }, + ]) + }) + + it.each([[null], [undefined], ['']])('maps %p onto isEmpty', (value) => { + expect(cellValueFilterConditions(column(), value)).toEqual([{ field: 'col_a', op: 'isEmpty' }]) + }) + + it('compares a single-select by option id', () => { + const col = column({ type: 'select', options: [{ id: 'opt_a', name: 'Alpha' }] }) + expect(cellValueFilterConditions(col, 'opt_a')).toEqual([ + { field: 'col_a', op: 'eq', value: 'opt_a' }, + ]) + }) + + it('asks a multi-select about membership, one condition per option', () => { + const col = column({ + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Alpha' }, + { id: 'opt_b', name: 'Beta' }, + ], + }) + expect(cellValueFilterConditions(col, ['opt_a', 'opt_b'])).toEqual([ + { field: 'col_a', op: 'contains', value: 'opt_a' }, + { field: 'col_a', op: 'contains', value: 'opt_b' }, + ]) + }) + + it('treats an empty multi-select cell as empty', () => { + const col = column({ type: 'select', multiple: true, options: [{ id: 'opt_a', name: 'A' }] }) + expect(cellValueFilterConditions(col, [])).toEqual([{ field: 'col_a', op: 'isEmpty' }]) + }) + + it('refuses an operator the column type rejects', () => { + // A multi-select accepts contains/ncontains/empty only — `eq` against the + // array cell can never be true, so a scalar reading has no filter to offer. + const multi = column({ type: 'select', multiple: true, options: [{ id: 'opt_a', name: 'A' }] }) + expect(cellValueFilterConditions(multi, 'opt_a')).toEqual([]) + }) + + it('refuses a structured value with no meaningful equality', () => { + expect(cellValueFilterConditions(column({ type: 'json' }), { a: 1 })).toEqual([]) + expect(cellValueFilterConditions(column({ type: 'json' }), [1, 2])).toEqual([]) + }) + + // A json cell holding a STRING array is shaped exactly like a multi-select + // cell. The server accepts `contains` on json and compiles it to an ILIKE + // substring match, so letting it through would quietly match unrelated rows. + it('refuses a json cell holding a string array', () => { + expect(cellValueFilterConditions(column({ type: 'json' }), ['a', 'b'])).toEqual([]) + }) + + // `json.coerce` accepts anything, so a json cell legitimately holds a scalar. + // The server rejects eq/ne/in/nin on a json column, and the rejected filter + // would stick in state and 400 every later refetch. + it.each([['hello'], [42], [true]])('refuses eq on a json cell holding %p', (value) => { + expect(cellValueFilterConditions(column({ type: 'json' }), value)).toEqual([]) + }) + + it('still offers isEmpty on an empty json cell', () => { + expect(cellValueFilterConditions(column({ type: 'json' }), null)).toEqual([ + { field: 'col_a', op: 'isEmpty' }, + ]) + }) +}) + +describe('withCellValueFilter', () => { + const eqA = { field: 'col_a', op: 'eq', value: 'x' } as const + + it('starts a new filter when none is active', () => { + expect(withCellValueFilter(null, [eqA])).toEqual({ all: [eqA] }) + }) + + it('keeps conditions on other columns', () => { + const current: TablePredicate = { all: [{ field: 'col_b', op: 'eq', value: 1 }] } + expect(withCellValueFilter(current, [eqA])).toEqual({ + all: [{ field: 'col_b', op: 'eq', value: 1 }, eqA], + }) + }) + + it('replaces an earlier condition on the same column instead of AND-ing it', () => { + const current: TablePredicate = { all: [{ field: 'col_a', op: 'eq', value: 'old' }] } + expect(withCellValueFilter(current, [eqA])).toEqual({ all: [eqA] }) + }) + + it('keeps an any-group that does not touch the column', () => { + const current: TablePredicate = { + any: [{ all: [{ field: 'col_b', op: 'eq', value: 'x' }] }], + } + expect(withCellValueFilter(current, [eqA])).toEqual({ all: [current, eqA] }) + }) + + // Reachable from the panel: an `or` rule produces an `any` group, and a cell + // filter on a column inside it would otherwise AND against the disjunction + // and empty the table. + it('drops a nested group that constrains the same column', () => { + const current: TablePredicate = { + any: [ + { all: [{ field: 'col_a', op: 'eq', value: 'old' }] }, + { all: [{ field: 'col_b', op: 'eq', value: 'keep' }] }, + ], + } + expect(withCellValueFilter(current, [eqA])).toEqual({ all: [eqA] }) + }) + + it('drops a same-column leaf nested inside an all-group', () => { + const current: TablePredicate = { + all: [ + { all: [{ field: 'col_a', op: 'eq', value: 'old' }] }, + { field: 'col_b', op: 'eq', value: 'keep' }, + ], + } + expect(withCellValueFilter(current, [eqA])).toEqual({ + all: [{ field: 'col_b', op: 'eq', value: 'keep' }, eqA], + }) + }) + + // An `{ all: [] }` group is not a valid predicate — the server rejects it. + it('leaves the filter untouched when there are no conditions', () => { + const current: TablePredicate = { all: [{ field: 'col_a', op: 'eq', value: 'x' }] } + expect(withCellValueFilter(current, [])).toBe(current) + expect(withCellValueFilter(null, [])).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/query-builder/cell-filter.ts b/apps/sim/lib/table/query-builder/cell-filter.ts new file mode 100644 index 00000000000..dcd8f6f5978 --- /dev/null +++ b/apps/sim/lib/table/query-builder/cell-filter.ts @@ -0,0 +1,112 @@ +/** + * "Filter by cell value" — turns one cell into the filter conditions that keep + * its row, and merges them into the active filter. + * + * Deliberately NOT in `converters.ts`: this reads the column-type registry, + * which carries React icon references, and `converters.ts` is re-exported from + * the `@/lib/table` barrel that server modules import. Import this module by + * its own path. + */ + +import { getColumnId } from '@/lib/table/column-keys' +import { filterOperatorsFor } from '@/lib/table/column-types/registry' +import { isEmptyCellValue } from '@/lib/table/deps' +import { UI_TO_WIRE_OPERATOR } from '@/lib/table/query-builder/constants' +import type { + ColumnDefinition, + FilterOp, + JsonValue, + Predicate, + PredicateNode, + TablePredicate, +} from '@/lib/table/types' + +/** + * Builds the conditions that match every row whose `column` cell reads the same + * as `value`. Empty when this cell cannot be expressed as a filter — an unknown + * column, a structured value with no meaningful equality, or a column type that + * rejects the operator the value needs. + * + * The raw stored value is carried through untouched rather than being rendered + * to text and re-parsed: a `select`'s option id, a `date`'s stored string, and a + * numeric-looking `string` cell all compare byte-exactly against what the write + * path stored, which text round-tripping would coerce away. + */ +export function cellValueFilterConditions( + column: ColumnDefinition | undefined, + value: unknown +): Predicate[] { + if (!column) return [] + + const field = getColumnId(column) + // `filterOperatorsFor` answers in wire operators (`$eq`), the filter grammar + // in bare ones (`eq`) — `UI_TO_WIRE_OPERATOR` is the existing bridge. + const allowed = filterOperatorsFor(column) + const supports = (op: FilterOp) => !allowed || allowed.has(UI_TO_WIRE_OPERATOR[op] ?? `$${op}`) + + // An empty cell asks about emptiness — `''`, a JSON null and an emptied + // multi-select's `[]` are all what the server's `isEmpty` matches. + if (isEmptyCellValue(value)) { + return supports('isEmpty') ? [{ field, op: 'isEmpty' }] : [] + } + + // A `json` column has no same-value filter to offer, whatever the cell holds. + // It must be checked BEFORE the shape branches below: `json.coerce` accepts + // anything, so a json cell holds arrays and scalars alike, and letting a + // string array through the multi-select branch would emit `contains` — which + // the server accepts on json and compiles to ILIKE substring matching, so + // unrelated rows would match. `eq` there is refused outright by `validateLeaf` + // in `query-builder/validate.ts`, and a refused predicate would stay in state + // and 400 every later refetch. Only the emptiness check above survives. + if (column.type === 'json') return [] + + // A multi-select cell holds several option ids, so "the same as this cell" + // is one membership test per id — equality against the whole array can never + // be true. Every id must be filterable, or the row the user clicked would + // not survive its own filter. + if (Array.isArray(value)) { + if (!supports('contains')) return [] + if (!value.every((id) => typeof id === 'string')) return [] + return value.map((id) => ({ field, op: 'contains', value: id }) satisfies Predicate) + } + + // A structured value on any other column type has no meaningful equality. + if (typeof value === 'object') return [] + if (!supports('eq')) return [] + return [{ field, op: 'eq', value: value as JsonValue }] +} + +/** True when any leaf anywhere under `node` filters on `field`. */ +function mentionsField(node: PredicateNode, field: string): boolean { + if ('field' in node) return node.field === field + const members = 'all' in node ? node.all : node.any + return members.some((member) => mentionsField(member, field)) +} + +/** + * Narrows `current` with one cell's conditions. + * + * Anything already constraining this column is dropped first, so filtering + * twice on one column swaps the value instead of ANDing two conditions the + * same row cannot satisfy — which would empty the table the user is looking at + * and give them no clue why. Conditions on other columns are kept: the action + * narrows the current view rather than replacing it. + * + * A whole nested group is dropped when it mentions the column ANYWHERE, not + * just its top-level leaves. Reaching inside an `any` group to pull one leaf + * out would silently WIDEN the user's disjunction — dropping the group loses + * the other columns it mentioned, but it is visible in the panel afterwards + * and never contradicts what was just asked for. + */ +export function withCellValueFilter( + current: TablePredicate | null, + conditions: readonly Predicate[] +): TablePredicate | null { + // Nothing to add leaves the filter exactly as it was — an `{ all: [] }` group + // is not a valid predicate and the server rejects it. + const field = conditions[0]?.field + if (field === undefined) return current + if (!current) return { all: [...conditions] } + const members = 'all' in current ? current.all : [current] + return { all: [...members.filter((node) => !mentionsField(node, field)), ...conditions] } +} diff --git a/apps/sim/lib/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts index 5e938c64731..981d62294c8 100644 --- a/apps/sim/lib/table/query-builder/converters.ts +++ b/apps/sim/lib/table/query-builder/converters.ts @@ -19,7 +19,6 @@ import type { JsonValue, Predicate, Sort, - SortDirection, SortRule, SortSpec, TablePredicate, @@ -159,12 +158,6 @@ export function isTablePredicate(value: Filter | TablePredicate): value is Table return ('all' in v && Array.isArray(v.all)) || ('any' in v && Array.isArray(v.any)) } -/** Converts a single UI sort rule to a Sort object for API queries. */ -export function sortRuleToSort(rule: SortRule | null): Sort | null { - if (!rule || !rule.column) return null - return { [rule.column]: rule.direction } -} - /** Converts multiple UI sort rules to a Sort object. */ export function sortRulesToSort(rules: SortRule[]): Sort | null { if (rules.length === 0) return null @@ -179,17 +172,6 @@ export function sortRulesToSort(rules: SortRule[]): Sort | null { return Object.keys(sort).length > 0 ? sort : null } -/** Converts a Sort object back to UI sort rules. */ -export function sortToRules(sort: Sort | null): SortRule[] { - if (!sort) return [] - - return Object.entries(sort).map(([column, direction]) => ({ - id: generateShortId(), - column, - direction: normalizeSortDirection(direction), - })) -} - function toRuleValue(operator: string, value: string, keepAsText = false): JsonValue { if (operator === 'isEmpty') return { $empty: true } if (operator === 'isNotEmpty') return { $empty: false } @@ -210,7 +192,7 @@ function mergeConditions(existing: unknown, incoming: unknown): Record { - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if (isRecordLike(value)) { return { ...(value as Record) } } return { $eq: value as JsonValue } @@ -324,10 +306,6 @@ function formatValueForBuilder(value: JsonValue): string { return String(value) } -function normalizeSortDirection(direction: string): SortDirection { - return direction === 'desc' ? 'desc' : 'asc' -} - /* ----------------------------- v2 grammar ----------------------------- */ const VALUELESS_OPS = new Set(['isEmpty', 'isNotEmpty', 'isNull', 'isNotNull']) diff --git a/apps/sim/lib/table/query-builder/use-query-builder.ts b/apps/sim/lib/table/query-builder/use-query-builder.ts index 2d86ae236fd..39fd4af3653 100644 --- a/apps/sim/lib/table/query-builder/use-query-builder.ts +++ b/apps/sim/lib/table/query-builder/use-query-builder.ts @@ -9,7 +9,6 @@ import { type FilterRule, LOGICAL_OPERATORS, SORT_DIRECTION_OPTIONS, - type SortRule, } from '@/lib/table/query-builder/constants' import type { ColumnOption } from '@/lib/table/types' @@ -77,51 +76,6 @@ export function useFilterBuilder({ } } -/** Manages sort rule state with add/remove/update operations. */ -export function useSortBuilder({ - columns, - sortRule, - setSortRule, -}: UseSortBuilderProps): UseSortBuilderReturn { - const addSort = useCallback(() => { - setSortRule({ - id: generateShortId(), - column: columns[0]?.value || '', - direction: 'asc', - }) - }, [columns, setSortRule]) - - const removeSort = useCallback(() => { - setSortRule(null) - }, [setSortRule]) - - const updateSortColumn = useCallback( - (column: string) => { - if (sortRule) { - setSortRule({ ...sortRule, column }) - } - }, - [sortRule, setSortRule] - ) - - const updateSortDirection = useCallback( - (direction: 'asc' | 'desc') => { - if (sortRule) { - setSortRule({ ...sortRule, direction }) - } - }, - [sortRule, setSortRule] - ) - - return { - sortDirectionOptions, - addSort, - removeSort, - updateSortColumn, - updateSortDirection, - } -} - export interface UseFilterBuilderProps { columns: ColumnOption[] rules: FilterRule[] @@ -138,17 +92,3 @@ export interface UseFilterBuilderReturn { updateRule: (id: string, field: keyof FilterRule, value: string) => void createDefaultRule: () => FilterRule } - -interface UseSortBuilderProps { - columns: ColumnOption[] - sortRule: SortRule | null - setSortRule: (sort: SortRule | null) => void -} - -interface UseSortBuilderReturn { - sortDirectionOptions: ColumnOption[] - addSort: () => void - removeSort: () => void - updateSortColumn: (column: string) => void - updateSortDirection: (direction: 'asc' | 'desc') => void -} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 015e9970917..4ed64ae8824 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -1270,17 +1270,6 @@ interface BoundedFetchResult { anchorOffset: number } -/** - * Belt-and-braces bound on drain iterations. - * - * Unreachable only because every iteration either consumes at least one row or cuts, and a bounded - * page's `limit` is capped at {@link TABLE_LIMITS.MAX_QUERY_LIMIT} — so the limit cut always fires - * first. That makes the two constants exactly tight: raising `MAX_QUERY_LIMIT` above this bound - * would let the loop exit with rows still unread and `hasMore: false`, which clients now trust as - * end-of-table (they terminate on `nextCursor`, which this decides). Raise both together. - */ -const MAX_QUERY_BATCHES = 1000 - /** * Drains rows in adaptively-sized bounded batches until the caller's `limit` * or the byte ceiling ends the page. Never issues an unbounded SELECT: the @@ -1364,7 +1353,7 @@ async function fetchRowsBounded(params: BoundedFetchParams): Promise buildQuery(trx), { seqscanOff: sorted }) } - for (let iteration = 0; iteration < MAX_QUERY_BATCHES; iteration++) { + while (true) { const limitRemaining = limit === undefined ? Number.POSITIVE_INFINITY : limit - rows.length const target = Math.min(nextBatchRows(), limitRemaining) const ask = target + 1 // +1 = witness row proving more data exists past a cut diff --git a/apps/sim/lib/table/select-values.ts b/apps/sim/lib/table/select-values.ts index 5c1c2ae0f60..fa568546006 100644 --- a/apps/sim/lib/table/select-values.ts +++ b/apps/sim/lib/table/select-values.ts @@ -9,6 +9,7 @@ * both the legacy `$` grammar and the v2 predicate tree. */ +import { isRecordLike } from '@sim/utils/object' import { buildIdByName, getColumnId, predicateNamesToIds } from '@/lib/table/column-keys' import { resolveSelectOptionId } from '@/lib/table/select-options' import type { @@ -74,7 +75,7 @@ export function resolveFilterSelectValues(filter: Filter, columns: ColumnDefinit continue } const options = column.options - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if (isRecordLike(value)) { const ops = value as ConditionOperators const next: ConditionOperators = { ...ops } if (ops.$eq !== undefined) diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 6a90b5280db..4d5c6c9ab04 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -12,6 +12,7 @@ import { sql } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' import { columnTypeById, + columnTypeOf, filterOperatorsFor, MULTI_SELECT_OPERATORS, SINGLE_SELECT_OPERATORS, @@ -350,6 +351,19 @@ function validateOperator(operator: string): void { } } +/** + * The caller-facing name for a field in an error message. + * + * Filters reach the SQL builders **storage-keyed** — the boundaries translate + * column name → column id first — so interpolating the raw `field` reports a + * `col_…` id the caller never sent and cannot look up. The definition already in + * hand carries the display name; fall back to `field` for a system column + * (`createdAt`), an unknown key, or a legacy column whose id IS its name. + */ +function columnLabel(field: string, column: ColumnDefinition | undefined): string { + return column?.name ?? field +} + /** * Validates that a range-operator value matches its column's expected JS type * before it reaches Postgres. Surfaces an actionable, column-named error at the @@ -357,31 +371,89 @@ function validateOperator(operator: string): void { * from the database. */ function validateComparisonValue( - field: string, + label: string, columnType: ColumnType | undefined, cast: 'numeric' | 'timestamptz', value: number | string ): void { if (cast === 'numeric' && typeof value !== 'number') { - const label = columnType ?? 'number' + const typeLabel = columnType ?? 'number' throw new TableQueryValidationError( - `Range operator on column "${field}" (${label}) requires a number, got ${typeof value}` + `Range operator on column "${label}" (${typeLabel}) requires a number, got ${typeof value}` ) } if (cast === 'timestamptz') { if (typeof value !== 'string') { throw new TableQueryValidationError( - `Range operator on column "${field}" (date) requires a date string, got ${typeof value}` + `Range operator on column "${label}" (date) requires a date string, got ${typeof value}` ) } if (normalizeDateCellValue(value) === null) { throw new TableQueryValidationError( - `Range operator on column "${field}" (date) requires a parseable date string, got "${truncate(value, 64)}"` + `Range operator on column "${label}" (date) requires a parseable date string, got "${truncate(value, 64)}"` ) } } } +/** + * Equality/membership operators. Their operand is compared by JSONB + * containment, which is exact and untyped: `{"score": 8} @> {"score": "8"}` is + * simply false, so a wrongly-typed operand never matches and the caller cannot + * tell that from a genuinely empty table. + */ +const CONTAINMENT_OPS = new Set(['eq', 'ne', 'in', 'nin']) + +/** + * Column types whose containment operand is left byte-exact. + * + * `select` — its operands are option **names**, already resolved to stored ids + * upstream by `resolvePredicateSelectValues` / `resolveFilterSelectValues`, and + * its `coerce` returns an array for a multi-select: the wrong shape for a + * membership clause. + * + * `date` — its `coerce` is **not idempotent**. `normalizeDateCellValue` rebuilds + * the string without a fractional part, so `"2024-01-31T10:00:00.000Z"` — the + * form the write path stores — comes back as `"2024-01-31T10:00:00Z"` and no + * longer matches the stored bytes. `fieldPredicate` is not only used for + * user-facing filters: it also compiles the unique-constraint probes + * (`checkUniqueConstraintsDb`, `checkBatchUniqueConstraintsDb`) and the upsert + * conflict probe, whose operands were **already** coerced by `coerceRowToSchema` + * earlier in the same request. Re-coercing them there would make a unique `date` + * probe stop matching, letting a duplicate row through inside the write + * transaction with no error. Fixing the stored date format is a far larger + * change than a read-path alignment should carry. + */ +const CONTAINMENT_COERCION_EXCLUDED_TYPES = new Set(['select', 'date']) + +/** + * Reads an equality/membership operand the way the **write path** reads a cell, + * so `eq` compares like against like — best-effort, never fatal. + * + * The column type's own `coerce` is the single definition of "what this column + * can hold": a write of `"8"` to a number column stores `8`, so a filter for + * `"8"` must look for `8` or it reports zero rows for a row that exists. + * + * When `coerce` refuses, the ORIGINAL operand is passed through unchanged and + * the clause compiles exactly as it always did — matching nothing, since JSONB + * containment is exact. Refusing loudly is not an option here: the v2 predicate + * grammar is not operand-type-checked at the boundary (leaf `value` is + * `z.unknown()`), so a throw would land not at submission but inside the + * background runners that compile the same predicate later — a filter-scoped + * cancel that can no longer compile would leave those cells uncancellable. + * + * `null` and `''` are passed through untouched. Neither is a typed operand: + * `null` is a real containment query for a JSON-null cell, and `''` is the + * cleared-cell sentinel the grid writes. Coercing either would change what an + * existing caller's filter means rather than fix it. `select` and `date` are + * excluded wholesale — see `CONTAINMENT_COERCION_EXCLUDED_TYPES`. + */ +function coerceContainmentOperand(column: ColumnDefinition, value: JsonValue): JsonValue { + if (value === null || value === '') return value + const result = columnTypeOf(column).coerce(value, column) + return result.ok ? (result.value as JsonValue) : value +} + /** * Guards a bound that is about to be bound into a `::timestamptz` cast on a * system timestamp column (`createdAt`/`updatedAt`). @@ -430,6 +502,7 @@ function buildFieldCondition( const columnType = column?.type const isSelect = columnType === 'select' const isMultiSelect = isSelect && column?.multiple === true + const label = columnLabel(field, column) // Types whose stored value is opaque (a select's option ids) restrict which // operators mean anything; `null` means the type accepts them all. const allowedOperators = column ? filterOperatorsFor(column) : null @@ -442,13 +515,13 @@ function buildFieldCondition( validateOperator(op) if (allowedOperators && !allowedOperators.has(op)) { throw new TableQueryValidationError( - `Operator "${op}" is not supported on ${isMultiSelect ? 'multi-select' : columnType} column "${field}". Allowed: ${Array.from(allowedOperators).join(', ')}` + `Operator "${op}" is not supported on ${isMultiSelect ? 'multi-select' : columnType} column "${label}". Allowed: ${Array.from(allowedOperators).join(', ')}` ) } if (op === '$empty') { // `$empty: true/false` maps onto the valueless v2 ops. - const filterOp: FilterOp = coerceEmptyFlag(field, value) ? 'isEmpty' : 'isNotEmpty' + const filterOp: FilterOp = coerceEmptyFlag(label, value) ? 'isEmpty' : 'isNotEmpty' const clause = fieldPredicate(tableName, field, filterOp, undefined, column) if (clause) conditions.push(clause) continue @@ -511,6 +584,10 @@ export function fieldPredicate( } const columnType = column?.type + // Messages must name what the CALLER sent. `field` is the storage key by the + // time it reaches here (the boundaries translate name → id before building + // SQL), so a raw `field` reports a `col_…` the caller never supplied. + const label = columnLabel(field, column) const isSelect = columnType === 'select' // A multi-select cell holds an ARRAY of option ids, so equality against a // scalar can never be true; the question is membership. Gating and clause @@ -522,7 +599,7 @@ export function fieldPredicate( const allowed = isMultiSelect ? MULTI_SELECT_OPS : SINGLE_SELECT_OPS if (!allowed.has(op)) { throw new TableQueryValidationError( - `Operator "${op}" is not supported on ${isMultiSelect ? 'multi-select' : 'select'} column "${field}". Allowed: ${Array.from(allowed).join(', ')}` + `Operator "${op}" is not supported on ${isMultiSelect ? 'multi-select' : 'select'} column "${label}". Allowed: ${Array.from(allowed).join(', ')}` ) } } @@ -542,45 +619,66 @@ export function fieldPredicate( } } + // Equality/membership compiles to exact JSONB containment, so an operand of + // the wrong JS type is not a narrower match — it is no match at all, reported + // as an empty 200 while the row it meant exists. Read the operand through the + // column type first, exactly as a write would. Best-effort only: an operand + // the type refuses passes through unchanged and the clause compiles as it + // always did. Skipped for a field with no schema entry (ad-hoc legacy keys), + // which has no declared type to read it with, and for the types in + // `CONTAINMENT_COERCION_EXCLUDED_TYPES`. + const coercesContainment = + column !== undefined && + !CONTAINMENT_COERCION_EXCLUDED_TYPES.has(column.type) && + CONTAINMENT_OPS.has(op) + const containmentValue: JsonValue | undefined = + coercesContainment && column + ? Array.isArray(value) + ? value.map((v) => coerceContainmentOperand(column, v as JsonValue)) + : coerceContainmentOperand(column, value as JsonValue) + : value + switch (op) { case 'eq': - return buildContainmentClause(tableName, field, value as JsonValue) + return buildContainmentClause(tableName, field, containmentValue as JsonValue) case 'ne': - return sql`NOT (${buildContainmentClause(tableName, field, value as JsonValue)})` + return sql`NOT (${buildContainmentClause(tableName, field, containmentValue as JsonValue)})` case 'gt': - return buildComparisonClause(tableName, field, '>', value as number | string, columnType) + return buildComparisonClause(tableName, field, column, '>', value as number | string) case 'gte': - return buildComparisonClause(tableName, field, '>=', value as number | string, columnType) + return buildComparisonClause(tableName, field, column, '>=', value as number | string) case 'lt': - return buildComparisonClause(tableName, field, '<', value as number | string, columnType) + return buildComparisonClause(tableName, field, column, '<', value as number | string) case 'lte': - return buildComparisonClause(tableName, field, '<=', value as number | string, columnType) + return buildComparisonClause(tableName, field, column, '<=', value as number | string) case 'in': { - if (!Array.isArray(value) || value.length === 0) return undefined - if (value.length === 1) return buildContainmentClause(tableName, field, value[0]) - const inConditions = value.map((v) => buildContainmentClause(tableName, field, v)) + const values = containmentValue + if (!Array.isArray(values) || values.length === 0) return undefined + if (values.length === 1) return buildContainmentClause(tableName, field, values[0]) + const inConditions = values.map((v) => buildContainmentClause(tableName, field, v)) return sql`(${sql.join(inConditions, sql.raw(' OR '))})` } case 'nin': { - if (!Array.isArray(value) || value.length === 0) return undefined - const ninConditions = value.map( + const values = containmentValue + if (!Array.isArray(values) || values.length === 0) return undefined + const ninConditions = values.map( (v) => sql`NOT (${buildContainmentClause(tableName, field, v)})` ) return sql`(${sql.join(ninConditions, sql.raw(' AND '))})` } case 'contains': - return buildLikeClause(tableName, field, value as string, 'contains') + return buildLikeClause(tableName, field, label, value as string, 'contains') case 'ncontains': - return buildLikeClause(tableName, field, value as string, 'contains', { negate: true }) + return buildLikeClause(tableName, field, label, value as string, 'contains', { negate: true }) case 'startsWith': - return buildLikeClause(tableName, field, value as string, 'startsWith') + return buildLikeClause(tableName, field, label, value as string, 'startsWith') case 'endsWith': - return buildLikeClause(tableName, field, value as string, 'endsWith') + return buildLikeClause(tableName, field, label, value as string, 'endsWith') case 'like': return buildPatternClause(tableName, field, value as string, { caseInsensitive: false }) @@ -813,15 +911,17 @@ function buildArrayMembershipClause(tableName: string, field: string, value: Jso function buildComparisonClause( tableName: string, field: string, + column: ColumnDefinition | undefined, operator: '>' | '>=' | '<' | '<=', - value: number | string, - columnType: ColumnType | undefined + value: number | string ): SQL { const escapedField = field.replace(/'/g, "''") + const label = columnLabel(field, column) + const columnType = column?.type if (columnType === 'boolean' || columnType === 'json') { throw new TableQueryValidationError( - `Range operator on column "${field}" (${columnType}) is not supported — ${columnType} values have no ordering.` + `Range operator on column "${label}" (${columnType}) is not supported — ${columnType} values have no ordering.` ) } @@ -831,7 +931,7 @@ function buildComparisonClause( } const cast = jsonbCastForType(columnType) ?? 'numeric' - validateComparisonValue(field, columnType, cast, value) + validateComparisonValue(label, columnType, cast, value) const cell = sql.raw(`(${tableName}.data->>'${escapedField}')::${cast}`) return cast === 'timestamptz' ? sql`${cell} ${sql.raw(operator)} ${value}::timestamptz` @@ -884,6 +984,7 @@ function buildPatternClause( function buildLikeClause( tableName: string, field: string, + label: string, value: string, position: 'contains' | 'startsWith' | 'endsWith', options?: { negate?: boolean } @@ -898,7 +999,7 @@ function buildLikeClause( if (text.length === 0) { const opName = position === 'contains' && options?.negate ? 'ncontains' : position throw new TableQueryValidationError( - `$${opName} on column "${field}" requires a non-empty value` + `$${opName} on column "${label}" requires a non-empty value` ) } const escaped = escapeLikePattern(text) @@ -920,12 +1021,12 @@ function buildLikeClause( * else throws rather than silently inverting the check — a 400 with a clear * message beats returning the opposite row set. */ -function coerceEmptyFlag(field: string, value: unknown): boolean { +function coerceEmptyFlag(label: string, value: unknown): boolean { if (typeof value === 'boolean') return value if (value === 'true') return true if (value === 'false') return false throw new TableQueryValidationError( - `$empty on column "${field}" requires a boolean, got ${typeof value}` + `$empty on column "${label}" requires a boolean, got ${typeof value}` ) } diff --git a/apps/sim/lib/table/workflow-group-cancellation.test.ts b/apps/sim/lib/table/workflow-group-cancellation.test.ts index 6514827e2ea..92e400d27e8 100644 --- a/apps/sim/lib/table/workflow-group-cancellation.test.ts +++ b/apps/sim/lib/table/workflow-group-cancellation.test.ts @@ -23,6 +23,8 @@ const OPTIONS = { executionId: 'execution-1', } +const NO_WRITES = { workflowLogTerminalized: false, sidecarCancelled: false } as const + const ACTIVE_TARGET = { tableId: 'table-1', rowId: 'row-1', @@ -64,6 +66,7 @@ describe('cancelWorkflowGroupExecution', () => { rowId: 'row-1', groupId: 'group-1', blockErrors: { 'block-1': 'Provider failed' }, + writes: { workflowLogTerminalized: true, sidecarCancelled: true }, }) expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() @@ -165,6 +168,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'not_workflow_group', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -178,6 +182,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'not_workflow_group', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -197,6 +202,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'not_workflow_group', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -216,6 +222,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'cancelled_without_sidecar', + writes: { workflowLogTerminalized: true, sidecarCancelled: false }, }) expect(dbChainMockFns.update).toHaveBeenCalledOnce() @@ -249,6 +256,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'already_cancelled_without_sidecar', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -269,6 +277,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'not_workflow_group', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -288,6 +297,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'conflict', status: 'completed', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -318,6 +328,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'conflict', status: 'completed', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -335,6 +346,7 @@ describe('cancelWorkflowGroupExecution', () => { rowId: 'row-1', groupId: 'group-1', blockErrors: { 'block-1': 'Provider failed' }, + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -353,6 +365,7 @@ describe('cancelWorkflowGroupExecution', () => { rowId: 'row-1', groupId: 'group-1', blockErrors: { 'block-1': 'Provider failed' }, + writes: { workflowLogTerminalized: false, sidecarCancelled: true }, }) expect(dbChainMockFns.update).toHaveBeenCalledOnce() @@ -371,6 +384,7 @@ describe('cancelWorkflowGroupExecution', () => { rowId: 'row-1', groupId: 'group-1', blockErrors: { 'block-1': 'Provider failed' }, + writes: { workflowLogTerminalized: false, sidecarCancelled: true }, }) const sidecarUpdateValues = collectConditionValues(dbChainMockFns.where.mock.calls[2]?.[0]) @@ -395,6 +409,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'conflict', status: 'error', + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() @@ -413,6 +428,7 @@ describe('cancelWorkflowGroupExecution', () => { rowId: 'row-1', groupId: 'group-1', blockErrors: { 'block-1': 'Provider failed' }, + writes: { workflowLogTerminalized: true, sidecarCancelled: false }, }) expect(dbChainMockFns.update).toHaveBeenCalledOnce() @@ -429,6 +445,7 @@ describe('cancelWorkflowGroupExecution', () => { await expect(cancelWorkflowGroupExecution(OPTIONS)).resolves.toEqual({ kind: 'conflict', status, + writes: NO_WRITES, }) expect(dbChainMockFns.update).not.toHaveBeenCalled() diff --git a/apps/sim/lib/table/workflow-group-cancellation.ts b/apps/sim/lib/table/workflow-group-cancellation.ts index 1231cc2b43a..fd739d3aefa 100644 --- a/apps/sim/lib/table/workflow-group-cancellation.ts +++ b/apps/sim/lib/table/workflow-group-cancellation.ts @@ -35,13 +35,28 @@ export type PublishableWorkflowGroupCancellation = | CancelledWorkflowGroupExecution | AlreadyCancelledWorkflowGroupExecution +/** + * The durable terminal writes this request's own transaction performed. Each + * flag is read off that statement's returned row, so it cannot drift from the + * write: the transaction updates the workflow log only, the cell sidecar only, + * or both, and `kind` alone collapses those cases. + */ +export interface WorkflowGroupCancellationWrites { + /** This transaction moved the workflow execution log to `cancelled`. */ + workflowLogTerminalized: boolean + /** This transaction moved the table cell sidecar to `cancelled`. */ + sidecarCancelled: boolean +} + +type WithCancellationWrites = TResult & { writes: WorkflowGroupCancellationWrites } + export type WorkflowGroupExecutionCancellationResult = - | { kind: 'not_workflow_group' } - | { kind: 'conflict'; status: string } - | { kind: 'cancelled_without_sidecar' } - | { kind: 'already_cancelled_without_sidecar' } - | CancelledWorkflowGroupExecution - | AlreadyCancelledWorkflowGroupExecution + | WithCancellationWrites<{ kind: 'not_workflow_group' }> + | WithCancellationWrites<{ kind: 'conflict'; status: string }> + | WithCancellationWrites<{ kind: 'cancelled_without_sidecar' }> + | WithCancellationWrites<{ kind: 'already_cancelled_without_sidecar' }> + | WithCancellationWrites + | WithCancellationWrites interface WorkflowGroupExecutionTarget { tableId: string @@ -90,11 +105,19 @@ function hasDurableWorkflowGroupOrigin(executionData: unknown): boolean { * * This helper only claims durable database state. Publish its `cancelled` result after * the exact execution has been signalled with `publishWorkflowGroupCancellationEvent`. + * + * Every result carries `writes`, the terminal writes this transaction actually + * performed, so a caller reporting durability never has to infer it from `kind`. */ export async function cancelWorkflowGroupExecution( options: CancelWorkflowGroupExecutionOptions ): Promise { const transition = await db.transaction(async (tx) => { + const writes: WorkflowGroupCancellationWrites = { + workflowLogTerminalized: false, + sidecarCancelled: false, + } + const workflowLog = await tx .select({ status: workflowExecutionLogs.status, @@ -134,20 +157,20 @@ export async function cancelWorkflowGroupExecution( .then((rows) => rows[0]) if (!workflowLog) { - return { result: { kind: 'conflict', status: 'no_longer_active' } as const } + return { result: { kind: 'conflict', status: 'no_longer_active' } as const, writes } } if (!target) { if (!hasDurableWorkflowGroupOrigin(workflowLog.executionData)) { - return { result: { kind: 'not_workflow_group' } as const } + return { result: { kind: 'not_workflow_group' } as const, writes } } const workflowLogActive = workflowLog.status === 'running' || workflowLog.status === 'pending' if (!workflowLogActive && workflowLog.status !== 'cancelled') { - return { result: { kind: 'conflict', status: workflowLog.status } as const } + return { result: { kind: 'conflict', status: workflowLog.status } as const, writes } } if (workflowLog.status === 'cancelled') { - return { result: { kind: 'already_cancelled_without_sidecar' } as const } + return { result: { kind: 'already_cancelled_without_sidecar' } as const, writes } } const cancelledAt = new Date() @@ -164,10 +187,11 @@ export async function cancelWorkflowGroupExecution( ) .returning({ status: workflowExecutionLogs.status }) - if (cancelledLog?.status !== 'cancelled') { + writes.workflowLogTerminalized = cancelledLog?.status === 'cancelled' + if (!writes.workflowLogTerminalized) { throw new Error('Workflow-group cancellation lost its locked workflow-log claim') } - return { result: { kind: 'cancelled_without_sidecar' } as const } + return { result: { kind: 'cancelled_without_sidecar' } as const, writes } } const workflowLogActive = workflowLog.status === 'running' || workflowLog.status === 'pending' @@ -179,10 +203,10 @@ export async function cancelWorkflowGroupExecution( const sidecarClaimable = sidecarActive || cancellationOwnedSidecarError if (!workflowLogActive && workflowLog.status !== 'cancelled') { - return { result: { kind: 'conflict', status: workflowLog.status } as const } + return { result: { kind: 'conflict', status: workflowLog.status } as const, writes } } if (!sidecarClaimable && target.status !== 'cancelled') { - return { result: { kind: 'conflict', status: target.status } as const } + return { result: { kind: 'conflict', status: target.status } as const, writes } } const now = new Date() @@ -200,7 +224,8 @@ export async function cancelWorkflowGroupExecution( ) .returning({ status: workflowExecutionLogs.status }) - if (cancelledLog?.status !== 'cancelled') { + writes.workflowLogTerminalized = cancelledLog?.status === 'cancelled' + if (!writes.workflowLogTerminalized) { throw new Error('Workflow-group cancellation lost its locked workflow-log claim') } } @@ -231,7 +256,8 @@ export async function cancelWorkflowGroupExecution( ) .returning({ status: tableRowExecutions.status }) - if (cancelledSidecar?.status !== 'cancelled') { + writes.sidecarCancelled = cancelledSidecar?.status === 'cancelled' + if (!writes.sidecarCancelled) { throw new Error('Workflow-group cancellation lost its locked table-sidecar claim') } } @@ -242,16 +268,17 @@ export async function cancelWorkflowGroupExecution( rowId: target.rowId, groupId: target.groupId, } - return { result, blockErrors: target.blockErrors } + return { result, writes, blockErrors: target.blockErrors } }) if (transition.result.kind !== 'cancelled' && transition.result.kind !== 'already_cancelled') { - return transition.result + return { ...transition.result, writes: transition.writes } } const blockErrors = normalizeBlockErrors(transition.blockErrors) return { ...transition.result, + writes: transition.writes, ...(blockErrors ? { blockErrors } : {}), } } diff --git a/apps/sim/lib/uploads/config.ts b/apps/sim/lib/uploads/config.ts index 489b2ee509b..e527b6f605e 100644 --- a/apps/sim/lib/uploads/config.ts +++ b/apps/sim/lib/uploads/config.ts @@ -4,11 +4,8 @@ import type { StorageConfig, StorageContext } from '@/lib/uploads/shared/types' export type { StorageConfig, StorageContext } from '@/lib/uploads/shared/types' -export const UPLOAD_DIR = '/uploads' - const storageProvider = getConfiguredStorageProviderId() -export const hasBlobConfig = storageProvider === 'azure' export const USE_BLOB_STORAGE = storageProvider === 'azure' export const USE_S3_STORAGE = storageProvider === 's3' export const USE_GCS_STORAGE = storageProvider === 'gcs' @@ -358,28 +355,3 @@ function getGcsConfig(context: StorageContext): StorageConfig { return { bucket: GCS_CONFIG.bucket } } } - -/** - * Check if a specific storage context is configured - * Returns false if the context would fall back to general config but general isn't configured - */ -export function isStorageContextConfigured(context: StorageContext): boolean { - const config = getStorageConfig(context) - - if (USE_BLOB_STORAGE) { - return !!( - config.containerName && - (config.connectionString || (config.accountName && config.accountKey)) - ) - } - - if (USE_S3_STORAGE) { - return !!(config.bucket && config.region) - } - - if (USE_GCS_STORAGE) { - return !!config.bucket - } - - return true -} diff --git a/apps/sim/lib/uploads/contexts/chat/chat-file-manager.ts b/apps/sim/lib/uploads/contexts/chat/chat-file-manager.ts index 0217bc5b2ee..3a6c340cbb5 100644 --- a/apps/sim/lib/uploads/contexts/chat/chat-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/chat/chat-file-manager.ts @@ -72,24 +72,3 @@ export async function processChatFiles( return userFiles } - -/** - * Upload a single chat file to temporary execution storage - * - * This is a convenience function for uploading individual files. - * For batch uploads, use processChatFiles() for better performance. - * - * @param file Chat file to upload - * @param executionContext Execution context for temporary storage - * @param requestId Unique request identifier - * @returns UserFile object with upload result - */ -async function uploadChatFile( - file: ChatFile, - executionContext: ChatExecutionContext, - requestId: string, - userId?: string -): Promise { - const [userFile] = await processChatFiles([file], executionContext, requestId, userId) - return userFile -} diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index f0406e73601..ceb7eacc49b 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -1,11 +1,6 @@ import { createLogger } from '@sim/logger' import { getBaseUrl } from '@/lib/core/utils/urls' -import { - deleteFile, - downloadFile, - generatePresignedDownloadUrl, - uploadFile, -} from '@/lib/uploads/core/storage-service' +import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' const logger = createLogger('CopilotFileManager') @@ -40,12 +35,6 @@ export function isSupportedFileType(mimeType: string): boolean { return SUPPORTED_FILE_TYPES.includes(mimeType.toLowerCase()) } -interface CopilotFileAttachment { - key: string - filename: string - media_type: string -} - export interface CopilotStoredFile { id: string key: string @@ -123,73 +112,3 @@ export async function downloadCopilotFile(key: string): Promise { throw error } } - -/** - * Process copilot file attachments for chat messages - * - * Downloads files from storage and validates they are supported types. - * Skips unsupported files with a warning. - * - * @param attachments Array of file attachments - * @param requestId Request identifier for logging - * @returns Array of buffers for successfully downloaded files - */ -export async function processCopilotAttachments( - attachments: CopilotFileAttachment[], - requestId: string -): Promise> { - const results: Array<{ buffer: Buffer; attachment: CopilotFileAttachment }> = [] - - for (const attachment of attachments) { - try { - if (!isSupportedFileType(attachment.media_type)) { - logger.warn(`[${requestId}] Unsupported file type: ${attachment.media_type}`) - continue - } - - const buffer = await downloadCopilotFile(attachment.key) - - results.push({ buffer, attachment }) - } catch (error) { - logger.error(`[${requestId}] Failed to process file ${attachment.filename}:`, error) - } - } - - logger.info(`Successfully processed ${results.length}/${attachments.length} attachments`, { - requestId, - }) - - return results -} - -/** - * Generate a presigned download URL for a copilot file - * - * @param key File storage key - * @param expirationSeconds Time in seconds until URL expires (default: 1 hour) - * @returns Presigned download URL - */ -export async function generateCopilotDownloadUrl( - key: string, - expirationSeconds = 3600 -): Promise { - const downloadUrl = await generatePresignedDownloadUrl(key, 'copilot', expirationSeconds) - - logger.info(`Generated copilot download URL for: ${key}`) - - return downloadUrl -} - -/** - * Delete a copilot file from storage - * - * @param key File storage key - */ -export async function deleteCopilotFile(key: string): Promise { - await deleteFile({ - key, - context: 'copilot', - }) - - logger.info(`Successfully deleted copilot file: ${key}`) -} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index b71a04f0111..34d8be56d6b 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -1094,92 +1094,6 @@ export async function moveWorkspaceFileItems(params: { }) } -export async function archiveWorkspaceFileFolderRecursive( - workspaceId: string, - folderId: string -): Promise { - const now = new Date() - - return db.transaction(async (tx) => { - await acquireWorkspaceFileFolderMutationLock(tx, workspaceId) - - const [folder] = await tx - .select({ id: folderTable.id }) - .from(folderTable) - .where( - and( - eq(folderTable.id, folderId), - eq(folderTable.workspaceId, workspaceId), - isFileFolder, - isNull(folderTable.deletedAt) - ) - ) - .limit(1) - - if (!folder) throw new OrchestrationError('not_found', 'Folder not found') - - const activeFolders = await tx - .select({ id: folderTable.id, parentId: folderTable.parentId }) - .from(folderTable) - .where( - and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) - ) - .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) - assertBulkAffectedItemsWithinLimit(activeFolders.length) - const folderIds = [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] - assertBulkAffectedItemsWithinLimit(folderIds.length) - - const affectedFiles = await tx - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - inArray(workspaceFiles.folderId, folderIds), - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) - .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) - assertBulkAffectedItemsWithinLimit(folderIds.length + affectedFiles.length) - - const archivedFiles = await tx - .update(workspaceFiles) - .set({ deletedAt: now, updatedAt: now }) - .where( - and( - inArray(workspaceFiles.folderId, folderIds), - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) - .returning({ id: workspaceFiles.id }) - - const archivedFolders = await tx - .update(folderTable) - .set({ deletedAt: now, updatedAt: now }) - .where( - and( - inArray(folderTable.id, folderIds), - eq(folderTable.workspaceId, workspaceId), - isFileFolder, - isNull(folderTable.deletedAt) - ) - ) - .returning({ id: folderTable.id }) - - logger.info('Archived workspace file folder recursively', { - workspaceId, - folderId, - folders: archivedFolders.length, - files: archivedFiles.length, - }) - - return { folders: archivedFolders.length, files: archivedFiles.length } - }) -} - export async function restoreWorkspaceFileFolder( workspaceId: string, folderId: string diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 73fed9321c1..98f972f4250 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -456,6 +456,11 @@ export function createUploadSessionAuthBinding( }, } } + case 'credential_group_enrollment': + throw new UploadSessionError( + 'forbidden', + 'Credential Group enrollment principals cannot create uploads' + ) } } diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 6d04e92c7ef..46a56af66ac 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -133,14 +133,6 @@ export function isVideoFileType(mimeType: string): boolean { return getContentType(mimeType) === 'video' } -/** - * Check if a MIME type is an audio or video type - */ -export function isMediaFileType(mimeType: string): boolean { - const contentType = getContentType(mimeType) - return contentType === 'audio' || contentType === 'video' -} - /** * Convert a file buffer to base64 */ diff --git a/apps/sim/lib/uploads/utils/validation.ts b/apps/sim/lib/uploads/utils/validation.ts index b400fa621a1..ce41ca20535 100644 --- a/apps/sim/lib/uploads/utils/validation.ts +++ b/apps/sim/lib/uploads/utils/validation.ts @@ -189,12 +189,6 @@ export const SUPPORTED_VIDEO_MIME_TYPES: Record `.${ext}`) export const ACCEPT_ATTRIBUTE = [...ACCEPTED_FILE_TYPES, ...ACCEPTED_FILE_EXTENSIONS].join(',') @@ -330,39 +324,6 @@ export function isSupportedExtension(extension: string): extension is SupportedD ) } -/** - * Get supported MIME types for an extension - */ -export function getSupportedMimeTypes(extension: string): string[] { - if (isSupportedExtension(extension)) { - return SUPPORTED_MIME_TYPES[extension as SupportedDocumentExtension] - } - if (SUPPORTED_AUDIO_EXTENSIONS.includes(extension as SupportedAudioExtension)) { - return SUPPORTED_AUDIO_MIME_TYPES[extension as SupportedAudioExtension] - } - if (SUPPORTED_VIDEO_EXTENSIONS.includes(extension as SupportedVideoExtension)) { - return SUPPORTED_VIDEO_MIME_TYPES[extension as SupportedVideoExtension] - } - return [] -} - -/** - * Check if file extension is a supported audio extension - */ -export function isSupportedAudioExtension(extension: string): extension is SupportedAudioExtension { - return SUPPORTED_AUDIO_EXTENSIONS.includes(extension.toLowerCase() as SupportedAudioExtension) -} - -/** - * Check if file extension is a supported video extension - */ -export function isSupportedVideoExtension(extension: string): extension is SupportedVideoExtension { - return SUPPORTED_VIDEO_EXTENSIONS.includes(extension.toLowerCase() as SupportedVideoExtension) -} - -/** - * Validate if an audio/video file type is supported for STT processing - */ const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) /** @@ -399,37 +360,3 @@ export function sniffImageContentType(buffer: Buffer): string | null { } return null } - -export function validateMediaFileType( - fileName: string, - mimeType: string -): FileValidationError | null { - const raw = extractExtension(fileName) - const extension = isAlphanumericExtension(raw) ? raw : '' - - const isAudio = SUPPORTED_AUDIO_EXTENSIONS.includes(extension as SupportedAudioExtension) - const isVideo = SUPPORTED_VIDEO_EXTENSIONS.includes(extension as SupportedVideoExtension) - - if (!isAudio && !isVideo) { - return { - code: 'UNSUPPORTED_FILE_TYPE', - message: `Unsupported media file type${extension ? `: ${extension}` : ` for "${fileName}"`}. Supported audio types: ${SUPPORTED_AUDIO_EXTENSIONS.join(', ')}. Supported video types: ${SUPPORTED_VIDEO_EXTENSIONS.join(', ')}`, - supportedTypes: [...SUPPORTED_AUDIO_EXTENSIONS, ...SUPPORTED_VIDEO_EXTENSIONS], - } - } - - const baseMimeType = mimeType.split(';')[0].trim() - const allowedMimeTypes = isAudio - ? SUPPORTED_AUDIO_MIME_TYPES[extension as SupportedAudioExtension] - : SUPPORTED_VIDEO_MIME_TYPES[extension as SupportedVideoExtension] - - if (!allowedMimeTypes.includes(baseMimeType)) { - return { - code: 'MIME_TYPE_MISMATCH', - message: `MIME type ${baseMimeType} does not match file extension ${extension}. Expected: ${allowedMimeTypes.join(', ')}`, - supportedTypes: allowedMimeTypes, - } - } - - return null -} diff --git a/apps/sim/lib/webhooks/env-resolver.ts b/apps/sim/lib/webhooks/env-resolver.ts index 1a2fb413fbd..13975c2c956 100644 --- a/apps/sim/lib/webhooks/env-resolver.ts +++ b/apps/sim/lib/webhooks/env-resolver.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' @@ -34,7 +35,7 @@ export async function resolveEnvVarsInObject>( * Normalizes webhook provider config into a plain object for runtime resolution. */ export function normalizeWebhookProviderConfig(providerConfig: unknown): Record { - if (providerConfig && typeof providerConfig === 'object' && !Array.isArray(providerConfig)) { + if (isRecordLike(providerConfig)) { return providerConfig as Record } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index e1162a13a8e..a7a56b03f58 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -2,6 +2,7 @@ import { db, webhook, webhookPathClaim, workflow, workflowDeploymentVersion } fr import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -300,84 +301,6 @@ export function handlePreDeploymentVerification( return null } -async function findWebhookAndWorkflow( - options: WebhookProcessorOptions -): Promise { - if (options.webhookId) { - const results = await db - .select({ - webhook: webhook, - workflow: workflow, - }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - and( - eq(workflowDeploymentVersion.workflowId, workflow.id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .where( - and( - eq(webhook.id, options.webhookId), - deliverableWebhookPredicate(webhook), - isNull(workflow.archivedAt), - or( - eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), - and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) - ) - ) - .limit(1) - - if (results.length === 0) { - logger.warn(`[${options.requestId}] No active webhook found for id: ${options.webhookId}`) - return null - } - - return { webhook: results[0].webhook, workflow: results[0].workflow } - } - - if (options.path) { - const results = await db - .select({ - webhook: webhook, - workflow: workflow, - }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - and( - eq(workflowDeploymentVersion.workflowId, workflow.id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .where( - and( - eq(webhook.path, options.path), - deliverableWebhookPredicate(webhook), - isNull(workflow.archivedAt), - or( - eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), - and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) - ) - ) - .limit(1) - - if (results.length === 0) { - logger.warn(`[${options.requestId}] No active webhook found for path: ${options.path}`) - return null - } - - return { webhook: results[0].webhook, workflow: results[0].workflow } - } - - return null -} - /** * Finds all webhooks matching a path, scoped to a single workflow. * @@ -693,9 +616,7 @@ export interface WebhookDispatchResult { } function parseProviderConfig(value: unknown): Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} + return isRecordLike(value) ? (value as Record) : {} } function getCredentialId(providerConfig: Record): string | undefined { diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts index f51eb1f8d9a..9c6a81aadf0 100644 --- a/apps/sim/lib/webhooks/providers/ashby.test.ts +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -3,7 +3,7 @@ */ import crypto from 'crypto' import { createMockRequest } from '@sim/testing' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ashbyHandler } from '@/lib/webhooks/providers/ashby' describe('ashbyHandler', () => { @@ -136,6 +136,135 @@ describe('ashbyHandler', () => { }) }) + describe('createSubscription error reporting', () => { + const realFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = realFetch + }) + + const ctx = { + requestId: 'req-1', + webhook: { + id: 'wh-1', + path: '/api/webhooks/trigger/abc', + providerConfig: { apiKey: 'k', triggerId: 'ashby_job_create' }, + }, + } as never + + const respondWith = (body: unknown, status = 200) => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) as never + } + + it('surfaces the object-shaped errors array Ashby documents', async () => { + // Reading only errorInfo.message misses this form, which is what a + // missing-permission failure arrives in - the user would see + // 'Unknown Ashby API error' instead of the actual cause. + respondWith({ success: false, errors: [{ message: 'missing_endpoint_permission' }] }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow( + /missing_endpoint_permission/ + ) + }) + + it('surfaces the plain-string errors array Ashby also returns', async () => { + respondWith({ success: false, errors: ['webhook_not_found'] }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow(/webhook_not_found/) + }) + + it('still prefers errorInfo.message when Ashby sends both shapes at once', async () => { + respondWith({ + success: false, + errors: ['webhook_not_found'], + errorInfo: { code: 'webhook_not_found', message: 'Webhook not found' }, + }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow(/Webhook not found/) + }) + + it('keeps the actionable duplicate-webhook guidance reachable', async () => { + // The duplicate branch only fires when the message was extracted, so an + // unparsed error costs the user the instructions for fixing it. + respondWith({ success: false, errors: [{ message: 'duplicate webhook for this url' }] }) + await expect(ashbyHandler.createSubscription?.(ctx)).rejects.toThrow( + /Ashby Settings > API\/Webhooks/ + ) + }) + }) + + describe('deleteSubscription', () => { + const realFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = realFetch + }) + + const ctx = (strict: boolean) => + ({ + requestId: 'req-1', + strict, + webhook: { + id: 'wh-1', + providerConfig: { apiKey: 'k', externalId: 'ext-1' }, + }, + }) as never + + const respondWith = (body: unknown, status = 200) => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) as never + } + + it('treats a 200 carrying success:false as a failed delete', async () => { + // Ashby returns what would be a 4XX as HTTP 200. Branching on + // response.ok alone reported the leak as a successful cleanup. + respondWith({ success: false, errors: [{ message: 'missing_endpoint_permission' }] }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).rejects.toThrow( + /missing_endpoint_permission/ + ) + }) + + it('stays non-fatal for a failed delete when not strict', async () => { + respondWith({ success: false, errors: [{ message: 'missing_endpoint_permission' }] }) + await expect(ashbyHandler.deleteSubscription?.(ctx(false))).resolves.toBeUndefined() + }) + + it('treats an already-removed webhook as done even in strict mode', async () => { + respondWith({ success: false, errors: ['webhook_not_found'] }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + + it('recognizes the not-found envelope Ashby actually sends for a repeat delete', async () => { + // errorInfo.message wins over the code array in the extractor, so it reads + // 'Webhook not found' - matching that against `webhook_not_found` would + // turn idempotent cleanup into a strict-mode throw. + respondWith({ + success: false, + errors: ['webhook_not_found'], + errorInfo: { + code: 'webhook_not_found', + message: 'Webhook not found', + requestId: '01JSJ8FEK5ZN4XQBZP7DBKK7ZC', + }, + }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + + it('recognizes a not-found reported only as prose', async () => { + respondWith({ success: false, errorInfo: { message: 'Webhook not found' } }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + + it('accepts a successful delete', async () => { + respondWith({ success: true, results: { webhookId: 'ext-1' } }) + await expect(ashbyHandler.deleteSubscription?.(ctx(true))).resolves.toBeUndefined() + }) + }) + describe('extractIdempotencyId', () => { it('derives a stable key from application id + updatedAt', () => { const body = { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 781fef57c66..93925a7d6b9 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -17,6 +17,80 @@ import type { } from '@/lib/webhooks/providers/types' import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils' +/** + * Kept local rather than imported from `@/tools/ashby/utils`, which has the same + * logic. The webhook providers are reachable from workspace page graphs, and the + * knowledge page graph currently sits exactly at the ceiling + * `check:tool-registry-boundary` allows - so neither an import edge into + * `@/tools/**` nor an extra module in this directory fits. Both copies derive + * from the same three documented Ashby error shapes and are covered + * independently by `tools/ashby/utils.test.ts` and `ashby.test.ts` here. + */ +/** + * Extract a human-readable error message from an Ashby error response. Ashby + * documents two shapes and uses three in practice: + * + * - `errorInfo: { code, message, requestId }` + * - `errors: ['webhook_not_found']` - plain strings + * - `errors: [{ message, parameter }]` - objects, which is the form a 403 for a + * missing module permission arrives in, and which stringifies to + * `[object Object]` unless the message is read explicitly + * + * A single response can carry more than one of these at once. + */ +function ashbyErrorMessage(data: unknown, fallback: string): string { + if (!data || typeof data !== 'object') return fallback + const d = data as Record + const info = d.errorInfo as Record | undefined + if (info && typeof info.message === 'string' && info.message) return info.message + if (Array.isArray(d.errors) && d.errors.length > 0) { + const messages = d.errors + .map((e) => { + if (typeof e === 'string') return e + if (e && typeof e === 'object') { + const entry = e as Record + const message = typeof entry.message === 'string' ? entry.message : '' + const parameter = typeof entry.parameter === 'string' ? entry.parameter : '' + if (message && parameter) return `${message} (${parameter})` + if (message) return message + } + return '' + }) + .filter(Boolean) + if (messages.length > 0) return messages.join('; ') + } + return fallback +} + +/** + * Whether an Ashby error response means the webhook id no longer exists. + * + * Ashby signals this as the machine code `webhook_not_found`, carried on + * `errorInfo.code` and/or as an `errors` entry — but the same envelope's + * `errorInfo.message` reads `Webhook not found`, and that is what + * `ashbyErrorMessage` returns, since message wins over the deprecated code + * array. Matching the extracted message against the code therefore misses the + * envelope Ashby actually sends for a repeat delete, and idempotent cleanup + * would be reported as a real failure. Read the codes directly, and keep a + * prose fallback for the message-only form. + */ +function isAshbyWebhookNotFound(data: Record, message: string): boolean { + const info = data.errorInfo as Record | undefined + if (typeof info?.code === 'string' && /webhook_not_found/i.test(info.code)) return true + + if (Array.isArray(data.errors)) { + for (const entry of data.errors) { + if (typeof entry === 'string' && /webhook_not_found/i.test(entry)) return true + if (entry && typeof entry === 'object') { + const entryMessage = (entry as Record).message + if (typeof entryMessage === 'string' && /webhook_not_found/i.test(entryMessage)) return true + } + } + } + + return /webhook[\s_]not[\s_]found/i.test(message) +} + const logger = createLogger('WebhookProvider:Ashby') function validateAshbySignature(secretToken: string, signature: string, body: string): boolean { @@ -212,9 +286,15 @@ export const ashbyHandler: WebhookProviderHandler = { const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record if (!ashbyResponse.ok || !responseBody.success) { - const errorInfo = responseBody.errorInfo as Record | undefined - const errorMessage = - errorInfo?.message || (responseBody.message as string) || 'Unknown Ashby API error' + // Ashby documents two error shapes and uses both. Reading only + // `errorInfo.message` misses the `errors: [{ message, parameter }]` form, + // which is what a missing-permission failure arrives in - and the + // duplicate-webhook branch below only fires when the message was + // extracted, so losing it costs the user the actionable guidance. + const errorMessage = ashbyErrorMessage( + responseBody, + (responseBody.message as string) || 'Unknown Ashby API error' + ) let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' if (ashbyResponse.status === 401) { @@ -289,23 +369,43 @@ export const ashbyHandler: WebhookProviderHandler = { body: JSON.stringify({ webhookId: externalId }), }) - if (ashbyResponse.ok) { - await ashbyResponse.body?.cancel() + const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record + + /** + * Ashby returns what would be a 4XX elsewhere as HTTP 200 with + * `success: false`, so the status alone cannot separate a completed + * delete from a rejected one. Branching on `ashbyResponse.ok` reported + * every rejection as a successful cleanup while Sim dropped its own row + * — and with no `webhook.list` endpoint, an orphan left behind that way + * cannot be enumerated afterwards. + * + * Unlike `createSubscription`, an absent `success` field is treated as + * success rather than failure: teardown runs on the undeploy path, and + * failing closed on an unparseable body would wedge cleanup on a + * response shape Ashby does not document. + */ + const rejected = !ashbyResponse.ok || responseBody.success === false + const errorMessage = ashbyErrorMessage(responseBody, `HTTP ${ashbyResponse.status}`) + + if (!rejected) { logger.info( `[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}` ) - } else if (ashbyResponse.status === 404) { - await ashbyResponse.body?.cancel() + } else if ( + ashbyResponse.status === 404 || + isAshbyWebhookNotFound(responseBody, errorMessage) + ) { logger.info( `[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` ) } else { - const responseBody = await ashbyResponse.json().catch(() => ({})) logger.warn( - `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, - { response: responseBody } + `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${errorMessage}`, + { status: ashbyResponse.status, response: responseBody } ) - if (ctx.strict) throw new Error(`Failed to delete Ashby webhook: ${ashbyResponse.status}`) + if (ctx.strict) { + throw new Error(`Failed to delete Ashby webhook: ${errorMessage}`) + } } } catch (error) { logger.warn(`[${ctx.requestId}] Error deleting Ashby webhook (non-fatal)`, error) diff --git a/apps/sim/lib/webhooks/providers/emailbison.ts b/apps/sim/lib/webhooks/providers/emailbison.ts index 838d042a1a5..ee4d1ba1b31 100644 --- a/apps/sim/lib/webhooks/providers/emailbison.ts +++ b/apps/sim/lib/webhooks/providers/emailbison.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' +import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import { type SecureFetchResponse, secureFetchWithPinnedIP, @@ -325,10 +325,6 @@ function toNumberOrNull(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null } -function toRecordOrNull(value: unknown): Record | null { - return isRecordLike(value) ? value : null -} - function renameTypeField(value: unknown, targetKey: string): Record | null { if (!isRecordLike(value)) return null diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index 2d2d9e66f58..6b5f4cd8d1b 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' +import { isRecordLike, toRecord } from '@sim/utils/object' import { NextResponse } from 'next/server' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' @@ -18,10 +19,6 @@ import { getGitLabApiBase, UnsafeGitLabHostError } from '@/tools/gitlab/utils' const logger = createLogger('WebhookProvider:GitLab') -function asRecord(value: unknown): Record { - return (value as Record) || {} -} - function gitlabProjectHooksUrl(projectId: string, host: unknown): string { return `${getGitLabApiBase(host)}/projects/${encodeURIComponent(projectId)}/hooks` } @@ -87,7 +84,7 @@ export const gitlabHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (!triggerId || triggerId === 'gitlab_webhook') return true - const objectKind = asRecord(body).object_kind as string | undefined + const objectKind = toRecord(body).object_kind as string | undefined const { isGitLabEventMatch } = await import('@/triggers/gitlab/utils') if (!isGitLabEventMatch(triggerId, objectKind || '')) { @@ -109,17 +106,13 @@ export const gitlabHandler: WebhookProviderHandler = { * referencing the undocumented raw path keeps working. */ async formatInput({ body, headers }: FormatInputContext): Promise { - const b = asRecord(body) + const b = toRecord(body) const eventType = headers['x-gitlab-event'] || '' - const ref = (b.ref as string) || '' + const ref = typeof b.ref === 'string' ? b.ref : '' const branch = ref.replace('refs/heads/', '') const objectAttributes = b.object_attributes let input: Record = { ...b, event_type: eventType, branch } - if ( - objectAttributes && - typeof objectAttributes === 'object' && - !Array.isArray(objectAttributes) - ) { + if (isRecordLike(objectAttributes)) { const workItemType = (objectAttributes as Record).type if (workItemType !== undefined) { input = { @@ -151,9 +144,9 @@ export const gitlabHandler: WebhookProviderHandler = { * (pending/running/success/failed) from colliding onto the same key. */ extractIdempotencyId(body: unknown): string | null { - const b = asRecord(body) + const b = toRecord(body) const objectKind = (b.object_kind as string) || '' - const project = asRecord(b.project) + const project = toRecord(b.project) const projectId = project.id != null ? String(project.id) : '' if (objectKind === 'push' || objectKind === 'tag_push') { @@ -163,7 +156,7 @@ export const gitlabHandler: WebhookProviderHandler = { return `gitlab:${objectKind}:${projectId}:${ref}:${checkoutSha}` } - const objectAttributes = asRecord(b.object_attributes) + const objectAttributes = toRecord(b.object_attributes) const id = objectAttributes.id != null ? String(objectAttributes.id) : '' if (!id) return null const version = diff --git a/apps/sim/lib/webhooks/providers/incidentio.ts b/apps/sim/lib/webhooks/providers/incidentio.ts index 256c763d367..25011781744 100644 --- a/apps/sim/lib/webhooks/providers/incidentio.ts +++ b/apps/sim/lib/webhooks/providers/incidentio.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' +import { toRecordOrNull } from '@sim/utils/object' import { NextResponse } from 'next/server' import type { AuthContext, @@ -56,13 +57,6 @@ function verifyIncidentioSignature( } } -function asObject(value: unknown): Record | null { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return value as Record - } - return null -} - function asString(value: unknown): string | null { return typeof value === 'string' ? value : null } @@ -84,9 +78,9 @@ function extractEntity( eventType: string, key: 'incident' | 'alert' ): Record | null { - const wrapper = eventType ? asObject(body[eventType]) : null + const wrapper = eventType ? toRecordOrNull(body[eventType]) : null if (!wrapper) return null - return asObject(wrapper[key]) ?? wrapper + return toRecordOrNull(wrapper[key]) ?? wrapper } export const incidentioHandler: WebhookProviderHandler = { @@ -152,9 +146,9 @@ export const incidentioHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const b = (asObject(body) ?? {}) as Record + const b = (toRecordOrNull(body) ?? {}) as Record const eventType = typeof b.event_type === 'string' ? b.event_type : '' - const wrapper = eventType ? asObject(b[eventType]) : null + const wrapper = eventType ? toRecordOrNull(b[eventType]) : null const isAlert = eventType.startsWith('public_alert.') if (isAlert) { @@ -187,15 +181,15 @@ export const incidentioHandler: WebhookProviderHandler = { name: asString(incident?.name), reference: asString(incident?.reference), summary: asString(incident?.summary), - incident_status: asObject(incident?.incident_status), - severity: asObject(incident?.severity), + incident_status: toRecordOrNull(incident?.incident_status), + severity: toRecordOrNull(incident?.severity), mode: asString(incident?.mode), visibility: asString(incident?.visibility), permalink: asString(incident?.permalink), created_at: asString(incident?.created_at), updated_at: asString(incident?.updated_at), - new_status: asObject(wrapper?.new_status), - previous_status: asObject(wrapper?.previous_status), + new_status: toRecordOrNull(wrapper?.new_status), + previous_status: toRecordOrNull(wrapper?.previous_status), update_message: asString(wrapper?.message), payload: b, }, @@ -203,7 +197,7 @@ export const incidentioHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - const b = asObject(body) + const b = toRecordOrNull(body) if (!b) return null const eventType = typeof b.event_type === 'string' ? b.event_type : '' const key = eventType.startsWith('public_alert.') ? 'alert' : 'incident' diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index dd0068ff561..0734212bd3b 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -110,7 +110,7 @@ export const linearHandler: WebhookProviderHandler = { const b = isRecordLike(body) ? body : {} const rawActor = b.actor let actor: unknown = null - if (rawActor && typeof rawActor === 'object' && !Array.isArray(rawActor)) { + if (isRecordLike(rawActor)) { const a = rawActor as Record const { type: linearActorType, ...rest } = a actor = { diff --git a/apps/sim/lib/webhooks/providers/pagerduty.ts b/apps/sim/lib/webhooks/providers/pagerduty.ts index f16124eaa1f..fe15d0d475c 100644 --- a/apps/sim/lib/webhooks/providers/pagerduty.ts +++ b/apps/sim/lib/webhooks/providers/pagerduty.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { toRecord } from '@sim/utils/object' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -42,10 +43,6 @@ function validatePagerDutySignature(secret: string, signature: string, body: str .some((part) => safeCompare(part.slice(3), computed)) } -function asRecord(value: unknown): Record { - return (value as Record) || {} -} - /** * Best-effort cleanup of a webhook subscription after a failed setup. Deletes by * id when known, otherwise finds the subscription pointing at `url` and deletes @@ -97,7 +94,7 @@ export const pagerdutyHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (!triggerId || triggerId === 'pagerduty_webhook') return true - const event = asRecord(asRecord(body).event) + const event = toRecord(toRecord(body).event) const eventType = event.event_type as string | undefined const { isPagerDutyEventMatch } = await import('@/triggers/pagerduty/utils') @@ -111,8 +108,8 @@ export const pagerdutyHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const event = asRecord(asRecord(body).event) - const data = asRecord(event.data) + const event = toRecord(toRecord(body).event) + const data = toRecord(event.data) const priority = referenceSummary(data.priority) return { @@ -139,7 +136,7 @@ export const pagerdutyHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - const event = asRecord(asRecord(body).event) + const event = toRecord(toRecord(body).event) return (event.id as string | undefined) || null }, @@ -177,10 +174,10 @@ export const pagerdutyHandler: WebhookProviderHandler = { throw new Error(`Failed to create PagerDuty webhook subscription: ${res.status}`) } - const created = asRecord((await res.json().catch(() => ({}))) as unknown) - const subscription = asRecord(created.webhook_subscription) + const created = toRecord((await res.json().catch(() => ({}))) as unknown) + const subscription = toRecord(created.webhook_subscription) const externalId = subscription.id as string | undefined - const secret = asRecord(subscription.delivery_method).secret as string | undefined + const secret = toRecord(subscription.delivery_method).secret as string | undefined // The subscription exists once PagerDuty returns success; if it is missing // its id or signing secret, delete it so it is not orphaned, then fail. diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index 8ead8812df0..f53dcf65f54 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike, toRecord } from '@sim/utils/object' import { NextResponse } from 'next/server' import type { AuthContext, @@ -26,7 +27,7 @@ export function extractSalesforceObjectTypeFromPayload( } const record = body.record - if (record && typeof record === 'object' && !Array.isArray(record)) { + if (isRecordLike(record)) { const r = record as Record if (typeof r.sobjectType === 'string') { return r.sobjectType @@ -49,15 +50,9 @@ function verifySalesforceSharedSecret(request: Request, secret: string): boolean return verifyTokenAuth(request, secret) } -function asRecord(body: unknown): Record { - return body && typeof body === 'object' && !Array.isArray(body) - ? (body as Record) - : {} -} - function extractRecordCore(body: Record): Record { const nested = body.record - if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + if (isRecordLike(nested)) { return { ...(nested as Record) } } @@ -135,7 +130,7 @@ export const salesforceHandler: WebhookProviderHandler = { const { isSalesforceEventMatch } = await import('@/triggers/salesforce/utils') const configuredObjectType = providerConfig.objectType as string | undefined - const obj = asRecord(body) + const obj = toRecord(body) if (!isSalesforceEventMatch(triggerId, obj, configuredObjectType)) { logger.debug( @@ -150,12 +145,9 @@ export const salesforceHandler: WebhookProviderHandler = { async formatInput(ctx: FormatInputContext): Promise { const rawPc = (ctx.webhook as { providerConfig?: unknown }).providerConfig - const pc = - rawPc && typeof rawPc === 'object' && !Array.isArray(rawPc) - ? (rawPc as Record) - : {} + const pc = isRecordLike(rawPc) ? (rawPc as Record) : {} const id = typeof pc.triggerId === 'string' ? pc.triggerId : '' - const body = asRecord(ctx.body) + const body = toRecord(ctx.body) const record = extractRecordCore(body) const objectType = @@ -300,7 +292,7 @@ export const salesforceHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown): string | null { - const b = asRecord(body) + const b = toRecord(body) const record = extractRecordCore(b) const id = pickRecordId(b, record) const et = diff --git a/apps/sim/lib/webhooks/providers/servicenow.ts b/apps/sim/lib/webhooks/providers/servicenow.ts index 8118bd72ed8..43c79e4e159 100644 --- a/apps/sim/lib/webhooks/providers/servicenow.ts +++ b/apps/sim/lib/webhooks/providers/servicenow.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { toRecord } from '@sim/utils/object' import { NextResponse } from 'next/server' import type { AuthContext, @@ -9,12 +10,6 @@ import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:ServiceNow') -function asRecord(body: unknown): Record { - return body && typeof body === 'object' && !Array.isArray(body) - ? (body as Record) - : {} -} - export const servicenowHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { const secret = providerConfig.webhookSecret as string | undefined @@ -42,7 +37,7 @@ export const servicenowHandler: WebhookProviderHandler = { const { isServiceNowEventMatch } = await import('@/triggers/servicenow/utils') const configuredTableName = providerConfig.tableName as string | undefined - const obj = asRecord(body) + const obj = toRecord(body) if (!isServiceNowEventMatch(triggerId, obj, configuredTableName)) { logger.debug( diff --git a/apps/sim/lib/webhooks/providers/tiktok.ts b/apps/sim/lib/webhooks/providers/tiktok.ts index 4196a11a2fd..a3aed74ab05 100644 --- a/apps/sim/lib/webhooks/providers/tiktok.ts +++ b/apps/sim/lib/webhooks/providers/tiktok.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' +import { toRecord, toRecordOrNull } from '@sim/utils/object' import { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import type { @@ -93,20 +94,15 @@ export function verifyTikTokSignature( return null } -function asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - return value as Record -} - /** * Parse the TikTok envelope `content` field (a JSON string) into an object. */ export function parseTikTokContent(content: unknown): Record { if (typeof content !== 'string' || content.length === 0) { - return asRecord(content) ?? {} + return toRecord(content) } try { - return asRecord(JSON.parse(content)) ?? {} + return toRecord(JSON.parse(content)) } catch { logger.warn('Failed to parse TikTok webhook content JSON string') return {} @@ -141,7 +137,7 @@ export const tiktokHandler: WebhookProviderHandler = { if (!triggerId) return true const { isTikTokEventMatch } = await import('@/triggers/tiktok/utils') - const event = stringField(asRecord(body) ?? {}, 'event') + const event = stringField(toRecord(body), 'event') if (!isTikTokEventMatch(triggerId, event)) { logger.debug( `[${requestId}] TikTok event mismatch for trigger ${triggerId}. Event: ${event}. Skipping.` @@ -152,7 +148,7 @@ export const tiktokHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const envelope = asRecord(body) ?? {} + const envelope = toRecord(body) const content = parseTikTokContent(envelope.content) const event = typeof envelope.event === 'string' ? envelope.event : '' const commonInput: Record = { @@ -205,7 +201,7 @@ export const tiktokHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - const envelope = asRecord(body) + const envelope = toRecordOrNull(body) if (!envelope) return null const event = typeof envelope.event === 'string' ? envelope.event : null diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts index 099931c4ad4..93319493703 100644 --- a/apps/sim/lib/webhooks/providers/vercel.ts +++ b/apps/sim/lib/webhooks/providers/vercel.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -261,7 +262,7 @@ export const vercelHandler: WebhookProviderHandler = { const linksRaw = payload.links let links: { deployment: string; project: string } | null = null - if (linksRaw && typeof linksRaw === 'object' && !Array.isArray(linksRaw)) { + if (isRecordLike(linksRaw)) { const L = linksRaw as Record const dep = L.deployment const proj = L.project @@ -279,7 +280,7 @@ export const vercelHandler: WebhookProviderHandler = { let deploymentMeta: Record | null = null if (deployment && typeof deployment === 'object') { const meta = (deployment as Record).meta - if (meta && typeof meta === 'object' && !Array.isArray(meta)) { + if (isRecordLike(meta)) { deploymentMeta = meta as Record } } diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 5bde7ef5de7..a62fec720c4 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' import { hmacSha256Hex } from '@sim/security/hmac' +import { isRecordLike } from '@sim/utils/object' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import type { @@ -14,26 +15,22 @@ import type { const logger = createLogger('WebhookProvider:WhatsApp') -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function getWhatsAppChanges( body: unknown ): Array<{ field?: string; value: Record }> { - if (!isRecord(body) || !Array.isArray(body.entry)) { + if (!isRecordLike(body) || !Array.isArray(body.entry)) { return [] } const changes: Array<{ field?: string; value: Record }> = [] for (const entry of body.entry) { - if (!isRecord(entry) || !Array.isArray(entry.changes)) { + if (!isRecordLike(entry) || !Array.isArray(entry.changes)) { continue } for (const change of entry.changes) { - if (!isRecord(change) || !isRecord(change.value)) { + if (!isRecordLike(change) || !isRecordLike(change.value)) { continue } @@ -48,7 +45,7 @@ function getWhatsAppChanges( } function normalizeWhatsAppContact(contact: Record) { - const profile = isRecord(contact.profile) ? contact.profile : undefined + const profile = isRecordLike(contact.profile) ? contact.profile : undefined return { wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : undefined, @@ -75,7 +72,7 @@ function extractWhatsAppMedia(message: Record) { return undefined } - const media = isRecord(message[type]) ? (message[type] as Record) : undefined + const media = isRecordLike(message[type]) ? (message[type] as Record) : undefined if (!media) { return undefined } @@ -93,7 +90,7 @@ function normalizeWhatsAppMessage( message: Record, metadata?: Record ) { - const text = isRecord(message.text) ? message.text : undefined + const text = isRecordLike(message.text) ? message.text : undefined const media = extractWhatsAppMedia(message) return { @@ -132,8 +129,8 @@ function normalizeWhatsAppStatus( : undefined, status: typeof status.status === 'string' ? status.status : undefined, timestamp: typeof status.timestamp === 'string' ? status.timestamp : undefined, - conversation: isRecord(status.conversation) ? status.conversation : undefined, - pricing: isRecord(status.pricing) ? status.pricing : undefined, + conversation: isRecordLike(status.conversation) ? status.conversation : undefined, + pricing: isRecordLike(status.pricing) ? status.pricing : undefined, raw: status, } } @@ -270,7 +267,7 @@ export const whatsappHandler: WebhookProviderHandler = { for (const { field, value } of getWhatsAppChanges(body)) { if (Array.isArray(value.messages)) { for (const message of value.messages) { - if (!isRecord(message) || typeof message.id !== 'string') { + if (!isRecordLike(message) || typeof message.id !== 'string') { continue } @@ -280,7 +277,7 @@ export const whatsappHandler: WebhookProviderHandler = { if (Array.isArray(value.statuses)) { for (const status of value.statuses) { - if (!isRecord(status) || typeof status.id !== 'string') { + if (!isRecordLike(status) || typeof status.id !== 'string') { continue } @@ -292,7 +289,7 @@ export const whatsappHandler: WebhookProviderHandler = { if (Array.isArray(value.groups)) { for (const group of value.groups) { - if (!isRecord(group) || typeof group.request_id !== 'string') { + if (!isRecordLike(group) || typeof group.request_id !== 'string') { continue } @@ -309,7 +306,7 @@ export const whatsappHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const payload = isRecord(body) ? body : undefined + const payload = isRecordLike(body) ? body : undefined const contacts: Array<{ wa_id?: string; profile?: { name?: string } }> = [] const messages: Array<{ messageId?: string @@ -339,11 +336,11 @@ export const whatsappHandler: WebhookProviderHandler = { }> = [] for (const { value } of getWhatsAppChanges(body)) { - const metadata = isRecord(value.metadata) ? value.metadata : undefined + const metadata = isRecordLike(value.metadata) ? value.metadata : undefined if (Array.isArray(value.contacts)) { for (const contact of value.contacts) { - if (!isRecord(contact)) { + if (!isRecordLike(contact)) { continue } @@ -353,7 +350,7 @@ export const whatsappHandler: WebhookProviderHandler = { if (Array.isArray(value.messages)) { for (const message of value.messages) { - if (!isRecord(message)) { + if (!isRecordLike(message)) { continue } @@ -363,7 +360,7 @@ export const whatsappHandler: WebhookProviderHandler = { if (Array.isArray(value.statuses)) { for (const status of value.statuses) { - if (!isRecord(status)) { + if (!isRecordLike(status)) { continue } diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts index 665f11b5853..452eec0ef37 100644 --- a/apps/sim/lib/webhooks/providers/zendesk.ts +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { toRecord } from '@sim/utils/object' import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -16,10 +17,6 @@ import type { const logger = createLogger('WebhookProvider:Zendesk') -function asRecord(value: unknown): Record { - return (value as Record) || {} -} - /** Zendesk API base for a subdomain. */ function zendeskApiBase(subdomain: string): string { return `https://${subdomain}.zendesk.com/api/v2` @@ -121,7 +118,7 @@ export const zendeskHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (!triggerId || triggerId === 'zendesk_webhook') return true - const eventType = asRecord(body).type as string | undefined + const eventType = toRecord(body).type as string | undefined const { isZendeskEventMatch } = await import('@/triggers/zendesk/utils') if (!isZendeskEventMatch(triggerId, eventType || '')) { @@ -134,9 +131,9 @@ export const zendeskHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const b = asRecord(body) - const detail = asRecord(b.detail) - const via = asRecord(detail.via) + const b = toRecord(body) + const detail = toRecord(b.detail) + const via = toRecord(detail.via) return { input: { @@ -167,7 +164,7 @@ export const zendeskHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - return (asRecord(body).id as string | undefined) || null + return (toRecord(body).id as string | undefined) || null }, async createSubscription(ctx: SubscriptionContext): Promise { @@ -218,8 +215,8 @@ export const zendeskHandler: WebhookProviderHandler = { throw new Error(`Failed to create Zendesk webhook: ${createRes.status}`) } - const created = asRecord((await createRes.json().catch(() => ({}))) as unknown) - const externalId = asRecord(created.webhook).id as string | undefined + const created = toRecord((await createRes.json().catch(() => ({}))) as unknown) + const externalId = toRecord(created.webhook).id as string | undefined if (!externalId) throw new Error('Zendesk webhook created but no webhook ID was returned.') const secretRes = await fetch(`${apiBase}/webhooks/${externalId}/signing_secret`, { @@ -235,8 +232,8 @@ export const zendeskHandler: WebhookProviderHandler = { throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`) } - const secretBody = asRecord((await secretRes.json().catch(() => ({}))) as unknown) - const secret = asRecord(secretBody.signing_secret).secret as string | undefined + const secretBody = toRecord((await secretRes.json().catch(() => ({}))) as unknown) + const secret = toRecord(secretBody.signing_secret).secret as string | undefined if (!secret) { await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId) throw new Error('Zendesk did not return a signing secret for the webhook.') diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 60f4e0ef749..677b39aa9cb 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' @@ -67,12 +68,9 @@ async function resolveZoomChallengeSecrets( const resolvedRows = await Promise.all( rows.map(async (row) => { - const rawConfig = - row.providerConfig && - typeof row.providerConfig === 'object' && - !Array.isArray(row.providerConfig) - ? (row.providerConfig as Record) - : {} + const rawConfig = isRecordLike(row.providerConfig) + ? (row.providerConfig as Record) + : {} try { const config = await resolveEnvVarsInObject( diff --git a/apps/sim/lib/workflows/application/update-workflow-content.ts b/apps/sim/lib/workflows/application/update-workflow-content.ts index 9d60b59d277..d654907c506 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.ts @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' import { and, eq, isNull } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -87,7 +88,7 @@ function coerceWorkflowVariableValue(value: unknown, type: string): unknown { try { const parsed: unknown = JSON.parse(String(value)) if (type === 'array' && Array.isArray(parsed)) return parsed - if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (type === 'object' && isRecordLike(parsed)) { return parsed } } catch (error) { @@ -103,10 +104,9 @@ function applyVariableOperations( currentVariables: unknown, operations: readonly WorkflowVariableOperation[] ): { variables: Record; changed: boolean } { - const current = - currentVariables && typeof currentVariables === 'object' && !Array.isArray(currentVariables) - ? (currentVariables as Record) - : {} + const current = isRecordLike(currentVariables) + ? (currentVariables as Record) + : {} const byName = new Map() for (const value of Object.values(current)) { if ( diff --git a/apps/sim/lib/workflows/autolayout/constants.ts b/apps/sim/lib/workflows/autolayout/constants.ts index f4a9356d4cb..cdbe021f04e 100644 --- a/apps/sim/lib/workflows/autolayout/constants.ts +++ b/apps/sim/lib/workflows/autolayout/constants.ts @@ -53,11 +53,6 @@ export const ROOT_PADDING_Y = 150 */ export const DEFAULT_LAYOUT_PADDING = { x: 150, y: 150 } -/** - * Margin for overlap detection - */ -export const OVERLAP_MARGIN = 30 - /** * Maximum iterations for overlap resolution */ @@ -78,24 +73,6 @@ export const AUTO_LAYOUT_EXCLUDED_TYPES = new Set([NOTE_BLOCK_TYPE]) */ export const CONTAINER_BLOCK_TYPES = new Set(['loop', 'parallel']) -/** - * Estimated height per subblock when no measured height is available. - * Used as a heuristic for new blocks that haven't been rendered yet. - */ -export const ESTIMATED_SUBBLOCK_HEIGHT = 45 - -/** - * Bottom padding added to estimated block height - */ -export const ESTIMATED_BLOCK_BOTTOM_PADDING = 20 - -/** - * Maximum estimated block height when no measurement is available. - * Prevents wildly over-estimated heights for blocks with many conditional - * subblocks (e.g. agent blocks define ~20 subblocks but only ~5 are visible). - */ -export const MAX_ESTIMATED_BLOCK_HEIGHT = 350 - /** * Default layout options */ diff --git a/apps/sim/lib/workflows/blocks/canvas-sentence.ts b/apps/sim/lib/workflows/blocks/canvas-sentence.ts index f88af9654f3..b8979245a5d 100644 --- a/apps/sim/lib/workflows/blocks/canvas-sentence.ts +++ b/apps/sim/lib/workflows/blocks/canvas-sentence.ts @@ -1,3 +1,4 @@ +import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' import { resolveFieldNoun } from '@/lib/workflows/blocks/canvas-sentence-noun' import { resolveTriggerSentence } from '@/lib/workflows/blocks/canvas-trigger-sentence' import type { @@ -26,16 +27,6 @@ import type { */ export type ResolvedSentenceSegment = string | { subBlockId: string; noun?: string } -/** - * The subblock id that carries a block's operation. - * - * Singular only. Four blocks (`elasticsearch`, `mailchimp`, `onepassword`, - * `typeform`) also declare an `operations` subblock, but it holds a JSON-patch - * payload rather than an operation selector — matching it could only ever key - * a sentence off the wrong value. - */ -const OPERATION_SUBBLOCK_ID = 'operation' - type CanvasSentenceConfig = Pick /** diff --git a/apps/sim/lib/workflows/blocks/flatten-outputs.ts b/apps/sim/lib/workflows/blocks/flatten-outputs.ts index c00c367d28a..44d393b6323 100644 --- a/apps/sim/lib/workflows/blocks/flatten-outputs.ts +++ b/apps/sim/lib/workflows/blocks/flatten-outputs.ts @@ -8,6 +8,7 @@ * output shapes, BFS sort order) don't drift between consumers. */ +import { isRecordLike } from '@sim/utils/object' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' /** @@ -90,9 +91,7 @@ export function flattenWorkflowOutputs( const add = (path: string, outputObj: unknown, prefix = ''): void => { const fullPath = prefix ? `${prefix}.${path}` : path const declaredType = - outputObj && - typeof outputObj === 'object' && - !Array.isArray(outputObj) && + isRecordLike(outputObj) && 'type' in (outputObj as object) && typeof (outputObj as { type: unknown }).type === 'string' ? (outputObj as { type: string }).type diff --git a/apps/sim/lib/workflows/comparison/normalize.ts b/apps/sim/lib/workflows/comparison/normalize.ts index 94a1ba0c17a..ac3d3b74365 100644 --- a/apps/sim/lib/workflows/comparison/normalize.ts +++ b/apps/sim/lib/workflows/comparison/normalize.ts @@ -3,6 +3,7 @@ * Used by both client-side signature computation and server-side comparison. */ +import { isRecordLike } from '@sim/utils/object' import { normalizeWorkflowEdgeSourceHandle, normalizeWorkflowEdgeTargetHandle, @@ -198,7 +199,7 @@ export function sanitizeTools(tools: unknown[] | undefined): Record { - if (tool && typeof tool === 'object' && !Array.isArray(tool)) { + if (isRecordLike(tool)) { const { isExpanded, ...rest } = tool as ToolWithExpanded return rest } @@ -290,7 +291,7 @@ type InputFormatItem = Record & { collapsed?: boolean } export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record[] { if (!Array.isArray(inputFormat)) return [] return inputFormat.map((item) => { - if (item && typeof item === 'object' && !Array.isArray(item)) { + if (isRecordLike(item)) { const { collapsed, ...rest } = item as InputFormatItem return rest } diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index f1d5d661687..4683bda7a6c 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -2,37 +2,11 @@ import { isPlainRecord } from '@sim/utils/object' import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker' -import { - buildCanonicalIndex, - buildSubBlockValues, - evaluateSubBlockCondition, - hasAdvancedValues, - isSubBlockFeatureEnabled, - isSubBlockVisibleForMode, - type SubBlockCondition, -} from '@/lib/workflows/subblocks/visibility' import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' -import { AuthMode } from '@/blocks/types' import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types' -// Credential types based on actual patterns in the codebase -enum CredentialType { - OAUTH = 'oauth', - SECRET = 'secret', // password: true (covers API keys, bot tokens, passwords, etc.) -} - -// Type for credential requirement -export interface CredentialRequirement { - type: CredentialType - serviceId?: string // For OAuth (e.g., 'google-drive', 'slack') - label: string // Human-readable label - blockType: string // The block type that requires this - subBlockId: string // The subblock ID for reference - required: boolean -} - /** * Resource-selector types NOT cleared by the workspace rule below. Everything else the resource * registry knows about IS cleared, so the two lists can never drift apart again — the previous @@ -98,121 +72,6 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ */ const OPAQUE_CREDENTIAL_BEARING_TYPES: ReadonlySet = new Set(['table']) -/** - * Extract required credentials from a workflow state - * This analyzes all blocks and their subblocks to identify credential requirements - */ -export function extractRequiredCredentials( - state: Partial | null | undefined -): CredentialRequirement[] { - const credentials: CredentialRequirement[] = [] - const seen = new Set() - - if (!state?.blocks) { - return credentials - } - - // Process each block - Object.values(state.blocks).forEach((block: BlockState) => { - if (!block?.type) return - - const blockConfig = getBlock(block.type) - if (!blockConfig) return - - // Add OAuth credential if block has OAuth auth mode - if (blockConfig.authMode === AuthMode.OAuth) { - const blockName = blockConfig.name || block.type - const key = `oauth-${block.type}` - - if (!seen.has(key)) { - seen.add(key) - credentials.push({ - type: CredentialType.OAUTH, - serviceId: block.type, - label: `Credential for ${blockName}`, - blockType: block.type, - subBlockId: 'oauth', - required: true, - }) - } - } - - // Process password fields (API keys, tokens, etc) - blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => { - if (!isSubBlockVisible(block, subBlockConfig)) return - if (!subBlockConfig.password) return - - const blockName = blockConfig.name || block.type - const suffix = block?.triggerMode ? ' Trigger' : '' - const fieldLabel = subBlockConfig.title || formatFieldName(subBlockConfig.id) - const key = `secret-${block.type}-${subBlockConfig.id}-${block?.triggerMode ? 'trigger' : 'default'}` - - if (!seen.has(key)) { - seen.add(key) - credentials.push({ - type: CredentialType.SECRET, - label: `${fieldLabel} for ${blockName}${suffix}`, - blockType: block.type, - subBlockId: subBlockConfig.id, - required: subBlockConfig.required !== false, - }) - } - }) - }) - - /** Helper to check visibility, respecting mode and conditions */ - function isSubBlockVisible(block: BlockState, subBlockConfig: SubBlockConfig): boolean { - if (!isSubBlockFeatureEnabled(subBlockConfig)) return false - - const values = buildSubBlockValues(block?.subBlocks || {}) - const blockConfig = getBlock(block.type) - const blockSubBlocks = blockConfig?.subBlocks || [] - const canonicalIndex = buildCanonicalIndex(blockSubBlocks) - const effectiveAdvanced = - (block?.advancedMode ?? false) || hasAdvancedValues(blockSubBlocks, values, canonicalIndex) - const canonicalModeOverrides = block.data?.canonicalModes - - if (subBlockConfig.mode === 'trigger' && !block?.triggerMode) return false - if (block?.triggerMode && subBlockConfig.mode && subBlockConfig.mode !== 'trigger') return false - - if ( - !isSubBlockVisibleForMode( - subBlockConfig, - effectiveAdvanced, - canonicalIndex, - values, - canonicalModeOverrides - ) - ) { - return false - } - - return evaluateSubBlockCondition(subBlockConfig.condition as SubBlockCondition, values) - } - - // Sort: OAuth first, then secrets, alphabetically within each type - credentials.sort((a, b) => { - if (a.type !== b.type) { - return a.type === CredentialType.OAUTH ? -1 : 1 - } - return a.label.localeCompare(b.label) - }) - - return credentials -} - -/** - * Format field name to be human-readable - */ -function formatFieldName(fieldName: string): string { - return fieldName - .replace(/[_-]/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .split(' ') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' ') -} - interface MutableSubBlockState extends Omit { value: unknown } @@ -452,13 +311,3 @@ export function sanitizeWorkflowForSharing( return sanitized } - -/** - * Sanitize workflow state for templates (removes credentials and workspace data) - * Wrapper for backward compatibility - */ -export function sanitizeCredentials( - state: Partial | null | undefined -): SanitizedWorkflowState { - return sanitizeWorkflowForSharing(state, { preserveEnvVars: false }) -} diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 4056e4db2c4..b934d6428b0 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -264,12 +264,6 @@ export async function getCustomBlockWithInputsByWorkflowId( return row ? hydrateCustomBlockRow(row) : null } -/** Fetch a single custom block row by id. */ -export async function getCustomBlockById(id: string) { - const [row] = await db.select().from(customBlock).where(eq(customBlock.id, id)).limit(1) - return row ?? null -} - /** * Org + source-workspace context for manage (edit/delete) authorization. Managing * a block is gated on admin of its SOURCE workflow's workspace — the same workspace diff --git a/apps/sim/lib/workflows/dynamic-handle-topology.ts b/apps/sim/lib/workflows/dynamic-handle-topology.ts index 91e05b5cd2b..00feb4b6c0a 100644 --- a/apps/sim/lib/workflows/dynamic-handle-topology.ts +++ b/apps/sim/lib/workflows/dynamic-handle-topology.ts @@ -24,12 +24,6 @@ function parseStructuredValue(value: unknown): unknown[] | null { return Array.isArray(value) ? value : null } -export function isDynamicHandleBlockType( - type: string | undefined -): type is 'condition' | 'router_v2' { - return type === 'condition' || type === 'router_v2' -} - export function getDynamicHandleSubblockId( blockType: string | undefined ): 'conditions' | 'routes' | null { diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index 45cd90ca3a3..e13003fe1f8 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -345,17 +345,6 @@ export function encodeSSEEvent(event: ExecutionEvent): Uint8Array { return new TextEncoder().encode(formatSSEEvent(event)) } -/** - * Options for creating SSE execution callbacks - */ -interface SSECallbackOptions { - executionId: string - workflowId: string - controller: ReadableStreamDefaultController - isStreamClosed: () => boolean - setStreamClosed: () => void -} - /** * Creates execution callbacks using a provided event sink. */ @@ -554,25 +543,3 @@ export function createExecutionCallbacks(options: { onChildWorkflowInstanceReady, } } - -/** - * Creates SSE callbacks for workflow execution streaming - */ -export function createSSECallbacks(options: SSECallbackOptions) { - const { executionId, workflowId, controller, isStreamClosed, setStreamClosed } = options - - const sendEvent = (event: ExecutionEvent) => { - if (isStreamClosed()) return - try { - controller.enqueue(encodeSSEEvent(event)) - } catch { - setStreamClosed() - } - } - - return createExecutionCallbacks({ - executionId, - workflowId, - sendEvent, - }) -} diff --git a/apps/sim/lib/workflows/executor/execution-queries.test.ts b/apps/sim/lib/workflows/executor/execution-queries.test.ts index 0b01c5b6f15..52750473ef4 100644 --- a/apps/sim/lib/workflows/executor/execution-queries.test.ts +++ b/apps/sim/lib/workflows/executor/execution-queries.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing/mocks' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetJob, mockGetJobQueue } = vi.hoisted(() => ({ @@ -63,12 +63,87 @@ describe('resolveWorkflowExecutionOwnership', () => { ).resolves.toMatchObject({ workflowGroupWorkspaceId: null }) }) + /** + * A cancel has to tell a live run apart from one that had already finished, + * and the row it would ask for is the row this query already reads. + */ + it('projects the durable status from the same log row it already reads', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { workflowId: 'workflow-1', status: 'completed' }, + ]) + + await expect( + resolveWorkflowExecutionOwnership('execution-1', 'workflow-1') + ).resolves.toMatchObject({ belongsToWorkflow: true, priorStatus: 'completed' }) + }) + + /** + * The row-queue mock returns whatever was queued for a table regardless of the + * predicate, so every other test here passes with the `WHERE` deleted. Execution + * ids are globally unique but nothing in the mock enforces that a lookup keyed + * on the wrong column — or on nothing — would fail, and this resolver is what + * every mutating caller trusts to say which workflow an execution belongs to. + */ + it('keys both durable reads on the execution id', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [{ workflowId: 'workflow-1' }]) + + await resolveWorkflowExecutionOwnership('execution-1', 'workflow-1') + + expect(dbChainMockFns.from).toHaveBeenNthCalledWith(1, schemaMock.workflowExecutionLogs) + expect(dbChainMockFns.from).toHaveBeenNthCalledWith(2, schemaMock.pausedExecutions) + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(1, { + type: 'eq', + left: schemaMock.workflowExecutionLogs.executionId, + right: 'execution-1', + }) + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(2, { + type: 'eq', + left: schemaMock.pausedExecutions.executionId, + right: 'execution-1', + }) + }) + + /** + * A run that paused before its log row landed — or whose log row is gone — is + * still durable, and the paused row's workflow id is the only thing standing + * between it and the queue fallback, which would answer `false` for a run the + * queue no longer holds. Nothing else here queues a `pausedExecutions` row, so + * dropping it from the ownership decision is otherwise invisible. + */ + it('resolves ownership from a paused-only execution', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, []) + queueTableRows(schemaMock.pausedExecutions, [{ workflowId: 'workflow-1' }]) + + await expect( + resolveWorkflowExecutionOwnership('execution-1', 'workflow-1') + ).resolves.toMatchObject({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: null, + }) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) + + it('rejects a paused-only execution bound to another workflow', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, []) + queueTableRows(schemaMock.pausedExecutions, [{ workflowId: 'workflow-2' }]) + + await expect( + resolveWorkflowExecutionOwnership('execution-1', 'workflow-1') + ).resolves.toMatchObject({ belongsToWorkflow: false }) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) + it('checks deterministic queue metadata before the durable log exists', async () => { mockGetJob.mockResolvedValue({ metadata: { workflowId: 'workflow-1' } }) await expect( resolveWorkflowExecutionOwnership('execution-1', 'workflow-1') - ).resolves.toMatchObject({ belongsToWorkflow: true, workflowGroupWorkspaceId: null }) + ).resolves.toMatchObject({ + belongsToWorkflow: true, + workflowGroupWorkspaceId: null, + priorStatus: null, + }) expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1') }) }) diff --git a/apps/sim/lib/workflows/executor/execution-queries.ts b/apps/sim/lib/workflows/executor/execution-queries.ts index 9c221871f79..6c150192eae 100644 --- a/apps/sim/lib/workflows/executor/execution-queries.ts +++ b/apps/sim/lib/workflows/executor/execution-queries.ts @@ -104,6 +104,11 @@ export interface WorkflowExecutionOwnership { * that has no log row yet, and a paused-only run. */ workflowGroupWorkspaceId: string | null + /** + * Status the durable log row already carried. `null` when there is no log row + * — a queue-only run, or a paused-only run. + */ + priorStatus: string | null } /** @@ -112,10 +117,12 @@ export interface WorkflowExecutionOwnership { * operating on an execution id because execution ids are globally unique, not * nested DB keys under a workflow. * - * The workflow-group origin rides along on the same log row the ownership check - * already reads. A group run owns a table cell sidecar, so cancelling only the - * workflow log would leave the cell stuck as running — and resolving that from a - * second SELECT of the identical row would double the read on every cancel. + * The workflow-group origin and the row's current status both ride along on the + * same log row the ownership check already reads. A group run owns a table cell + * sidecar, so cancelling only the workflow log would leave the cell stuck as + * running; and a cancel has to tell a live run apart from one that had already + * finished. Resolving either from a second SELECT of the identical row would + * double the read on every cancel. */ export async function resolveWorkflowExecutionOwnership( executionId: string, @@ -126,6 +133,7 @@ export async function resolveWorkflowExecutionOwnership( .select({ workflowId: workflowExecutionLogs.workflowId, workspaceId: workflowExecutionLogs.workspaceId, + status: workflowExecutionLogs.status, executionOrigin: workflowExecutionOriginSql(), }) .from(workflowExecutionLogs) @@ -149,6 +157,7 @@ export async function resolveWorkflowExecutionOwnership( return { belongsToWorkflow: durableWorkflowIds.every((value) => value === workflowId), workflowGroupWorkspaceId, + priorStatus: logRow?.status ?? null, } } @@ -157,5 +166,6 @@ export async function resolveWorkflowExecutionOwnership( return { belongsToWorkflow: job?.metadata.workflowId === workflowId, workflowGroupWorkspaceId: null, + priorStatus: null, } } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 5754349fc63..aaf109c3cc0 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -12,6 +12,7 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core/execution-limits' import { abortManualExecution } from '@/lib/execution/manual-cancellation' +import { terminalExecutionLogFields } from '@/lib/logs/execution/cancellation' const { mockReleaseExecutionSlot, mockReplaceLargeValueReferenceKeysWithClient } = vi.hoisted( () => ({ @@ -1533,15 +1534,56 @@ describe('PauseResumeManager blocked resume readmission', () => { }) }) -describe('PauseResumeManager completed resume transitions', () => { +describe('PauseResumeManager terminal resume failure', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() }) - it('clears the active attempt deadline when sibling pause points remain', async () => { + async function markResumeFailed(): Promise { + const managerInternals = PauseResumeManager as unknown as PauseResumeManagerInternals + await managerInternals.markResumeFailed({ + resumeEntryId: 'resume-entry-1', + pausedExecutionId: 'paused-exec-1', + parentExecutionId: 'execution-1', + contextId: 'context-1', + failureReason: 'Resume execution failed', + }) + } + + it('terminalizes the parent log: end timestamp, derived duration, deadline cleared', async () => { queueTableRows(workflowExecutionLogs, [{ status: 'running' }]) queueTableRows(pausedExecutions, [{ status: 'paused' }]) + + await markResumeFailed() + + const logUpdate = dbChainMockFns.set.mock.calls.at(-1)?.[0] as { + status: string + endedAt: Date + totalDurationMs: unknown + executionDeadlineAt: Date | null + } + expect(logUpdate.status).toBe('failed') + expect(logUpdate.endedAt).toBeInstanceOf(Date) + expect(logUpdate.executionDeadlineAt).toBeNull() + expect(JSON.stringify(logUpdate.totalDurationMs)).toContain(logUpdate.endedAt.toISOString()) + }) +}) + +describe('PauseResumeManager completed resume transitions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + interface MockSqlFragment { + values: unknown[] + toSQL: () => { sql: string } + } + + async function markPartialResumeCompleted(logStatus: string): Promise { + queueTableRows(workflowExecutionLogs, [{ status: logStatus }]) + queueTableRows(pausedExecutions, [{ status: 'paused' }]) queueTableRows(resumeQueue, [{ status: 'claimed' }]) queueTableRows(pausedExecutions, [{ remaining: 1 }]) const markResumeCompleted = Reflect.get(PauseResumeManager, 'markResumeCompleted') as (args: { @@ -1557,16 +1599,101 @@ describe('PauseResumeManager completed resume transitions', () => { parentExecutionId: 'execution-1', contextId: 'context-1', }) + } - expect(dbChainMockFns.set).toHaveBeenNthCalledWith(3, { - status: 'pending', - executionDeadlineAt: null, - }) + /** The revival is the third write: resume queue, paused execution, then the log. */ + function revivalPayload(): { + status: string + executionDeadlineAt: Date | null + endedAt: MockSqlFragment + totalDurationMs: MockSqlFragment + } { + return dbChainMockFns.set.mock.calls.at(-1)?.[0] + } + + it('clears the active attempt deadline when sibling pause points remain', async () => { + await markPartialResumeCompleted('running') + + expect(dbChainMockFns.set).toHaveBeenCalledTimes(3) + const revival = revivalPayload() + expect(revival.status).toBe('pending') + expect(revival.executionDeadlineAt).toBeNull() expect(dbChainMockFns.from).toHaveBeenNthCalledWith(1, workflowExecutionLogs) expect(dbChainMockFns.from).toHaveBeenNthCalledWith(2, pausedExecutions) expect(dbChainMockFns.from).toHaveBeenNthCalledWith(3, resumeQueue) }) + /** + * The revival claim excludes only `cancelled`, so it also matches a row + * `markResumeFailed` already ended: one context's resume fails, a sibling + * context resumes successfully afterwards, and the run goes live again. It + * must not go live still carrying the end timestamp and duration of the + * attempt that failed — a run waiting on its remaining pause points has not + * ended, and reporting that it has puts it in the `minDurationMs`/ + * `maxDurationMs` filters on `GET /api/v2/logs` with a duration measured at + * something other than its own end. + */ + it('clears the terminal stamp when a partial resume revives a force-failed row', async () => { + await markPartialResumeCompleted('failed') + + const revival = revivalPayload() + expect(Object.keys(revival).sort()).toEqual([ + 'endedAt', + 'executionDeadlineAt', + 'status', + 'totalDurationMs', + ]) + expect(revival.status).toBe('pending') + expect(revival.endedAt.toSQL().sql).toContain("IN ('failed', 'completed') THEN NULL") + expect(revival.totalDurationMs.toSQL().sql).toContain("IN ('failed', 'completed') THEN NULL") + expect(revival.endedAt.values[0]).toBe(workflowExecutionLogs.status) + expect(revival.totalDurationMs.values[0]).toBe(workflowExecutionLogs.status) + }) + + /** + * The opposite case, and the reason the clear is conditional rather than + * unconditional: a row revived from a non-terminal status carries the + * checkpoint `completeWithPause` banked, which is the active duration + * `elapsedDurationMsSql` deliberately preserves for a `pending` row. Nulling + * that would redefine a later terminal duration to include the time the run + * sat waiting. + */ + it('keeps the checkpoint a still-live row banked at its pause', async () => { + await markPartialResumeCompleted('running') + + const revival = revivalPayload() + expect(revival.endedAt.toSQL().sql).toContain('ELSE ?') + expect(revival.totalDurationMs.toSQL().sql).toContain('ELSE ?') + expect(revival.endedAt.values.at(-1)).toBe(workflowExecutionLogs.endedAt) + expect(revival.totalDurationMs.values.at(-1)).toBe(workflowExecutionLogs.totalDurationMs) + }) + + /** + * The compounding half. `elapsedDurationMsSql` preserves a `pending` row's + * `total_duration_ms` and recomputes otherwise, so whatever the revival leaves + * behind is what the next terminal write — a cancel, say — records as the run's + * duration. Leaving the failed resume's frozen value there would freeze the + * cancel at it; leaving `NULL` is what makes the `COALESCE` fall through to the + * elapsed computation. + * + * The link is asserted as a composition rather than executed: the repository + * has no in-memory Postgres, and under the drizzle mock a fragment renders its + * interpolations as `?` with the bound columns on `values`. + */ + it('lets the next terminal write recompute rather than preserve the failed resume duration', async () => { + await markPartialResumeCompleted('failed') + + const revived = revivalPayload().totalDurationMs + expect(revived.toSQL().sql).toContain("IN ('failed', 'completed') THEN NULL") + + const nextTerminalWrite = terminalExecutionLogFields( + 'cancelled', + new Date('2026-08-14T12:00:00.000Z') + ).totalDurationMs as unknown as MockSqlFragment + expect(nextTerminalWrite.toSQL().sql).toContain("= 'pending' THEN ?") + expect(nextTerminalWrite.values).toContain(workflowExecutionLogs.totalDurationMs) + }) + it('fails a claimed resume after cancellation wins the log lock', async () => { queueTableRows(workflowExecutionLogs, [{ status: 'cancelled' }]) queueTableRows(pausedExecutions, [{ status: 'cancelled' }]) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 48029178fdd..2dfef26f5d4 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -33,7 +33,10 @@ import { } from '@/lib/execution/payloads/large-value-metadata' import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { preprocessExecution } from '@/lib/execution/preprocessing' -import { cancelledExecutionLogFields } from '@/lib/logs/execution/cancellation' +import { + cancelledExecutionLogFields, + terminalExecutionLogFields, +} from '@/lib/logs/execution/cancellation' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' @@ -249,6 +252,32 @@ function clearAutomaticResumeWaitingMetadataSql(contextId: string): SQL { END` } +/** + * The terminal columns a `workflow_execution_logs` row keeps when a partial + * resume moves it back to `pending`. + * + * The revival claim excludes only `cancelled`, so it also matches a row + * `markResumeFailed` already ended: one context's resume fails, a sibling + * context resumes successfully afterwards, and the run becomes live again + * carrying the end timestamp and duration of the attempt that failed. A live row + * must not carry a terminal stamp — and because `elapsedDurationMsSql` preserves + * a `pending` row's `total_duration_ms` as its pause checkpoint, leaving it + * there also hands the next terminal write a duration frozen at the failed + * resume rather than one it recomputes. + * + * A row revived from a non-terminal status is the opposite case: its columns are + * the checkpoint `completeWithPause` banked, which is precisely what that + * preservation rule exists to keep, so they survive untouched. The row's own + * status decides, read — like every expression in the same `SET` — against the + * pre-update row. + */ +const revivedExecutionLogStamp = { + endedAt: sql`CASE WHEN ${workflowExecutionLogs.status} IN ('failed', 'completed') THEN NULL ELSE ${workflowExecutionLogs.endedAt} END`, + totalDurationMs: sql< + number | null + >`CASE WHEN ${workflowExecutionLogs.status} IN ('failed', 'completed') THEN NULL ELSE ${workflowExecutionLogs.totalDurationMs} END`, +} + function withoutAutomaticResumeWaitingReason( point: Record ): Record { @@ -1160,12 +1189,7 @@ export class PauseResumeManager { })() const submissionPayload = - normalizedResumeInputRaw && - typeof normalizedResumeInputRaw === 'object' && - !Array.isArray(normalizedResumeInputRaw) && - normalizedResumeInputRaw.submission && - typeof normalizedResumeInputRaw.submission === 'object' && - !Array.isArray(normalizedResumeInputRaw.submission) + isRecordLike(normalizedResumeInputRaw) && isRecordLike(normalizedResumeInputRaw.submission) ? (normalizedResumeInputRaw.submission as Record) : (normalizedResumeInputRaw as Record) @@ -2104,7 +2128,7 @@ export class PauseResumeManager { } else { await tx .update(workflowExecutionLogs) - .set({ status: 'pending', executionDeadlineAt: null }) + .set({ status: 'pending', executionDeadlineAt: null, ...revivedExecutionLogStamp }) .where( and( eq(workflowExecutionLogs.executionId, targetParentExecutionId), @@ -2173,7 +2197,7 @@ export class PauseResumeManager { await tx .update(workflowExecutionLogs) - .set({ status: 'failed' }) + .set(terminalExecutionLogFields('failed', now)) .where( and( eq(workflowExecutionLogs.executionId, args.parentExecutionId), diff --git a/apps/sim/lib/workflows/operations/deployment-utils.ts b/apps/sim/lib/workflows/operations/deployment-utils.ts index 76bab43f807..020576b1916 100644 --- a/apps/sim/lib/workflows/operations/deployment-utils.ts +++ b/apps/sim/lib/workflows/operations/deployment-utils.ts @@ -6,47 +6,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('DeploymentUtils') -interface InputField { - name: string - type: string -} - -/** - * Gets the input format from the Start block - * Returns an array of field definitions with name and type - */ -export function getStartBlockInputFormat(): InputField[] { - try { - const candidates = resolveStartCandidates(useWorkflowStore.getState().blocks, { - execution: 'api', - }) - - const targetCandidate = - candidates.find((candidate) => candidate.path === StartBlockPath.UNIFIED) || - candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_API) || - candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_INPUT) || - candidates.find((candidate) => candidate.path === StartBlockPath.LEGACY_STARTER) - - const targetBlock = targetCandidate?.block - - if (targetBlock) { - const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat') - if (inputFormat && Array.isArray(inputFormat)) { - return inputFormat - .map((field: { name?: string; type?: string }) => ({ - name: field.name || '', - type: field.type || 'string', - })) - .filter((field) => field.name) - } - } - } catch (error) { - logger.warn('Error getting start block input format:', error) - } - - return [] -} - /** * Gets the input format example for a workflow's API deployment * Returns the -d flag with example data if inputs exist, empty string otherwise diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 92d9f140628..31a7c342e56 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -12,6 +12,7 @@ import { FolderLockedError, } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { normalizeWorkflowEdgeSourceHandle, normalizeWorkflowEdgeTargetHandle, @@ -276,10 +277,7 @@ export async function duplicateWorkflow( const newBlockId = blockIdMapping.get(block.id)! // Update parent ID to point to the new parent block ID if it exists - const blockData = - block.data && typeof block.data === 'object' && !Array.isArray(block.data) - ? (block.data as any) - : {} + const blockData = isRecordLike(block.data) ? (block.data as any) : {} let newParentId = blockData.parentId if (blockData.parentId && blockIdMapping.has(blockData.parentId)) { newParentId = blockIdMapping.get(blockData.parentId)! @@ -288,7 +286,7 @@ export async function duplicateWorkflow( // Update data.parentId and extent if they exist in the data object let updatedData = block.data let newExtent = blockData.extent - if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) { + if (isRecordLike(block.data)) { const dataObj = block.data as any if (dataObj.parentId && typeof dataObj.parentId === 'string') { updatedData = { ...dataObj } @@ -303,29 +301,16 @@ export async function duplicateWorkflow( // Update variable references in subBlocks (e.g. variables-input assignments) let updatedSubBlocks = block.subBlocks - if ( - updatedSubBlocks && - typeof updatedSubBlocks === 'object' && - !Array.isArray(updatedSubBlocks) - ) { + if (isRecordLike(updatedSubBlocks)) { updatedSubBlocks = sanitizeSubBlocksForDuplicate(updatedSubBlocks as SubBlockRecord) } - if ( - varIdMapping.size > 0 && - updatedSubBlocks && - typeof updatedSubBlocks === 'object' && - !Array.isArray(updatedSubBlocks) - ) { + if (varIdMapping.size > 0 && isRecordLike(updatedSubBlocks)) { updatedSubBlocks = remapVariableIdsInSubBlocks( updatedSubBlocks as SubBlockRecord, varIdMapping ) } - if ( - updatedSubBlocks && - typeof updatedSubBlocks === 'object' && - !Array.isArray(updatedSubBlocks) - ) { + if (isRecordLike(updatedSubBlocks)) { updatedSubBlocks = remapWorkflowReferencesInSubBlocks( updatedSubBlocks as SubBlockRecord, workflowIdMap diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts index 09217823190..2e5213ef178 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { remapConditionBlockIds } from '@/lib/workflows/condition-ids' import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology' import { @@ -26,10 +27,6 @@ const DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS = new Set( SYSTEM_SUBBLOCK_IDS.filter((id) => id !== 'triggerCredentials') ) -export function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)) -} - /** Coerce a subblock value that holds a JSON array (stored as an array or a JSON string). */ export function coerceObjectArray(value: unknown): { array: unknown[] | null; wasString: boolean } { if (Array.isArray(value)) return { array: value, wasString: false } @@ -62,7 +59,7 @@ function remapVariableAssignment(value: unknown, varIdMap: Map): if (Array.isArray(value)) { return value.map((item) => remapVariableAssignment(item, varIdMap)) } - if (!isRecord(value)) { + if (!isRecordLike(value)) { return value } const assignment = value as VariableAssignment @@ -300,7 +297,8 @@ function remapWorkflowInputTools( if (!array) return value let changed = false const next = array.flatMap((tool) => { - if (!isRecord(tool) || tool.type !== 'workflow_input' || !isRecord(tool.params)) return [tool] + if (!isRecordLike(tool) || tool.type !== 'workflow_input' || !isRecordLike(tool.params)) + return [tool] const workflowId = tool.params.workflowId if (typeof workflowId !== 'string') return [tool] const mapped = workflowIdMap.get(workflowId) diff --git a/apps/sim/lib/workflows/schedules/utils.ts b/apps/sim/lib/workflows/schedules/utils.ts index ab30c80bb27..11791e90e8f 100644 --- a/apps/sim/lib/workflows/schedules/utils.ts +++ b/apps/sim/lib/workflows/schedules/utils.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { formatDateTime, getTimezoneAbbreviation } from '@sim/utils/formatting' +import { getTimezoneAbbreviation } from '@sim/utils/formatting' import { Cron } from 'croner' import cronstrue from 'cronstrue' @@ -487,157 +487,3 @@ const REVERSE_DAY_MAP: Record = { } export type ScheduleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom' - -export interface CronFormState { - scheduleType: ScheduleType - minutesInterval: string - hourlyMinute: string - dailyTime: string - weeklyDay: string - weeklyDayTime: string - monthlyDay: string - monthlyTime: string - cronExpression: string -} - -const CRON_FORM_DEFAULTS: CronFormState = { - scheduleType: 'custom', - minutesInterval: '15', - hourlyMinute: '0', - dailyTime: '09:00', - weeklyDay: 'MON', - weeklyDayTime: '09:00', - monthlyDay: '1', - monthlyTime: '09:00', - cronExpression: '', -} - -/** - * Reverse-parses a cron expression into schedule type and form field values. - * Used to pre-populate the schedule modal when editing an existing schedule. - */ -export function parseCronToScheduleType(cronExpression: string | null | undefined): CronFormState { - if (!cronExpression?.trim()) { - return { ...CRON_FORM_DEFAULTS } - } - - const parts = cronExpression.trim().split(/\s+/) - if (parts.length !== 5) { - return { ...CRON_FORM_DEFAULTS, cronExpression } - } - - const [minute, hour, dayOfMonth, month, dayOfWeek] = parts - const pad = (n: number) => String(n).padStart(2, '0') - - if ( - minute.startsWith('*/') && - hour === '*' && - dayOfMonth === '*' && - month === '*' && - dayOfWeek === '*' - ) { - const interval = Number.parseInt(minute.slice(2), 10) - if (!Number.isNaN(interval) && interval > 0) { - return { ...CRON_FORM_DEFAULTS, scheduleType: 'minutes', minutesInterval: String(interval) } - } - } - - const m = Number.parseInt(minute, 10) - const h = Number.parseInt(hour, 10) - - if ( - !Number.isNaN(m) && - hour === '*' && - dayOfMonth === '*' && - month === '*' && - dayOfWeek === '*' - ) { - return { ...CRON_FORM_DEFAULTS, scheduleType: 'hourly', hourlyMinute: String(m) } - } - - if ( - !Number.isNaN(m) && - !Number.isNaN(h) && - dayOfMonth === '*' && - month === '*' && - dayOfWeek === '*' - ) { - return { ...CRON_FORM_DEFAULTS, scheduleType: 'daily', dailyTime: `${pad(h)}:${pad(m)}` } - } - - if ( - !Number.isNaN(m) && - !Number.isNaN(h) && - dayOfMonth === '*' && - month === '*' && - dayOfWeek !== '*' - ) { - const dow = Number.parseInt(dayOfWeek, 10) - const dayName = REVERSE_DAY_MAP[dow] - if (dayName) { - return { - ...CRON_FORM_DEFAULTS, - scheduleType: 'weekly', - weeklyDay: dayName, - weeklyDayTime: `${pad(h)}:${pad(m)}`, - } - } - } - - if ( - !Number.isNaN(m) && - !Number.isNaN(h) && - dayOfMonth !== '*' && - month === '*' && - dayOfWeek === '*' - ) { - const dom = Number.parseInt(dayOfMonth, 10) - if (!Number.isNaN(dom) && dom >= 1 && dom <= 31) { - return { - ...CRON_FORM_DEFAULTS, - scheduleType: 'monthly', - monthlyDay: String(dom), - monthlyTime: `${pad(h)}:${pad(m)}`, - } - } - } - - return { ...CRON_FORM_DEFAULTS, cronExpression } -} - -/** - * Format schedule information for display - */ -export const getScheduleInfo = ( - cronExpression: string | null, - nextRunAt: string | null, - lastRanAt: string | null, - scheduleType?: string | null, - timezone?: string | null -): { - scheduleTiming: string - nextRunFormatted: string | null - lastRunFormatted: string | null -} => { - if (!nextRunAt) { - return { - scheduleTiming: 'Unknown schedule', - nextRunFormatted: null, - lastRunFormatted: null, - } - } - - let scheduleTiming = 'Unknown schedule' - - if (cronExpression) { - scheduleTiming = parseCronToHumanReadable(cronExpression, timezone || undefined) - } else if (scheduleType) { - scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}` - } - - return { - scheduleTiming, - nextRunFormatted: formatDateTime(new Date(nextRunAt)), - lastRunFormatted: lastRanAt ? formatDateTime(new Date(lastRanAt)) : null, - } -} diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 2d0fdb23b7f..5b5dccf4307 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks' import type { SubBlockType } from '@sim/workflow-types/blocks' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' @@ -138,20 +139,12 @@ const TOOL_INPUT_TEXT_EXCLUDED_PATH_KEYS = new Set(['schema']) type WorkflowSearchSubBlockConfig = Pick & Partial type DisplayLabelLeaf = { value: string; path: WorkflowSearchValuePath; fieldTitle?: string } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - function looksLikeStoredSkillList(value: unknown): boolean { return ( Array.isArray(value) && value.length > 0 && value.every( - (item) => - item && - typeof item === 'object' && - !Array.isArray(item) && - typeof (item as Record).skillId === 'string' + (item) => isRecordLike(item) && typeof (item as Record).skillId === 'string' ) ) } @@ -166,7 +159,7 @@ function looksLikeStructuredString(value: string): boolean { function getFallbackToolParamType(value: unknown, paramType?: string): SubBlockType { if (paramType === 'object') return 'workflow-input-mapper' - if (value && typeof value === 'object' && !Array.isArray(value)) return 'workflow-input-mapper' + if (isRecordLike(value)) return 'workflow-input-mapper' if (typeof value !== 'string') return DEFAULT_SUBBLOCK_TYPE as SubBlockType const trimmed = value.trim() @@ -176,7 +169,7 @@ function getFallbackToolParamType(value: unknown, paramType?: string): SubBlockT try { const parsed: unknown = JSON.parse(trimmed) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (isRecordLike(parsed)) { return 'workflow-input-mapper' } } catch {} @@ -315,10 +308,11 @@ function getOptionLabelLeaves( function getMcpDynamicArgEnumLabelLeaves(value: unknown, schema: unknown): DisplayLabelLeaf[] { const parsedValue = typeof value === 'string' ? safeParseJson(value) : value - if (!isRecord(parsedValue) || !isRecord(schema) || !isRecord(schema.properties)) return [] + if (!isRecordLike(parsedValue) || !isRecordLike(schema) || !isRecordLike(schema.properties)) + return [] return Object.entries(schema.properties).flatMap(([paramName, paramSchema]) => { - if (!isRecord(paramSchema) || !Array.isArray(paramSchema.enum)) return [] + if (!isRecordLike(paramSchema) || !Array.isArray(paramSchema.enum)) return [] const selectedValue = parsedValue[paramName] if (selectedValue === undefined || selectedValue === null || selectedValue === '') return [] return [ diff --git a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts index a91091289fa..96464f3bd89 100644 --- a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts +++ b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts @@ -5,7 +5,6 @@ import type { WorkflowSearchResourceMeta, WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' -import type { SelectorContext } from '@/hooks/selectors/types' /** * Which kind wins when two matches cover the same span. Exported so the @@ -67,16 +66,6 @@ export function getWorkflowSearchMatchResourceGroupKey(match: WorkflowSearchMatc ) } -export function selectorContextMatches( - left: SelectorContext | undefined, - right: SelectorContext | undefined -): boolean { - return ( - stableStringifyWorkflowSearchValue(left ?? {}) === - stableStringifyWorkflowSearchValue(right ?? {}) - ) -} - export function replacementOptionMatchesResourceMatch( option: WorkflowSearchReplacementOption, match: WorkflowSearchMatch diff --git a/apps/sim/lib/workflows/search-replace/value-walker.ts b/apps/sim/lib/workflows/search-replace/value-walker.ts index 3f9070fbfd6..4842f7670f7 100644 --- a/apps/sim/lib/workflows/search-replace/value-walker.ts +++ b/apps/sim/lib/workflows/search-replace/value-walker.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type { WorkflowSearchValuePath } from '@/lib/workflows/search-replace/types' export interface WalkedStringValue { @@ -6,10 +7,6 @@ export interface WalkedStringValue { originalValue: unknown } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - export function walkStringValues( value: unknown, path: WorkflowSearchValuePath = [] @@ -26,7 +23,7 @@ export function walkStringValues( return value.flatMap((item, index) => walkStringValues(item, [...path, index])) } - if (isRecord(value)) { + if (isRecordLike(value)) { return Object.entries(value).flatMap(([key, item]) => walkStringValues(item, [...path, key])) } @@ -38,7 +35,7 @@ export function getValueAtPath(value: unknown, path: WorkflowSearchValuePath): u if (Array.isArray(current) && typeof segment === 'number') { return current[segment] } - if (isRecord(current) && typeof segment === 'string') { + if (isRecordLike(current) && typeof segment === 'string') { return current[segment] } return undefined @@ -61,7 +58,7 @@ export function setValueAtPath( return copy } - if (isRecord(value)) { + if (isRecordLike(value)) { if (typeof segment !== 'string') return value return { ...value, diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts index 6d7eb892696..f18ef078638 100644 --- a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts @@ -25,6 +25,7 @@ * See docs: workflows/deployment/agent-events. */ +import { isRecordLike } from '@sim/utils/object' import { isToolCallEndStatus, type ToolCallEndStatus } from '@/providers/stream-events' /** Lookup key. Lowercase because HTTP/2 lowercases on the wire; `Headers.get` is case-insensitive either way. */ @@ -112,17 +113,13 @@ export type ChatStreamFrame = | ChatStreamErrorFrame | ChatStreamStreamErrorFrame -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' -} - /** * Answer text frame: `{ blockId, chunk }` with no `event` discriminator. * Positively defined so thinking/tool/terminal frames can never be appended * into the answer by a client that checks this first. */ export function isChatChunkFrame(value: unknown): value is ChatStreamChunkFrame { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return ( typeof value.blockId === 'string' && typeof value.chunk === 'string' && @@ -132,12 +129,12 @@ export function isChatChunkFrame(value: unknown): value is ChatStreamChunkFrame } export function isChatChunkResetFrame(value: unknown): value is ChatStreamChunkResetFrame { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return value.event === 'chunk_reset' && typeof value.blockId === 'string' } export function isChatThinkingFrame(value: unknown): value is ChatStreamThinkingFrame { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return ( value.event === 'thinking' && typeof value.blockId === 'string' && @@ -146,7 +143,7 @@ export function isChatThinkingFrame(value: unknown): value is ChatStreamThinking } export function isChatToolFrame(value: unknown): value is ChatStreamToolFrame { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return ( value.event === 'tool' && typeof value.blockId === 'string' && @@ -163,17 +160,17 @@ export function isChatToolFrame(value: unknown): value is ChatStreamToolFrame { } export function isChatFinalFrame(value: unknown): value is ChatStreamFinalFrame { - if (!isRecord(value)) return false - return value.event === 'final' && isRecord(value.data) + if (!isRecordLike(value)) return false + return value.event === 'final' && isRecordLike(value.data) } export function isChatErrorFrame(value: unknown): value is ChatStreamErrorFrame { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return value.event === 'error' } export function isChatStreamErrorFrame(value: unknown): value is ChatStreamStreamErrorFrame { - if (!isRecord(value)) return false + if (!isRecordLike(value)) return false return value.event === 'stream_error' } diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index ef588530a03..c5fc887f086 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -367,21 +367,6 @@ export function reindexToolCanonicalModes( return reindexCanonicalModesByPosition(newIndexByOldIndex, overrides) } -/** - * Check if a block has any standalone advanced-only fields (not part of canonical pairs). - * These require the block-level advanced mode toggle to be visible. - */ -export function hasStandaloneAdvancedFields( - subBlocks: SubBlockConfig[], - canonicalIndex: CanonicalIndex -): boolean { - for (const subBlock of subBlocks) { - if (!isStandaloneAdvancedMode(subBlock.mode)) continue - if (!canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) return true - } - return false -} - /** * True for the modes that make a field advanced-only when it is not part of a * canonical basic/advanced pair: a standalone `advanced` field, or a standalone diff --git a/apps/sim/lib/workflows/tool-input/types.ts b/apps/sim/lib/workflows/tool-input/types.ts index 6e4fe0ced6e..9c1914a7f2a 100644 --- a/apps/sim/lib/workflows/tool-input/types.ts +++ b/apps/sim/lib/workflows/tool-input/types.ts @@ -1,3 +1,5 @@ +import { isRecordLike } from '@sim/utils/object' + interface StoredToolSchema { description?: string properties?: Record @@ -39,10 +41,9 @@ export function parseStoredToolInputValue(value: unknown): ParsedStoredTool[] { const record = tool as Record if (typeof record.type !== 'string') return [] - const params = - record.params && typeof record.params === 'object' && !Array.isArray(record.params) - ? (record.params as Record) - : undefined + const params = isRecordLike(record.params) + ? (record.params as Record) + : undefined return [ { @@ -60,10 +61,7 @@ export function parseStoredToolInputValue(value: unknown): ParsedStoredTool[] { ? record.usageControl : undefined, isExpanded: typeof record.isExpanded === 'boolean' ? record.isExpanded : undefined, - schema: - record.schema && typeof record.schema === 'object' && !Array.isArray(record.schema) - ? (record.schema as StoredToolSchema) - : undefined, + schema: isRecordLike(record.schema) ? (record.schema as StoredToolSchema) : undefined, }, ] }) diff --git a/apps/sim/lib/workflows/triggers/mock-payload.ts b/apps/sim/lib/workflows/triggers/mock-payload.ts index 18c69b7397f..23bd9cc9bc8 100644 --- a/apps/sim/lib/workflows/triggers/mock-payload.ts +++ b/apps/sim/lib/workflows/triggers/mock-payload.ts @@ -1,3 +1,5 @@ +import { isRecordLike } from '@sim/utils/object' + /** * Mock payload generation from a trigger's `outputs` definition. * @@ -88,7 +90,7 @@ function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 1 return generateMockValue(typedField.type, typedField.description, key) } - if (field && typeof field === 'object' && !Array.isArray(field)) { + if (isRecordLike(field)) { const nestedObject: Record = {} for (const [nestedKey, nestedField] of Object.entries(field)) { nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth) diff --git a/apps/sim/lib/workflows/triggers/run-options.ts b/apps/sim/lib/workflows/triggers/run-options.ts index 853459ee2f6..c8d79d588b6 100644 --- a/apps/sim/lib/workflows/triggers/run-options.ts +++ b/apps/sim/lib/workflows/triggers/run-options.ts @@ -58,7 +58,7 @@ export interface TriggerInputValidationResult { function readSubBlockValue(block: TriggerBlockLike, key: string): unknown { const raw = (block.subBlocks as Record | undefined)?.[key] - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + if (isRecordLike(raw)) { return (raw as { value?: unknown }).value } return undefined @@ -104,7 +104,7 @@ function outputFieldToSchema(field: unknown): Record { return { type: mapOutputType(typed.type) } } - if (field && typeof field === 'object' && !Array.isArray(field)) { + if (isRecordLike(field)) { const properties: Record = {} for (const [key, value] of Object.entries(field)) { properties[key] = outputFieldToSchema(value) diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 0ab01bb7fda..b333ea4b12a 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -26,63 +26,6 @@ export function hasValidStartBlockInState(state: WorkflowState | null | undefine return !!startBlock } -interface TriggerInfo { - id: string - name: string - description: string - icon: React.ComponentType<{ className?: string }> - color: string - category: 'core' | 'integration' - enableTriggerMode?: boolean -} - -/** - * Get all blocks that can act as triggers - * This includes both dedicated trigger blocks and tools with trigger capabilities - */ -export function getAllTriggerBlocks(): TriggerInfo[] { - const allBlocks = getAllBlocks() - const triggers: TriggerInfo[] = [] - - for (const block of allBlocks) { - // Skip hidden blocks - if (block.hideFromToolbar) continue - - // Check if it's a core trigger block (category: 'triggers') - if (block.category === 'triggers') { - triggers.push({ - id: block.type, - name: block.name, - description: block.description, - icon: block.icon, - color: block.bgColor, - category: 'core', - enableTriggerMode: hasTriggerCapability(block), - }) - } - // Check if it's a tool with trigger capability (has trigger-config subblock) - else if (hasTriggerCapability(block)) { - triggers.push({ - id: block.type, - name: block.name, - description: block.description.replace(' or trigger workflows from ', ', trigger from '), - icon: block.icon, - color: block.bgColor, - category: 'integration', - enableTriggerMode: true, - }) - } - } - - // Sort: core triggers first, then integration triggers, alphabetically within each category - return triggers.sort((a, b) => { - if (a.category !== b.category) { - return a.category === 'core' ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) -} - /** * Check if a block has trigger capability (contains trigger mode subblocks) */ @@ -112,16 +55,6 @@ export function getTriggersForSidebar(): BlockConfig[] { }) } -/** - * Get the proper display name for a trigger block in the UI - */ -export function getTriggerDisplayName(blockType: string): string { - const block = getBlock(blockType) - if (!block) return blockType - - return block.name -} - /** * Groups triggers by their immediate downstream blocks to identify disjoint paths */ diff --git a/apps/sim/lib/workflows/triggers/triggers.ts b/apps/sim/lib/workflows/triggers/triggers.ts index 6fe61714756..4bbe304c53f 100644 --- a/apps/sim/lib/workflows/triggers/triggers.ts +++ b/apps/sim/lib/workflows/triggers/triggers.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { getBlock } from '@/blocks' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -124,10 +125,6 @@ export function classifyStartBlock(block: T): StartBlock return classifyStartBlockType(block.type, { category, triggerModeEnabled }) } -export function isLegacyStartPath(path: StartBlockPath): boolean { - return path !== StartBlockPath.UNIFIED -} - function toEntries(blocks: Record | T[]): Array<[string, T]> { if (Array.isArray(blocks)) { return blocks.map((block, index) => { @@ -224,7 +221,7 @@ type SubBlockWithValue = { value?: unknown } function readSubBlockValue(subBlocks: Record | undefined, key: string): unknown { const raw = subBlocks?.[key] - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + if (isRecordLike(raw)) { return (raw as SubBlockWithValue).value } return undefined diff --git a/apps/sim/lib/workspace-events/subscriptions.ts b/apps/sim/lib/workspace-events/subscriptions.ts index 67951b46519..ec254877383 100644 --- a/apps/sim/lib/workspace-events/subscriptions.ts +++ b/apps/sim/lib/workspace-events/subscriptions.ts @@ -1,5 +1,6 @@ import { db } from '@sim/db' import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' import { and, eq, isNull, or } from 'drizzle-orm' import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import { @@ -98,10 +99,7 @@ function parseBoundedNumber( * Returns null when the config has no recognizable event type. */ export function parseSubscriptionConfig(providerConfig: unknown): SimSubscriptionConfig | null { - const config = - providerConfig && typeof providerConfig === 'object' && !Array.isArray(providerConfig) - ? (providerConfig as Record) - : {} + const config = isRecordLike(providerConfig) ? (providerConfig as Record) : {} const eventType = config.eventType if ( diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts index 4d29c4211c5..3214cdf11f4 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts @@ -24,6 +24,7 @@ vi.mock('@/lib/public-shares/share-manager', () => ({ getShareForResource: mocks.getShareForResource, })) +import { NoWorkspaceAccessError } from '@/lib/core/application' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' const canonical = { @@ -91,6 +92,61 @@ describe('readWorkspaceFileMetadata', () => { expect(mocks.getShareForResource).toHaveBeenCalledWith('file', 'file-1') }) + it('resolves a soft-deleted file when the caller opts into the archived lifecycle set', async () => { + const archived = { ...file, deletedAt: new Date('2026-01-03T00:00:00Z') } + mocks.getWorkspaceFile.mockResolvedValueOnce(archived) + + await expect( + readWorkspaceFileMetadata.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + includeDeleted: true, + }, + }) + ).resolves.toEqual({ file: archived, share }) + + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: true }) + expect(mocks.getWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + includeDeleted: true, + throwOnError: true, + }) + }) + + it('authorizes an archived read exactly like an active one', async () => { + mocks.resolvePermission.mockResolvedValueOnce(null) + + await expect( + readWorkspaceFileMetadata.execute({ + principal: { kind: 'session', userId: 'outsider', sessionId: 'session-2' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + includeDeleted: true, + }, + }) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + + expect(mocks.getWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.getShareForResource).not.toHaveBeenCalled() + }) + + it('still refuses an archived read that asserts the wrong workspace', async () => { + await expect( + readWorkspaceFileMetadata.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-2', + includeDeleted: true, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.getWorkspaceFile).not.toHaveBeenCalled() + }) + it('fails fast if the authorized file disappears before projection', async () => { mocks.getWorkspaceFile.mockResolvedValueOnce(null) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts index 5be9e1c9c99..621d20f1324 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts @@ -14,6 +14,12 @@ import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/applica export interface ReadWorkspaceFileMetadataInput { fileId: string assertedWorkspaceId?: string + /** + * Opt into the archived lifecycle set. It relaxes only the `deleted_at` predicate on the + * canonical row lookup — the workspace the file resolves to, the asserted-workspace check, + * and the `files.read_metadata` authorization that follows are identical either way, so it + * never widens who may read a file. + */ includeDeleted?: boolean } diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index d350c5a1763..4b01ec40ece 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -1,6 +1,7 @@ import { cache } from 'react' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -27,6 +28,7 @@ async function resolveWorkspaceHostContextForViewer( ? getOrganizationSettingsAccess(hostOrganizationId, userId) : Promise.resolve({ isMember: false, isAdmin: false }), ]) + const credentialGroupsAvailable = await isCredentialGroupsAvailable(ownerBilling) return { workspace: { @@ -42,6 +44,9 @@ async function resolveWorkspaceHostContextForViewer( isHostOrganizationMember: hostOrganizationAccess.isMember, isHostOrganizationAdmin: hostOrganizationAccess.isAdmin, }, + features: { + credentialGroups: credentialGroupsAvailable, + }, } } diff --git a/apps/sim/lib/workspaces/naming.ts b/apps/sim/lib/workspaces/naming.ts index 02284ca7093..902d82d34bf 100644 --- a/apps/sim/lib/workspaces/naming.ts +++ b/apps/sim/lib/workspaces/naming.ts @@ -2,7 +2,6 @@ * Utility functions for generating names for workspaces and folders */ -import { randomItem } from '@sim/utils/random' import { requestJson } from '@/lib/api/client/request' import { type FolderApi, listFoldersContract } from '@/lib/api/contracts/folders' @@ -10,59 +9,6 @@ interface NameableEntity { name: string } -const WORKSPACE_NOUNS = [ - 'Pulsar', - 'Quasar', - 'Nebula', - 'Nova', - 'Cosmos', - 'Orion', - 'Vega', - 'Zenith', - 'Horizon', - 'Eclipse', - 'Aurora', - 'Photon', - 'Vertex', - 'Nexus', - 'Solaris', - 'Andromeda', - 'Phoenix', - 'Polaris', - 'Sirius', - 'Altair', - 'Meridian', - 'Titan', - 'Apex', - 'Aether', - 'Voyager', - 'Beacon', - 'Sentinel', - 'Pioneer', - 'Equinox', - 'Solstice', - 'Corona', - 'Stellar', - 'Helix', - 'Prism', - 'Axiom', - 'Boson', - 'Cygnus', - 'Draco', - 'Lyra', - 'Aquila', - 'Perseus', - 'Pegasus', - 'Triton', - 'Callisto', - 'Europa', - 'Oberon', - 'Tachyon', - 'Neutron', - 'Graviton', - 'Parallax', -] as const - /** * Generates the next incremental name for entities following pattern: "{prefix} {number}" * @@ -86,13 +32,6 @@ export function generateIncrementalName( return `${prefix} ${nextNumber}` } -/** - * Generates a random cosmos-themed workspace name - */ -export function generateWorkspaceName(): string { - return randomItem(WORKSPACE_NOUNS) -} - async function fetchWorkspaceFolders(workspaceId: string): Promise { const { folders } = await requestJson(listFoldersContract, { query: { workspaceId }, diff --git a/apps/sim/lib/workspaces/organization/utils.ts b/apps/sim/lib/workspaces/organization/utils.ts index 43f5990b29c..49c51f03bfe 100644 --- a/apps/sim/lib/workspaces/organization/utils.ts +++ b/apps/sim/lib/workspaces/organization/utils.ts @@ -77,14 +77,6 @@ export function generateSlug(name: string): string { .replace(/^-|-$/g, '') // Remove leading and trailing hyphens } -/** - * Validate organization slug format - */ -export function validateSlug(slug: string): boolean { - const slugRegex = /^[a-z0-9-_]+$/ - return slugRegex.test(slug) -} - /** * Validate email format */ diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 163b988bb6b..74da60b1d20 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -974,10 +974,7 @@ function enrichLastModelSegmentFromAnthropicResponse( const toolCalls: IterationToolCall[] = toolUseBlocks.map((t) => ({ id: t.id, name: t.name, - arguments: - t.input && typeof t.input === 'object' && !Array.isArray(t.input) - ? (t.input as Record) - : {}, + arguments: isRecordLike(t.input) ? (t.input as Record) : {}, })) const usage = createAnthropicUsageAccumulator() diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts index ff644eb0838..8563253e5bb 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -75,10 +75,7 @@ function enrichModelSegment( const toolCalls: IterationToolCall[] = toolUseBlocks.map((t) => ({ id: t.id, name: t.name, - arguments: - t.input && typeof t.input === 'object' && !Array.isArray(t.input) - ? (t.input as Record) - : {}, + arguments: isRecordLike(t.input) ? (t.input as Record) : {}, })) const usage = createAnthropicUsageAccumulator() diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index abd36b4c9cd..3acd28a99f1 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -77,10 +77,7 @@ function enrichLastModelSegmentFromBedrockResponse( return { id: b.toolUse.toolUseId ?? '', name: b.toolUse.name ?? '', - arguments: - input && typeof input === 'object' && !Array.isArray(input) - ? (input as Record) - : {}, + arguments: isRecordLike(input) ? (input as Record) : {}, } }) @@ -633,10 +630,9 @@ export const bedrockProvider: ProviderConfig = { const toolExecutionPromises = currentToolUses.map(async (toolUse: ToolUseBlock) => { const toolCallStartTime = Date.now() const toolName = toolUse.name || '' - const toolArgs = - toolUse.input && typeof toolUse.input === 'object' && !Array.isArray(toolUse.input) - ? (toolUse.input as Record) - : undefined + const toolArgs = isRecordLike(toolUse.input) + ? (toolUse.input as Record) + : undefined const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) try { diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts index d79e4277b29..c84b2ead349 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -373,16 +373,14 @@ export function createBedrockStreamingToolLoopStream( assembledToolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name || '' - const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined + /** Already a non-null, non-array object: `parseToolInput` throws otherwise. */ + const toolArgs: Record = toolUse.input const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) try { if (loopAbortController.signal.aborted) { throw new DOMException('Stream aborted', 'AbortError') } - if (!toolArgs) { - throw new Error(`Arguments for tool "${toolName}" must be an object`) - } const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -482,7 +480,7 @@ export function createBedrockStreamingToolLoopStream( toolUse, toolUseId, toolName, - toolArgs: toolArgs ?? {}, + toolArgs, toolParams: {} as Record, result: { success: false as const, diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index 93d05f6b9c8..0586bf8741f 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type OpenAI from 'openai' import { Stream } from 'openai/streaming' import { buildOpenAIMessageContent } from '@/providers/attachments' @@ -255,12 +256,8 @@ export function toResponsesToolChoice( return 'auto' } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function extractTextFromMessageItem(item: unknown): string { - if (!isRecord(item)) { + if (!isRecordLike(item)) { return '' } @@ -274,7 +271,7 @@ function extractTextFromMessageItem(item: unknown): string { const textParts: string[] = [] for (const part of item.content) { - if (!isRecord(part)) { + if (!isRecordLike(part)) { continue } @@ -361,7 +358,7 @@ export function extractResponseToolCalls( const toolCalls: ResponsesToolCall[] = [] for (const item of output) { - if (!isRecord(item)) { + if (!isRecordLike(item)) { continue } diff --git a/apps/sim/providers/pi-providers.ts b/apps/sim/providers/pi-providers.ts index 1053e911ce8..363c205d921 100644 --- a/apps/sim/providers/pi-providers.ts +++ b/apps/sim/providers/pi-providers.ts @@ -6,14 +6,6 @@ import { } from '@/providers/pi-provider-configs' import type { BYOKProviderId } from '@/tools/types' -/** - * Shared provider and model bridge for the Pi model picker, executor, host SDK, - * and E2B CLI. - */ -export const PI_SUPPORTED_PROVIDER_IDS: readonly PiSupportedProvider[] = PI_PROVIDER_CONFIGS.map( - ({ id }) => id -) - const PI_PROVIDER_CONFIG_BY_ID = new Map( PI_PROVIDER_CONFIGS.map((config) => [config.id, config]) ) diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index 529d7c0b09f..a1d70caa046 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { anthropicProvider } from '@/providers/anthropic' import { azureAnthropicProvider } from '@/providers/azure-anthropic' import { azureOpenAIProvider } from '@/providers/azure-openai' @@ -67,18 +66,3 @@ export async function getProviderExecutor( } return provider } - -export async function initializeProviders(): Promise { - for (const [id, provider] of Object.entries(providerRegistry)) { - if (provider.initialize) { - try { - await provider.initialize() - logger.info(`Initialized provider: ${id}`) - } catch (error) { - logger.error(`Failed to initialize ${id} provider`, { - error: getErrorMessage(error, 'Unknown error'), - }) - } - } - } -} diff --git a/apps/sim/providers/stream-events.ts b/apps/sim/providers/stream-events.ts index 09e460f730f..3180ffe1fb6 100644 --- a/apps/sim/providers/stream-events.ts +++ b/apps/sim/providers/stream-events.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' /** * Canonical agent stream event contract (provider → executor). * @@ -56,10 +57,6 @@ export type AgentStreamSink = { export type UnsubscribeAgentStreamSink = () => void -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - export function isToolCallEndStatus(value: unknown): value is ToolCallEndStatus { return value === 'success' || value === 'error' || value === 'cancelled' } @@ -73,7 +70,7 @@ export function isTextDeltaClassification(value: unknown): value is TextDeltaCla } export function isAgentStreamEvent(value: unknown): value is AgentStreamEvent { - if (!isRecord(value) || typeof value.type !== 'string') { + if (!isRecordLike(value) || typeof value.type !== 'string') { return false } diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index 342fd38b570..cdd83116f84 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type { BlockTokens, IterationToolCall, ProviderTimingSegment } from '@/executor/types' import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' import { @@ -128,7 +129,7 @@ function parseToolCallArguments(rawArguments: string): Record | if (typeof rawArguments !== 'string') return '' try { const parsed = JSON.parse(rawArguments) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (isRecordLike(parsed)) { return parsed as Record } return rawArguments diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index e2723a0c2ba..9deedf19aab 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -16,6 +16,7 @@ import { describeModelLevel, extractAndParseJSON, filterBlacklistedModels, + findProviderFromModel, formatCost, generateStructuredOutputInstructions, getAllModelProviders, @@ -948,6 +949,15 @@ describe('Provider Management', () => { expect(getProviderFromModel('unknown-model')).toBe('ollama') }) + it('should resolve gateway models that getBaseModelProviders deliberately omits', () => { + // getBaseModelProviders() filters these providers out entirely, so a model + // block that looked models up there rejected valid ids like these. + expect(getProviderFromModel('openrouter/meta-llama/llama-4-maverick')).toBe('openrouter') + expect(getProviderFromModel('together/some-model')).toBe('together') + expect(getProviderFromModel('fireworks/some-model')).toBe('fireworks') + expect(getBaseModelProviders()['openrouter/meta-llama/llama-4-maverick']).toBeUndefined() + }) + it('should be case insensitive', () => { expect(getProviderFromModel('GPT-4O')).toBe('openai') expect(getProviderFromModel('CLAUDE-SONNET-4-0')).toBe('anthropic') @@ -2036,3 +2046,27 @@ describe('describeModelLevel', () => { expect(describeModelLevel('')).toBe('(unset)') }) }) + +describe('findProviderFromModel', () => { + it('resolves a chat model to its declaring provider', () => { + expect(findProviderFromModel('claude-sonnet-5')).toBe('anthropic') + expect(findProviderFromModel('gpt-5.2')).toBe('openai') + }) + + it('is case-insensitive, like getProviderFromModel', () => { + expect(findProviderFromModel('Claude-Sonnet-5')).toBe('anthropic') + }) + + it('returns null for ids the registry does not declare, instead of guessing ollama', () => { + /* The registry holds chat models only. Speech, image, video and embedding + ids reach `model` subblocks too, and a permission gate must not read them + as Ollama models — see isModelUsable. */ + for (const id of ['whisper-1', 'dall-e-3', 'veo-3.1', 'embed-v4.0', 'tts-1']) { + expect(findProviderFromModel(id)).toBeNull() + } + }) + + it('still lets getProviderFromModel fall back to ollama for those ids', () => { + expect(getProviderFromModel('whisper-1')).toBe('ollama') + }) +}) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1cb25c7da2d..037dc757712 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -286,27 +286,35 @@ export function getAllModelProviders(): Record { ) } -export function getProviderFromModel(model: string): ProviderId { +/** + * The provider that declares `model`, or `null` when none does. + * + * The non-guessing half of {@link getProviderFromModel}. A caller that *gates* + * on the answer needs "unknown" to stay distinct from "ollama": this registry + * holds chat models only, so every embedding, speech, image and video model id + * would otherwise read as an Ollama model and be judged against an allowlist + * that was never about it. + */ +export function findProviderFromModel(model: string): ProviderId | null { const normalizedModel = model.toLowerCase() - let providerId: ProviderId | null = null + const declared = getAllModelProviders()[normalizedModel] + if (declared) return declared - if (normalizedModel in getAllModelProviders()) { - providerId = getAllModelProviders()[normalizedModel] - } else { - for (const [id, config] of Object.entries(providers)) { - if (config.modelPatterns) { - for (const pattern of config.modelPatterns) { - if (pattern.test(normalizedModel)) { - providerId = id as ProviderId - break - } - } - } - if (providerId) break + for (const [id, config] of Object.entries(providers)) { + for (const pattern of config.modelPatterns ?? []) { + if (pattern.test(normalizedModel)) return id as ProviderId } } + return null +} + +export function getProviderFromModel(model: string): ProviderId { + const normalizedModel = model.toLowerCase() + + let providerId = findProviderFromModel(model) + if (!providerId) { logger.warn(`No provider found for model: ${model}, defaulting to ollama`) providerId = 'ollama' diff --git a/apps/sim/scripts/function-sandbox-parity-manifest.ts b/apps/sim/scripts/function-sandbox-parity-manifest.ts index 652607c3f0b..1c524860efa 100644 --- a/apps/sim/scripts/function-sandbox-parity-manifest.ts +++ b/apps/sim/scripts/function-sandbox-parity-manifest.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { FUNCTION_NODE_MAJOR, FUNCTION_NPM_CLI_PACKAGE_CONTRACT, @@ -36,12 +37,8 @@ export interface FunctionSandboxParityManifest { commands: string[] } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function requireRecord(value: unknown, field: string): Record { - if (!isRecord(value)) throw new Error(`${field} must be an object`) + if (!isRecordLike(value)) throw new Error(`${field} must be an object`) return value } diff --git a/apps/sim/scripts/pi-sandbox-packages.ts b/apps/sim/scripts/pi-sandbox-packages.ts index a6646201ccd..a4326b682d7 100644 --- a/apps/sim/scripts/pi-sandbox-packages.ts +++ b/apps/sim/scripts/pi-sandbox-packages.ts @@ -58,13 +58,6 @@ export const PI_NODE_VERSION_ASSERT = /** Fails the build loudly if the sandbox does not contain the repository's Bun version. */ export const PI_BUN_VERSION_ASSERT = `test "$(bun --version)" = "${PI_BUN_VERSION}"` -/** - * The review tools run `python3 /workspace/sim-review-tools.py` - * (`cloud-review-tools.ts:15`). E2B's `code-interpreter-v1` base ships Python, so - * only the Daytona image has to provide it explicitly. - */ -export const PI_REQUIRES_PYTHON3 = true - /** * vCPU and RAM for the Pi sandbox, shared for the same reason the package lists * are: the two providers had already drifted here. Daytona asked for 4 CPU / 8 GB diff --git a/apps/sim/stores/chat/utils.ts b/apps/sim/stores/chat/utils.ts index 037b65da220..390253b6012 100644 --- a/apps/sim/stores/chat/utils.ts +++ b/apps/sim/stores/chat/utils.ts @@ -39,14 +39,6 @@ const calculateDefaultPosition = (): ChatPosition => { return { x, y } } -/** - * Get the default chat dimensions - */ -export const getDefaultChatDimensions = () => ({ - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, -}) - /** * Calculate constrained position ensuring chat stays within bounds */ diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index 5f4115418fb..90506e37cf5 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -294,9 +294,3 @@ export const useFolderStore = create()( { name: 'folder-store' } ) ) - -export const useIsWorkflowSelected = (workflowId: string) => - useFolderStore((state) => state.selectedWorkflows.has(workflowId)) - -export const useIsFolderSelected = (folderId: string) => - useFolderStore((state) => state.selectedFolders.has(folderId)) diff --git a/apps/sim/stores/modals/search/store.test.ts b/apps/sim/stores/modals/search/store.test.ts index 1d96b0a4662..032bc2875bd 100644 --- a/apps/sim/stores/modals/search/store.test.ts +++ b/apps/sim/stores/modals/search/store.test.ts @@ -136,7 +136,10 @@ describe('search modal store', () => { mockGetAllBlocks.mockReturnValue([visibleBlock, hiddenBlock]) - useSearchModalStore.getState().initializeData((blocks) => blocks) + useSearchModalStore.getState().initializeData( + (blocks) => blocks, + () => true + ) const { tools } = useSearchModalStore.getState().data expect(tools).toHaveLength(1) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 337583e9672..8c60ec9d152 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -1,10 +1,11 @@ import { Repeat, Split } from '@sim/emcn/icons' import { create } from 'zustand' import { devtools } from 'zustand/middleware' +import { isOperationAllowed } from '@/lib/permission-groups/operation-access' import { toSearchToken } from '@/lib/search/tokens' import { getToolOperationsIndex } from '@/lib/search/tool-operations' import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' -import { getAllBlocks } from '@/blocks' +import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { SearchBlockItem, @@ -80,7 +81,7 @@ export const useSearchModalStore = create()( set({ isOpen: false }) }, - initializeData: (filterBlocks) => { + initializeData: (filterBlocks, isToolAllowed) => { const allBlocks = getAllBlocks() const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks @@ -158,6 +159,10 @@ export const useSearchModalStore = create()( const allowedBlockTypes = new Set(tools.map((t) => t.type)) const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex() .filter((op) => allowedBlockTypes.has(op.blockType)) + /* Selecting a result drops a block already set to that operation, so + the group's tool denylist has to apply here too — the block-level + allowlist above only decides whether the integration is offered. */ + .filter((op) => isOperationAllowed(getBlock(op.blockType), op.operationId, isToolAllowed)) .map((op) => { const aliasesStr = op.aliases?.length ? ` ${op.aliases.map(toSearchToken).join(' ')}` diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index e7f8c7cee39..16a8d3b4821 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -1,4 +1,5 @@ import type { ComponentType } from 'react' +import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' import type { BlockConfig } from '@/blocks/types' /** @@ -78,7 +79,11 @@ export interface SearchModalState { close: () => void /** - * Initialize search data. Called once on app load. + * Initialize search data. Re-runs whenever the caller's permission config + * resolves or changes, since both predicates are derived from it. */ - initializeData: (filterBlocks: (blocks: T[]) => T[]) => void + initializeData: ( + filterBlocks: (blocks: T[]) => T[], + isToolAllowed: IsToolAllowed + ) => void } diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 0d1850b0695..622feb67662 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -7,9 +7,10 @@ import { createStarterBlock, } from '@sim/testing' import type { Edge } from 'reactflow' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getBlock } from '@/blocks/registry' import { normalizeName } from '@/executor/constants' -import { filterNewEdges, getUniqueBlockName, regenerateBlockIds } from './utils' +import { filterNewEdges, getUniqueBlockName, prepareBlockState, regenerateBlockIds } from './utils' describe('normalizeName', () => { it.concurrent('should convert to lowercase', () => { @@ -1063,3 +1064,102 @@ describe('regenerateBlockIds — cloned webhook path', () => { expect(result.subBlockValues[newId].token).toBe('user-secret') }) }) + +describe('prepareBlockState — permission-group seed veto', () => { + const blockWithDefaults = { + name: 'Mock Block', + description: '', + icon: () => null, + outputs: {}, + tools: { access: ['slack_message'] }, + subBlocks: [ + { id: 'operation', type: 'dropdown', defaultValue: 'send' }, + { id: 'model', type: 'combobox', defaultValue: 'claude-sonnet-5' }, + { id: 'channel', type: 'short-input', defaultValue: '#general' }, + { id: 'blank', type: 'short-input', defaultValue: '' }, + { id: 'headers', type: 'table', defaultValue: [] }, + ], + } + + const seededValues = (isSeededValueAllowed?: (subBlockId: string, value: string) => boolean) => { + vi.mocked(getBlock).mockReturnValueOnce(blockWithDefaults as never) + const block = prepareBlockState({ + id: 'b1', + type: 'slack', + name: 'Slack', + position: { x: 0, y: 0 }, + isSeededValueAllowed, + }) + return Object.fromEntries( + Object.entries(block.subBlocks).map(([id, subBlock]) => [id, subBlock.value]) + ) + } + + afterEach(() => { + vi.mocked(getBlock).mockReset() + }) + + it('seeds every declared default when no gate is supplied', () => { + expect(seededValues()).toEqual({ + operation: 'send', + model: 'claude-sonnet-5', + channel: '#general', + blank: '', + headers: [], + }) + }) + + it('seeds every declared default when the gate allows them', () => { + expect(seededValues(() => true)).toEqual({ + operation: 'send', + model: 'claude-sonnet-5', + channel: '#general', + blank: '', + headers: [], + }) + }) + + it('never consults the gate for an empty or non-string default', () => { + /* Both are "nothing was declared" rather than a value to authorize, and a + gate that saw them would veto every unfilled field. */ + const seen: string[] = [] + seededValues((subBlockId) => { + seen.push(subBlockId) + return true + }) + expect(seen).not.toContain('blank') + expect(seen).not.toContain('headers') + }) + + it('keeps an empty or non-string default even when the gate rejects everything', () => { + const values = seededValues(() => false) + expect(values.blank).toBe('') + expect(values.headers).toEqual([]) + }) + + it('leaves a denied operation unseeded rather than substituting one', () => { + const values = seededValues((subBlockId) => subBlockId !== 'operation') + expect(values.operation).toBeNull() + expect(values.model).toBe('claude-sonnet-5') + expect(values.channel).toBe('#general') + }) + + it('leaves a denied model unseeded', () => { + const values = seededValues((subBlockId) => subBlockId !== 'model') + expect(values.model).toBeNull() + expect(values.operation).toBe('send') + }) + + it('passes the seeded value to the gate, not just the field id', () => { + const seen: Array<[string, string]> = [] + seededValues((subBlockId, value) => { + seen.push([subBlockId, value]) + return true + }) + expect(seen).toEqual([ + ['operation', 'send'], + ['model', 'claude-sonnet-5'], + ['channel', '#general'], + ]) + }) +}) diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 9075df4d958..34a5cc1a54a 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -2,6 +2,7 @@ import { generateId } from '@sim/utils/id' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' +import type { SeedValueGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' @@ -101,6 +102,23 @@ export interface PrepareBlockStateOptions { parentId?: string extent?: 'parent' triggerMode?: boolean + /** + * Vetoes a declared default that the creator's permission group denies — + * today the `operation` and `model` fields, both of which blocks pre-fill. + * + * A vetoed field is seeded with nothing rather than a substitute. The editor's + * own permission-aware pickers already resolve the right replacement (first + * allowed operation; preferred-then-first allowed model) and they only fill a + * field that is empty, so leaving it empty hands the choice to the one place + * that knows how to make it. Substituting here instead would also drift from + * `getDefaultBlockName`, which names the block after its *declared* default. + * + * Omit it entirely only where permission gating does not apply, in which case + * declared defaults are seeded unchanged. A caller that cannot yet answer — + * config still loading — vetoes rather than omitting, since a value written + * here is never revisited. + */ + isSeededValueAllowed?: SeedValueGate } /** @@ -108,7 +126,17 @@ export interface PrepareBlockStateOptions { * Generates subBlocks and outputs from the block registry. */ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState { - const { id, type, name, position, data, parentId, extent, triggerMode = false } = options + const { + id, + type, + name, + position, + data, + parentId, + extent, + triggerMode = false, + isSeededValueAllowed, + } = options const blockConfig = getBlock(type) @@ -153,6 +181,15 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState initialValue = [] } + if ( + isSeededValueAllowed && + typeof initialValue === 'string' && + initialValue !== '' && + !isSeededValueAllowed(subBlock.id, initialValue) + ) { + initialValue = null + } + subBlocks[subBlock.id] = { id: subBlock.id, type: subBlock.type, diff --git a/apps/sim/tools/airweave/types.ts b/apps/sim/tools/airweave/types.ts index 2f3b79ac651..d2e1ab32e88 100644 --- a/apps/sim/tools/airweave/types.ts +++ b/apps/sim/tools/airweave/types.ts @@ -27,15 +27,6 @@ export const AIRWEAVE_SEARCH_RESULT_OUTPUT_PROPERTIES = { url: { type: 'string', description: 'URL to the original content', optional: true }, } as const satisfies Record -/** - * Complete search result output definition. - */ -export const AIRWEAVE_SEARCH_RESULT_OUTPUT: OutputProperty = { - type: 'object', - description: 'Search result item with content and metadata', - properties: AIRWEAVE_SEARCH_RESULT_OUTPUT_PROPERTIES, -} - /** * Parameters for Airweave search requests. */ diff --git a/apps/sim/tools/ashby/anonymize_candidate.ts b/apps/sim/tools/ashby/anonymize_candidate.ts new file mode 100644 index 00000000000..db3a73945de --- /dev/null +++ b/apps/sim/tools/ashby/anonymize_candidate.ts @@ -0,0 +1,65 @@ +import type { AshbyCandidate } from '@/tools/ashby/types' +import { + ashbyAuthHeaders, + ashbyErrorMessage, + CANDIDATE_OUTPUTS, + mapCandidate, +} from '@/tools/ashby/utils' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface AshbyAnonymizeCandidateParams { + apiKey: string + candidateId: string +} + +interface AshbyAnonymizeCandidateResponse extends ToolResponse { + output: AshbyCandidate +} + +export const anonymizeCandidateTool: ToolConfig< + AshbyAnonymizeCandidateParams, + AshbyAnonymizeCandidateResponse +> = { + 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', + }, + }, + + request: { + url: 'https://api.ashbyhq.com/candidate.anonymize', + method: 'POST', + headers: (params) => ashbyAuthHeaders(params.apiKey), + body: (params) => ({ candidateId: params.candidateId.trim() }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + throw new Error(ashbyErrorMessage(data, 'Failed to anonymize candidate')) + } + + return { + success: true, + output: mapCandidate(data.results), + } + }, + + outputs: CANDIDATE_OUTPUTS, +} diff --git a/apps/sim/tools/ashby/ashby.live.test.ts b/apps/sim/tools/ashby/ashby.live.test.ts new file mode 100644 index 00000000000..154058aebf9 --- /dev/null +++ b/apps/sim/tools/ashby/ashby.live.test.ts @@ -0,0 +1,517 @@ +/** + * Live verification of the Ashby connector against a real Ashby organization. + * + * Skipped unless `ASHBY_LIVE=1` and `ASHBY_API_KEY` are set, so it is inert in + * CI and for anyone without credentials. Writes are gated separately behind + * `ASHBY_LIVE_WRITES=1` because every Ashby call is a production write - there + * is no sandbox, no test mode, and no dry-run flag. + * + * Read-only: ASHBY_LIVE=1 ASHBY_API_KEY=... bunx vitest run tools/ashby/ashby.live.test.ts + * With writes: add ASHBY_LIVE_WRITES=1 ASHBY_FIXTURE_JOB_ID= + * + * The Ashby tools have a static URL, pure `headers(params)`/`body(params)`, and + * a `transformResponse(Response)` with no postProcess or execution context, so + * driving them directly here reproduces exactly what `executeTool` does. + * + * @vitest-environment node + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { anonymizeCandidateTool } from '@/tools/ashby/anonymize_candidate' +import { changeApplicationSourceTool } from '@/tools/ashby/change_application_source' +import { createApplicationTool } from '@/tools/ashby/create_application' +import { createCandidateTool } from '@/tools/ashby/create_candidate' +import { deleteApplicationTool } from '@/tools/ashby/delete_application' +import { getApplicationTool } from '@/tools/ashby/get_application' +import { getCandidateTool } from '@/tools/ashby/get_candidate' +import { listCustomFieldsTool } from '@/tools/ashby/list_custom_fields' +import { listJobPostingsTool } from '@/tools/ashby/list_job_postings' +import { listJobsTool } from '@/tools/ashby/list_jobs' +import { listSourcesTool } from '@/tools/ashby/list_sources' +import { setCustomFieldValueTool } from '@/tools/ashby/set_custom_field_value' +import { setCustomFieldValuesTool } from '@/tools/ashby/set_custom_field_values' +import type { ToolConfig } from '@/tools/types' + +const LIVE = process.env.ASHBY_LIVE === '1' && Boolean(process.env.ASHBY_API_KEY) +const WRITES = LIVE && process.env.ASHBY_LIVE_WRITES === '1' +const apiKey = process.env.ASHBY_API_KEY ?? '' + +/** Live API calls with pagination comfortably exceed the 10s default. */ +const TIMEOUT = 120_000 + +type AnyTool = ToolConfig + +/** Drive a tool exactly as executeTool does for this family: build, fetch, transform. */ +async function call(tool: AnyTool, params: Record): Promise> { + const request = tool.request + if (!request) throw new Error(`${tool.id} has no request config`) + const url = typeof request.url === 'function' ? request.url(params as never) : request.url + const headers = request.headers ? request.headers(params as never) : {} + const buildBody = request.body as ((p: unknown) => unknown) | undefined + const response = await fetch(url as string, { + method: (request.method as string) ?? 'POST', + headers: headers as Record, + body: buildBody ? JSON.stringify(buildBody(params)) : undefined, + }) + const transform = tool.transformResponse as (r: Response, p?: unknown) => Promise + const result = await transform(response, params) + return result.output +} + +/** Report a finding to stdout so the run doubles as a readable verification log. */ +function note(label: string, value: unknown) { + const rendered = typeof value === 'string' ? value : JSON.stringify(value) + console.info(` [live] ${label}: ${rendered}`) +} + +/** Facts discovered in the read-only phase and reused by the write phase. */ +const discovered: { + syncToken?: string | null + fullScanJobCount?: number + candidateFieldId?: string + candidateFieldType?: string + candidateFieldOptions?: string[] + hasCandidatesDelete?: boolean +} = {} + +/** + * `vitest.setup.ts` calls `setupGlobalFetchMock()`, which does + * `vi.stubGlobal('fetch', ...)` for every test file in the app. Without + * restoring the real implementation, every request below would quietly hit the + * mock and the whole suite would be a false pass - so restore it, then assert + * the restore actually worked. + */ +function useRealFetch() { + vi.unstubAllGlobals() + expect(vi.isMockFunction(globalThis.fetch)).toBe(false) +} + +describe.skipIf(!LIVE)('ashby live (read-only)', () => { + beforeAll(useRealFetch) + + it( + 'probes candidatesDelete without creating anything', + async () => { + // application.delete against a random UUID has no side effects and + // distinguishes a missing scope from a missing record. This runs before + // any fixture exists so we never create an application we cannot delete. + let message = '' + try { + await call(deleteApplicationTool, { + apiKey, + applicationId: '00000000-0000-4000-8000-000000000000', + }) + message = '(unexpectedly succeeded)' + } catch (error) { + message = (error as Error).message + } + note('delete scope probe error', message) + // Whatever Ashby returns, our error extraction must render it readably. + expect(message).not.toContain('[object Object]') + discovered.hasCandidatesDelete = !/permission/i.test(message) + note('candidatesDelete present', discovered.hasCandidatesDelete) + }, + TIMEOUT + ) + + it( + 'probes candidatesWrite without creating anything', + async () => { + // Same zero-side-effect trick: a random UUID cannot exist, so a + // permission error and a not-found error are cleanly distinguishable. + const probe = async (fn: () => Promise) => { + try { + await fn() + return '(unexpectedly succeeded)' + } catch (error) { + return (error as Error).message + } + } + const random = '00000000-0000-4000-8000-000000000000' + + const anonymizeError = await probe(() => + call(anonymizeCandidateTool, { apiKey, candidateId: random }) + ) + const setValueError = await probe(() => + call(setCustomFieldValueTool, { + apiKey, + objectId: random, + objectType: 'Candidate', + fieldId: random, + fieldValue: null, + }) + ) + note('anonymize probe error', anonymizeError) + note('setValue probe error', setValueError) + note('candidatesWrite present', !/permission/i.test(anonymizeError)) + + expect(anonymizeError).not.toContain('[object Object]') + expect(setValueError).not.toContain('[object Object]') + }, + TIMEOUT + ) + + it( + 'returns a syncToken only on the last page', + async () => { + // The core P0 claim, asserted in both our docs and the param description + // but never checked against the API. + let cursor: string | undefined + let page = 0 + let total = 0 + let lastPageToken: string | null = null + const midPageTokens: Array = [] + + do { + const output = await call(listJobsTool, { apiKey, perPage: 5, cursor }) + page += 1 + total += output.jobs.length + if (output.moreDataAvailable) { + midPageTokens.push(output.nextSyncCursor) + cursor = output.nextCursor + } else { + lastPageToken = output.nextSyncCursor + cursor = undefined + } + expect(page).toBeLessThan(60) + } while (cursor) + + note('pages walked', page) + note('jobs seen', total) + note('syncToken on non-final pages', midPageTokens) + note('syncToken on final page', lastPageToken ? 'present' : 'absent') + + for (const token of midPageTokens) expect(token).toBeNull() + discovered.syncToken = lastPageToken + discovered.fullScanJobCount = total + }, + TIMEOUT + ) + + it( + 'replays the syncToken for an incremental scan', + async () => { + if (!discovered.syncToken) { + note('incremental replay', 'SKIPPED - no syncToken was returned') + return + } + const output = await call(listJobsTool, { apiKey, syncToken: discovered.syncToken }) + note('full scan job count', discovered.fullScanJobCount) + note('incremental job count', output.jobs.length) + note('incremental syncToken', output.nextSyncCursor ? 'present' : 'absent') + expect(output.jobs.length).toBeLessThanOrEqual(discovered.fullScanJobCount ?? 0) + }, + TIMEOUT + ) + + it( + 'exposes confidential as a boolean on every job', + async () => { + const output = await call(listJobsTool, { apiKey, perPage: 100 }) + expect(output.jobs.length).toBeGreaterThan(0) + for (const job of output.jobs) expect(typeof job.confidential).toBe('boolean') + const confidential = output.jobs.filter((j: { confidential: boolean }) => j.confidential) + note('jobs sampled', output.jobs.length) + note('confidential jobs visible to this key', confidential.length) + }, + TIMEOUT + ) + + it( + 'includes draft postings only when asked', + async () => { + const withoutDrafts = await call(listJobPostingsTool, { apiKey }) + const withDrafts = await call(listJobPostingsTool, { + apiKey, + includeUnpublishedJobPostings: true, + }) + const baseIds = new Set(withoutDrafts.jobPostings.map((p: { id: string }) => p.id)) + const extra = withDrafts.jobPostings.filter((p: { id: string }) => !baseIds.has(p.id)) + const statuses = [...new Set(withDrafts.jobPostings.map((p: { status: string }) => p.status))] + + note('postings without drafts', withoutDrafts.jobPostings.length) + note('postings with drafts', withDrafts.jobPostings.length) + note('postings added by the flag', extra.length) + note('statuses seen', statuses) + + // Superset, never a different set. + for (const id of baseIds) { + expect(withDrafts.jobPostings.some((p: { id: string }) => p.id === id)).toBe(true) + } + expect(withDrafts.jobPostings.length).toBeGreaterThanOrEqual(withoutDrafts.jobPostings.length) + }, + TIMEOUT + ) + + it( + 'finds a Candidate-scoped custom field to write to', + async () => { + const output = await call(listCustomFieldsTool, { apiKey, perPage: 100 }) + const candidateFields = output.customFields.filter( + (f: { objectType: string; isArchived: boolean; isRequired: boolean }) => + f.objectType === 'Candidate' && !f.isArchived && !f.isRequired + ) + note('candidate custom fields available', candidateFields.length) + note( + 'candidate fields', + candidateFields.map( + (f: { title: string; fieldType: string }) => `${f.title}:${f.fieldType}` + ) + ) + + // Prefer a free-text field; a ValueSelect only accepts its own options. + const preferred = + candidateFields.find((f: { fieldType: string }) => + ['String', 'LongText'].includes(f.fieldType) + ) ?? candidateFields[0] + + if (preferred) { + discovered.candidateFieldId = preferred.id + discovered.candidateFieldType = preferred.fieldType + discovered.candidateFieldOptions = (preferred.selectableValues ?? []).map( + (v: { value: string }) => v.value + ) + note('chosen field', `${preferred.title} (${preferred.fieldType}) ${preferred.id}`) + } else { + note('chosen field', 'NONE - no writable Candidate custom field exists') + } + expect(output.customFields.length).toBeGreaterThan(0) + }, + TIMEOUT + ) +}) + +describe.skipIf(!WRITES)('ashby live (writes on fixtures)', () => { + beforeAll(useRealFetch) + + const stamp = new Date().toISOString() + const fixtureName = `ZZ SIM E2E TEST ${stamp}` + const state: { candidateId?: string; applicationId?: string } = {} + + it( + 'creates the fixture candidate', + async () => { + const output = await call(createCandidateTool, { + apiKey, + name: fixtureName, + email: `zz-sim-e2e-${Date.now()}@example.invalid`, + }) + state.candidateId = output.id + note('fixture candidate id', output.id) + note('fixture candidate name', output.name) + expect(output.id).toBeTruthy() + }, + TIMEOUT + ) + + it( + 'writes a custom field value and reads it back', + async () => { + const fieldId = discovered.candidateFieldId + if (!fieldId || !state.candidateId) { + note('custom field write', 'SKIPPED - no field or candidate') + return + } + const value = + discovered.candidateFieldType === 'Number' + ? 42 + : discovered.candidateFieldType === 'Boolean' + ? true + : discovered.candidateFieldType === 'MultiValueSelect' + ? discovered.candidateFieldOptions?.slice(0, 1) + : discovered.candidateFieldType === 'ValueSelect' + ? discovered.candidateFieldOptions?.[0] + : 'sim-e2e' + + const written = await call(setCustomFieldValueTool, { + apiKey, + objectId: state.candidateId, + objectType: 'Candidate', + fieldId, + fieldValue: value, + }) + note('written value', written.customField.value) + + const candidate = await call(getCandidateTool, { apiKey, candidateId: state.candidateId }) + const stored = candidate.customFields.find((f: { id: string }) => f.id === fieldId) + note('value read back from candidate', stored?.value) + expect(stored).toBeDefined() + }, + TIMEOUT + ) + + it( + 'clears the custom field with null', + async () => { + const fieldId = discovered.candidateFieldId + if (!fieldId || !state.candidateId) { + note('custom field clear', 'SKIPPED') + return + } + const cleared = await call(setCustomFieldValueTool, { + apiKey, + objectId: state.candidateId, + objectType: 'Candidate', + fieldId, + fieldValue: null, + }) + note('value after null write', cleared.customField.value) + + const candidate = await call(getCandidateTool, { apiKey, candidateId: state.candidateId }) + const stored = candidate.customFields.find((f: { id: string }) => f.id === fieldId) + note('value read back after clear', stored ? stored.value : '(field absent)') + expect(stored?.value ?? null).toBeNull() + }, + TIMEOUT + ) + + it( + 'writes several fields at once and gets an array back', + async () => { + const fieldId = discovered.candidateFieldId + if (!fieldId || !state.candidateId) { + note('setValues', 'SKIPPED') + return + } + const output = await call(setCustomFieldValuesTool, { + apiKey, + objectId: state.candidateId, + objectType: 'Candidate', + values: [{ fieldId, fieldValue: 'sim-e2e-plural' }], + }) + note('setValues returned', output.customFields) + expect(Array.isArray(output.customFields)).toBe(true) + + // Leave the fixture clean. + await call(setCustomFieldValueTool, { + apiKey, + objectId: state.candidateId, + objectType: 'Candidate', + fieldId, + fieldValue: null, + }) + }, + TIMEOUT + ) + + it( + 'creates the fixture application on the approved job', + async () => { + const jobId = process.env.ASHBY_FIXTURE_JOB_ID + if (!jobId || !state.candidateId || !discovered.hasCandidatesDelete) { + note('fixture application', 'SKIPPED - no approved job or no candidatesDelete') + return + } + const output = await call(createApplicationTool, { + apiKey, + candidateId: state.candidateId, + jobId, + }) + state.applicationId = output.id + note('fixture application id', output.id) + expect(output.id).toBeTruthy() + }, + TIMEOUT + ) + + it( + 'changes the application source, then unsets it with an explicit null', + async () => { + if (!state.applicationId) { + note('changeSource', 'SKIPPED - no fixture application') + return + } + const sources = await call(listSourcesTool, { apiKey }) + const source = sources.sources?.find((s: { isArchived: boolean }) => !s.isArchived) + note('source used', source ? `${source.title} ${source.id}` : 'none available') + + if (source) { + const set = await call(changeApplicationSourceTool, { + apiKey, + applicationId: state.applicationId, + sourceId: source.id, + }) + note('source after set', set.source?.title ?? null) + expect(set.source?.id).toBe(source.id) + } + + // The spec detail most likely to be wrong: sourceId must be PRESENT and + // null to unset. A dropped key is a 400, not a clear. + const unset = await call(changeApplicationSourceTool, { + apiKey, + applicationId: state.applicationId, + unsetSource: true, + }) + note('source after explicit null', unset.source) + expect(unset.source ?? null).toBeNull() + }, + TIMEOUT + ) + + it( + 'deletes the fixture application', + async () => { + if (!state.applicationId) { + note('delete', 'SKIPPED - no fixture application') + return + } + const output = await call(deleteApplicationTool, { + apiKey, + applicationId: state.applicationId, + }) + note('deleted application id', output.applicationId) + expect(output.applicationId).toBe(state.applicationId) + + let stillThere = true + try { + await call(getApplicationTool, { apiKey, applicationId: state.applicationId }) + } catch (error) { + stillThere = false + note('get after delete', (error as Error).message) + } + expect(stillThere).toBe(false) + state.applicationId = undefined + }, + TIMEOUT + ) + + it( + 'anonymizes the fixture candidate last', + async () => { + if (!state.candidateId) { + note('anonymize', 'SKIPPED') + return + } + const output = await call(anonymizeCandidateTool, { + apiKey, + candidateId: state.candidateId, + }) + note('name after anonymize', output.name) + note('email after anonymize', output.primaryEmailAddress?.value ?? null) + + // Our documented limitation: the record survives, only the PII is gone. + const after = await call(getCandidateTool, { apiKey, candidateId: state.candidateId }) + note('record still exists after anonymize', Boolean(after.id)) + expect(after.id).toBe(state.candidateId) + expect(after.name).not.toBe(fixtureName) + }, + TIMEOUT + ) + + it( + 'leaves the fixture in the expected final state', + async () => { + // Teardown is asserted, not assumed: no application survives, and the + // custom field value we wrote is cleared rather than left behind. + if (!state.candidateId) return + const after = await call(getCandidateTool, { apiKey, candidateId: state.candidateId }) + const field = after.customFields.find( + (f: { id: string }) => f.id === discovered.candidateFieldId + ) + note('final custom field value', field ? field.value : '(field absent)') + note('final application ids on fixture', after.applicationIds) + expect(field?.value ?? null).toBeNull() + expect(after.applicationIds).toEqual([]) + }, + TIMEOUT + ) +}) diff --git a/apps/sim/tools/ashby/ashby.test.ts b/apps/sim/tools/ashby/ashby.test.ts new file mode 100644 index 00000000000..9c05a2bbeeb --- /dev/null +++ b/apps/sim/tools/ashby/ashby.test.ts @@ -0,0 +1,303 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isSensitiveKey } from '@/lib/core/security/redaction' +import { anonymizeCandidateTool } from '@/tools/ashby/anonymize_candidate' +import { changeApplicationSourceTool } from '@/tools/ashby/change_application_source' +import { deleteApplicationTool } from '@/tools/ashby/delete_application' +import { listJobPostingsTool } from '@/tools/ashby/list_job_postings' +import { listJobsTool } from '@/tools/ashby/list_jobs' +import { setCustomFieldValueTool } from '@/tools/ashby/set_custom_field_value' +import { setCustomFieldValuesTool } from '@/tools/ashby/set_custom_field_values' +import type { ToolConfig } from '@/tools/types' + +const respond = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }) + +function requestBody(tool: ToolConfig, params: unknown): Record { + const build = tool.request?.body as (p: unknown) => Record + return build(params) +} + +async function transform(tool: ToolConfig, body: unknown) { + const run = tool.transformResponse as (r: Response) => Promise<{ output: Record }> + return run(respond(body)) +} + +describe('ashby request bodies', () => { + it('list_jobs forwards a sync token and omits the key when absent', () => { + expect(requestBody(listJobsTool, { apiKey: 'k', syncToken: 'tok' }).syncToken).toBe('tok') + expect(requestBody(listJobsTool, { apiKey: 'k' })).not.toHaveProperty('syncToken') + }) + + it('list_job_postings uses the jobPosting.list spelling, not the job.list one', () => { + // job.list takes `includeUnpublishedJobPostingsIds`, a different parameter + // with a different meaning. Sending that name here is silently ignored. + const body = requestBody(listJobPostingsTool, { + apiKey: 'k', + includeUnpublishedJobPostings: true, + }) + expect(body.includeUnpublishedJobPostings).toBe(true) + expect(body).not.toHaveProperty('includeUnpublishedJobPostingsIds') + }) + + it('change_application_source sends sourceId when one is provided', () => { + // Ashby requires the key to be present; omitting it is a 400 rather than + // the intended write. + expect( + requestBody(changeApplicationSourceTool, { applicationId: ' a ', sourceId: ' s ' }) + ).toEqual({ + applicationId: 'a', + sourceId: 's', + }) + }) + + it('does not mark fieldValue required, so a null clear survives validation', () => { + // validateRequiredParametersAfterMerge rejects a required `user-or-llm` + // param whose value is null, which would make clearing a custom field + // impossible - the exact operation the null value exists for. + expect(setCustomFieldValueTool.params.fieldValue.required).toBe(false) + expect(setCustomFieldValueTool.params.objectId.required).toBe(true) + expect(setCustomFieldValueTool.params.fieldId.required).toBe(true) + }) + + it('set_custom_field_value preserves an explicit null so a field can be cleared', () => { + expect( + requestBody(setCustomFieldValueTool, { + objectId: 'o', + objectType: 'Job', + fieldId: 'f', + fieldValue: null, + }) + ).toEqual({ objectId: 'o', objectType: 'Job', fieldId: 'f', fieldValue: null }) + }) + + it('refuses to clear the field when the value was merely omitted', () => { + // null clears the field, so an absent value must not become one: a dropped + // variable, an unresolved reference, or a model call that forgot the + // argument would otherwise silently destroy the stored value. + expect(() => + requestBody(setCustomFieldValueTool, { objectId: 'o', objectType: 'Job', fieldId: 'f' }) + ).toThrow(/required. Pass null to clear/) + expect(() => + requestBody(setCustomFieldValueTool, { + objectId: 'o', + objectType: 'Job', + fieldId: 'f', + fieldValue: ' ', + }) + ).toThrow(/required. Pass null to clear/) + }) + + it('set_custom_field_values sends the values array', () => { + const values = [{ fieldId: 'f', fieldValue: 'High' }] + expect( + requestBody(setCustomFieldValuesTool, { objectId: 'o', objectType: 'Job', values }).values + ).toEqual(values) + }) + + it('rejects an empty values array before it reaches Ashby', () => { + expect(() => + requestBody(setCustomFieldValuesTool, { objectId: 'o', objectType: 'Job', values: [] }) + ).toThrow(/non-empty array/) + }) + + it('refuses to unset an application source unless asked explicitly', () => { + // The endpoint has no "leave unchanged" mode, so a dropped variable or a + // model call that omits sourceId would otherwise wipe attribution and still + // report success. + expect(() => requestBody(changeApplicationSourceTool, { applicationId: 'a' })).toThrow( + /unsetSource/ + ) + expect( + requestBody(changeApplicationSourceTool, { applicationId: 'a', unsetSource: true }).sourceId + ).toBeNull() + }) + + it('rejects a source id and an unset request together', () => { + // Preferring either one silently discards the other, which is how an + // intentional clear turns into a set nobody asked for. + expect(() => + requestBody(changeApplicationSourceTool, { + applicationId: 'a', + sourceId: 's', + unsetSource: true, + }) + ).toThrow(/mutually exclusive/) + }) + + it('rejects an object type Ashby would not accept', () => { + // objectType is user-or-llm and Ashby's enum is case-sensitive. + expect(() => + requestBody(setCustomFieldValueTool, { + objectId: 'o', + objectType: 'Sandwich', + fieldId: 'f', + fieldValue: 'x', + }) + ).toThrow(/Expected one of/) + expect( + requestBody(setCustomFieldValueTool, { + objectId: ' o ', + objectType: 'candidate', + fieldId: ' f ', + fieldValue: 'x', + }) + ).toEqual({ objectId: 'o', objectType: 'Candidate', fieldId: 'f', fieldValue: 'x' }) + }) + + it('trims ids on the single-id operations', () => { + expect(requestBody(deleteApplicationTool, { applicationId: ' app-1 ' }).applicationId).toBe( + 'app-1' + ) + expect(requestBody(anonymizeCandidateTool, { candidateId: ' cand-1 ' }).candidateId).toBe( + 'cand-1' + ) + }) +}) + +describe('ashby response transforms', () => { + it('exposes the sync cursor under a name redaction does not treat as a secret', async () => { + // `syncToken` matches the /^.*token$/i deny-list, so surfacing it under that + // name renders it [REDACTED] in block output - and an incremental sync is + // useless if the operator cannot read the cursor for the next run. It is an + // opaque resumption marker, so it belongs with nextCursor, not with API keys. + expect(isSensitiveKey('syncToken')).toBe(true) + expect(isSensitiveKey('nextSyncCursor')).toBe(false) + expect(isSensitiveKey('nextCursor')).toBe(false) + }) + + it('list_jobs surfaces the sync cursor and the confidential flag', async () => { + const result = await transform(listJobsTool, { + success: true, + results: [{ id: 'j1', title: 'Engineer', confidential: true }], + moreDataAvailable: false, + syncToken: 'next-token', + }) + expect(result.output.nextSyncCursor).toBe('next-token') + expect(result.output.jobs[0].confidential).toBe(true) + }) + + it('list_jobs reports a null sync token on a non-final page', async () => { + // Ashby only returns a sync token once the last page is drained, so callers + // must not persist what comes back mid-pagination. + const result = await transform(listJobsTool, { + success: true, + results: [], + moreDataAvailable: true, + nextCursor: 'cursor-1', + }) + expect(result.output.nextSyncCursor).toBeNull() + }) + + it('set_custom_field_value maps the single result object', async () => { + const result = await transform(setCustomFieldValueTool, { + success: true, + results: { id: 'cf', title: 'Priority', isPrivate: false, value: 'High', valueLabel: 'High' }, + }) + expect(result.output.customField).toEqual({ + id: 'cf', + title: 'Priority', + isPrivate: false, + value: 'High', + valueLabel: 'High', + }) + }) + + it('set_custom_field_values maps the result array', async () => { + // The plural endpoint returns an array where the singular returns an object. + const result = await transform(setCustomFieldValuesTool, { + success: true, + results: [ + { id: 'a', title: 'A', value: 1 }, + { id: 'b', title: 'B', value: null }, + ], + }) + expect(result.output.customFields).toHaveLength(2) + expect(result.output.customFields[1].value).toBeNull() + }) + + it('delete_application surfaces the deleted id', async () => { + const result = await transform(deleteApplicationTool, { + success: true, + results: { applicationId: 'app-1' }, + }) + expect(result.output.applicationId).toBe('app-1') + }) + + it('delete_application reports a missing permission readably', async () => { + // candidatesDelete is a separate module permission, so this is the most + // likely failure for this operation and must not surface as [object Object]. + await expect( + transform(deleteApplicationTool, { + success: false, + errors: [{ message: 'missing_endpoint_permission' }], + }) + ).rejects.toThrow('missing_endpoint_permission') + }) + + it('every new tool surfaces an Ashby failure readably', async () => { + // The object-shaped errors array is the form Ashby's own spec documents, and + // it is what these messages are built from - each tool must unwrap it rather + // than stringify the entry. + const failure = { success: false, errors: [{ message: 'invalid_input', parameter: 'x' }] } + for (const tool of [ + setCustomFieldValueTool, + setCustomFieldValuesTool, + anonymizeCandidateTool, + changeApplicationSourceTool, + ]) { + await expect(transform(tool, failure)).rejects.toThrow('invalid_input (x)') + } + }) + + it('anonymize_candidate maps the returned candidate', async () => { + const result = await transform(anonymizeCandidateTool, { + success: true, + results: { id: 'cand-1', name: 'Anonymous cand-1', primaryEmailAddress: null }, + }) + expect(result.output.id).toBe('cand-1') + expect(result.output.name).toBe('Anonymous cand-1') + }) + + it('change_application_source maps the returned application', async () => { + const result = await transform(changeApplicationSourceTool, { + success: true, + results: { id: 'app-1', status: 'Active', source: null }, + }) + expect(result.output.id).toBe('app-1') + expect(result.output.source).toBeNull() + }) + + it('keeps an array valueLabel intact for MultiValueSelect fields', async () => { + // This is why AshbyCustomField.valueLabel widened to string | string[]; + // a mapper that coerced it would silently drop the labels. + const result = await transform(setCustomFieldValueTool, { + success: true, + results: { id: 'cf', title: 'Modes', value: ['a', 'b'], valueLabel: ['A', 'B'] }, + }) + expect(result.output.customField.valueLabel).toEqual(['A', 'B']) + expect(result.output.customField.value).toEqual(['a', 'b']) + }) + + it('list_job_postings surfaces draft status on the new field', async () => { + const result = await transform(listJobPostingsTool, { + success: true, + results: [ + { id: 'p1', title: 'A', status: 'Published' }, + { id: 'p2', title: 'B', status: 'Draft' }, + ], + }) + expect(result.output.jobPostings.map((p: { status: string }) => p.status)).toEqual([ + 'Published', + 'Draft', + ]) + }) + + it('defaults missing result payloads instead of throwing', async () => { + const deleted = await transform(deleteApplicationTool, { success: true, results: {} }) + expect(deleted.output.applicationId).toBe('') + const plural = await transform(setCustomFieldValuesTool, { success: true }) + expect(plural.output.customFields).toEqual([]) + }) +}) diff --git a/apps/sim/tools/ashby/change_application_source.ts b/apps/sim/tools/ashby/change_application_source.ts new file mode 100644 index 00000000000..5d2866d29c7 --- /dev/null +++ b/apps/sim/tools/ashby/change_application_source.ts @@ -0,0 +1,110 @@ +import type { AshbyApplication } from '@/tools/ashby/types' +import { + APPLICATION_OUTPUTS, + ashbyAuthHeaders, + ashbyErrorMessage, + mapApplication, +} from '@/tools/ashby/utils' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface AshbyChangeApplicationSourceParams { + apiKey: string + applicationId: string + sourceId?: string + unsetSource?: boolean +} + +interface AshbyChangeApplicationSourceResponse extends ToolResponse { + output: AshbyApplication +} + +export const changeApplicationSourceTool: ToolConfig< + AshbyChangeApplicationSourceParams, + AshbyChangeApplicationSourceResponse +> = { + 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.', + }, + }, + + request: { + url: 'https://api.ashbyhq.com/application.changeSource', + method: 'POST', + headers: (params) => ashbyAuthHeaders(params.apiKey), + /** + * Ashby requires `sourceId` to be present even when unsetting, so it always + * serializes - but the value has to say what the caller actually meant. The + * endpoint has no "leave unchanged" mode, so setting and clearing are the + * only two intents, and exactly one of them must be expressed: + * + * - neither given: an omitted sourceId would otherwise wipe attribution on a + * real application and still report success + * - both given: preferring either one silently discards the other, which is + * how "clear this" turns into a set nobody asked for + * + * Both are caller errors rather than something to resolve by precedence. + */ + body: (params) => { + const sourceId = params.sourceId?.trim() + if (sourceId && params.unsetSource) { + throw new Error( + 'Ashby source ID and unsetSource are mutually exclusive. Provide a source ID to attribute the application, or set unsetSource on its own to clear it.' + ) + } + if (!sourceId && !params.unsetSource) { + throw new Error( + 'Ashby source ID is required. Set unsetSource to true to deliberately clear the application source.' + ) + } + return { + applicationId: params.applicationId.trim(), + sourceId: sourceId ? sourceId : null, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + throw new Error(ashbyErrorMessage(data, 'Failed to change application source')) + } + + return { + success: true, + output: mapApplication(data.results), + } + }, + + outputs: APPLICATION_OUTPUTS, +} diff --git a/apps/sim/tools/ashby/delete_application.ts b/apps/sim/tools/ashby/delete_application.ts new file mode 100644 index 00000000000..e116d8fde86 --- /dev/null +++ b/apps/sim/tools/ashby/delete_application.ts @@ -0,0 +1,70 @@ +import { ashbyAuthHeaders, ashbyErrorMessage } from '@/tools/ashby/utils' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface AshbyDeleteApplicationParams { + apiKey: string + applicationId: string +} + +interface AshbyDeleteApplicationResponse extends ToolResponse { + output: { + applicationId: string + } +} + +export const deleteApplicationTool: ToolConfig< + AshbyDeleteApplicationParams, + AshbyDeleteApplicationResponse +> = { + id: 'ashby_delete_application', + name: 'Ashby Delete Application', + description: + 'Permanently deletes an application in Ashby. Requires the candidatesDelete permission, which is a separate module permission from candidatesWrite - a read and write key returns 403 here. There is no equivalent endpoint for deleting a candidate; candidate deletion is UI-only.', + 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 to delete', + }, + }, + + request: { + url: 'https://api.ashbyhq.com/application.delete', + method: 'POST', + headers: (params) => ashbyAuthHeaders(params.apiKey), + body: (params) => ({ applicationId: params.applicationId.trim() }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + throw new Error(ashbyErrorMessage(data, 'Failed to delete application')) + } + + const result = (data.results ?? {}) as Record + + return { + success: true, + output: { + applicationId: (result.applicationId as string) ?? '', + }, + } + }, + + outputs: { + applicationId: { + type: 'string', + description: 'UUID of the deleted application', + }, + }, +} diff --git a/apps/sim/tools/ashby/index.ts b/apps/sim/tools/ashby/index.ts index bc3b872e24c..6682637c3ec 100644 --- a/apps/sim/tools/ashby/index.ts +++ b/apps/sim/tools/ashby/index.ts @@ -1,8 +1,11 @@ import { addCandidateTagTool } from '@/tools/ashby/add_candidate_tag' +import { anonymizeCandidateTool } from '@/tools/ashby/anonymize_candidate' +import { changeApplicationSourceTool } from '@/tools/ashby/change_application_source' import { changeApplicationStageTool } from '@/tools/ashby/change_application_stage' import { createApplicationTool } from '@/tools/ashby/create_application' import { createCandidateTool } from '@/tools/ashby/create_candidate' import { createNoteTool } from '@/tools/ashby/create_note' +import { deleteApplicationTool } from '@/tools/ashby/delete_application' import { getApplicationTool } from '@/tools/ashby/get_application' import { getCandidateTool } from '@/tools/ashby/get_candidate' import { getJobTool } from '@/tools/ashby/get_job' @@ -25,17 +28,22 @@ import { listSourcesTool } from '@/tools/ashby/list_sources' import { listUsersTool } from '@/tools/ashby/list_users' import { removeCandidateTagTool } from '@/tools/ashby/remove_candidate_tag' import { searchCandidatesTool } from '@/tools/ashby/search_candidates' +import { setCustomFieldValueTool } from '@/tools/ashby/set_custom_field_value' +import { setCustomFieldValuesTool } from '@/tools/ashby/set_custom_field_values' import { updateCandidateTool } from '@/tools/ashby/update_candidate' export const ashbyAddCandidateTagTool = addCandidateTagTool +export const ashbyAnonymizeCandidateTool = anonymizeCandidateTool +export const ashbyChangeApplicationSourceTool = changeApplicationSourceTool export const ashbyChangeApplicationStageTool = changeApplicationStageTool export const ashbyCreateApplicationTool = createApplicationTool export const ashbyCreateCandidateTool = createCandidateTool export const ashbyCreateNoteTool = createNoteTool +export const ashbyDeleteApplicationTool = deleteApplicationTool export const ashbyGetApplicationTool = getApplicationTool export const ashbyGetCandidateTool = getCandidateTool -export const ashbyGetJobTool = getJobTool export const ashbyGetJobPostingTool = getJobPostingTool +export const ashbyGetJobTool = getJobTool export const ashbyGetOfferTool = getOfferTool export const ashbyListApplicationsTool = listApplicationsTool export const ashbyListArchiveReasonsTool = listArchiveReasonsTool @@ -54,6 +62,8 @@ export const ashbyListSourcesTool = listSourcesTool export const ashbyListUsersTool = listUsersTool export const ashbyRemoveCandidateTagTool = removeCandidateTagTool export const ashbySearchCandidatesTool = searchCandidatesTool +export const ashbySetCustomFieldValueTool = setCustomFieldValueTool +export const ashbySetCustomFieldValuesTool = setCustomFieldValuesTool export const ashbyUpdateCandidateTool = updateCandidateTool export * from './types' diff --git a/apps/sim/tools/ashby/list_job_postings.ts b/apps/sim/tools/ashby/list_job_postings.ts index aded90c1cac..f4321116220 100644 --- a/apps/sim/tools/ashby/list_job_postings.ts +++ b/apps/sim/tools/ashby/list_job_postings.ts @@ -6,6 +6,7 @@ interface AshbyListJobPostingsParams { location?: string department?: string listedOnly?: boolean + includeUnpublishedJobPostings?: boolean jobBoardId?: string } @@ -22,6 +23,7 @@ interface AshbyJobPostingSummary { } | null workplaceType: string | null employmentType: string | null + status: string | null isListed: boolean publishedDate: string | null applicationDeadline: string | null @@ -72,6 +74,13 @@ export const listJobPostingsTool: ToolConfig< visibility: 'user-or-llm', description: 'When true, only returns listed (publicly visible) job postings (default false)', }, + includeUnpublishedJobPostings: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, also returns unpublished (Draft) job postings. The endpoint already returns both listed and unlisted published postings by default, so this only adds drafts.', + }, jobBoardId: { type: 'string', required: false, @@ -90,6 +99,9 @@ export const listJobPostingsTool: ToolConfig< if (params.location) body.location = params.location if (params.department) body.department = params.department if (params.listedOnly !== undefined) body.listedOnly = params.listedOnly + if (params.includeUnpublishedJobPostings !== undefined) { + body.includeUnpublishedJobPostings = params.includeUnpublishedJobPostings + } if (params.jobBoardId) body.jobBoardId = params.jobBoardId.trim() return body }, @@ -127,6 +139,7 @@ export const listJobPostingsTool: ToolConfig< : null, workplaceType: (jp.workplaceType as string) ?? null, employmentType: (jp.employmentType as string) ?? null, + status: (jp.status as string) ?? null, isListed: (jp.isListed as boolean) ?? false, publishedDate: (jp.publishedDate as string) ?? null, applicationDeadline: (jp.applicationDeadline as string) ?? null, @@ -186,6 +199,11 @@ export const listJobPostingsTool: ToolConfig< description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)', optional: true, }, + status: { + type: 'string', + description: 'Posting status (Draft or Published)', + optional: true, + }, isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' }, publishedDate: { type: 'string', diff --git a/apps/sim/tools/ashby/list_jobs.ts b/apps/sim/tools/ashby/list_jobs.ts index 13457a1732e..2c71f42df2f 100644 --- a/apps/sim/tools/ashby/list_jobs.ts +++ b/apps/sim/tools/ashby/list_jobs.ts @@ -26,7 +26,15 @@ export const listJobsTool: ToolConfig = { expand: ['openings', 'location'] } if (params.cursor) body.cursor = params.cursor if (params.perPage) body.limit = params.perPage + if (params.syncToken) body.syncToken = params.syncToken if (params.status) body.status = [params.status] const isoToMs = (iso: string): number | null => { const ms = new Date(iso).getTime() @@ -117,6 +126,7 @@ export const listJobsTool: ToolConfig = { + id: 'ashby_set_custom_field_value', + name: 'Ashby Set Custom Field Value', + description: + 'Sets the value of a single custom field on an Ashby Application, Candidate, Job, or Opening. Custom fields are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Requires the candidatesWrite permission.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ashby API Key', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'UUID of the object to set the field on (application, candidate, job, or opening)', + }, + objectType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of the object: Application, Candidate, Job, or Opening', + }, + fieldId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'UUID of the custom field definition to set, as returned by List Custom Fields. This is the field definition ID, not the ID of a value already on the object.', + }, + /** + * Not marked required even though Ashby always expects the key: the shared + * post-merge validator rejects a required `user-or-llm` param whose value is + * `null`, and `null` is exactly how a custom field is cleared. The block + * keeps its own required marker on the subblock, so a blank field is still + * caught in the editor. See `validateRequiredParametersAfterMerge`. + */ + fieldValue: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Value to write, matching the field type: boolean, number, string (String, LongText, Date, Url, or a ValueSelect option), string array (MultiValueSelect), or an object for Currency ({value, currencyCode}), NumberRange ({type, minValue, maxValue}), CompensationRange, and Location ({country, region, city}). Pass null to clear the value, which makes the annotation reversible.', + }, + }, + + request: { + url: 'https://api.ashbyhq.com/customField.setValue', + method: 'POST', + headers: (params) => ashbyAuthHeaders(params.apiKey), + /** + * `null` clears the field, so an omitted or blank value must not be allowed + * to reach Ashby as one - that would turn a dropped variable, an unresolved + * block reference, or a model call that forgot the argument into silent + * data loss. Clearing has to be asked for explicitly. + */ + body: (params) => { + const isBlank = + params.fieldValue === undefined || + (typeof params.fieldValue === 'string' && params.fieldValue.trim() === '') + if (isBlank) { + throw new Error( + 'Ashby custom field value is required. Pass null to clear the field, or provide a value to set.' + ) + } + return { + objectId: params.objectId.trim(), + objectType: normalizeObjectType(params.objectType), + fieldId: params.fieldId.trim(), + fieldValue: params.fieldValue, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + throw new Error(ashbyErrorMessage(data, 'Failed to set custom field value')) + } + + return { + success: true, + output: { + customField: mapCustomFieldOnObject(data.results), + }, + } + }, + + outputs: { + customField: { + type: 'object', + description: 'The custom field as stored on the object after the write', + properties: CUSTOM_FIELD_ON_OBJECT_OUTPUT, + }, + }, +} diff --git a/apps/sim/tools/ashby/set_custom_field_values.ts b/apps/sim/tools/ashby/set_custom_field_values.ts new file mode 100644 index 00000000000..1fc138868ef --- /dev/null +++ b/apps/sim/tools/ashby/set_custom_field_values.ts @@ -0,0 +1,103 @@ +import type { AshbyCustomField } from '@/tools/ashby/types' +import { + ashbyAuthHeaders, + ashbyErrorMessage, + CUSTOM_FIELDS_OUTPUT, + mapCustomFieldOnObject, + normalizeObjectType, +} from '@/tools/ashby/utils' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface AshbySetCustomFieldValuesParams { + apiKey: string + objectId: string + objectType: string + values: unknown +} + +interface AshbySetCustomFieldValuesResponse extends ToolResponse { + output: { + customFields: AshbyCustomField[] + } +} + +export const setCustomFieldValuesTool: ToolConfig< + AshbySetCustomFieldValuesParams, + AshbySetCustomFieldValuesResponse +> = { + id: 'ashby_set_custom_field_values', + name: 'Ashby Set Custom Field Values', + description: + 'Sets several custom field values on one Ashby Application, Candidate, Job, or Opening in a single call. Prefer this over repeated single-field writes to the same object - Ashby recommends it because concurrent single-field calls can race and overwrite each other. Requires the candidatesWrite permission.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ashby API Key', + }, + objectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'UUID of the object to set the fields on (application, candidate, job, or opening)', + }, + objectType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of the object: Application, Candidate, Job, or Opening', + }, + values: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of at least one { fieldId, fieldValue } pair. fieldId is a custom field definition UUID from List Custom Fields. fieldValue matches the field type: boolean, number, string, string array (MultiValueSelect), or an object for Currency, NumberRange, CompensationRange, and Location. Pass null as a fieldValue to clear that field.', + }, + }, + + request: { + url: 'https://api.ashbyhq.com/customField.setValues', + method: 'POST', + headers: (params) => ashbyAuthHeaders(params.apiKey), + /** Ashby rejects an empty array; fail here with a message naming the field. */ + body: (params) => { + if (!Array.isArray(params.values) || params.values.length === 0) { + throw new Error( + 'Ashby custom field values must be a non-empty array of { fieldId, fieldValue } pairs.' + ) + } + return { + objectId: params.objectId.trim(), + objectType: normalizeObjectType(params.objectType), + values: params.values, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + throw new Error(ashbyErrorMessage(data, 'Failed to set custom field values')) + } + + return { + success: true, + output: { + customFields: (data.results ?? []).map(mapCustomFieldOnObject), + }, + } + }, + + outputs: { + customFields: { + ...CUSTOM_FIELDS_OUTPUT, + description: 'The custom fields as stored on the object after the write', + }, + }, +} diff --git a/apps/sim/tools/ashby/types.ts b/apps/sim/tools/ashby/types.ts index 800728b5948..cf3cced8895 100644 --- a/apps/sim/tools/ashby/types.ts +++ b/apps/sim/tools/ashby/types.ts @@ -31,7 +31,7 @@ export interface AshbyCustomField { id: string | null title: string isPrivate: boolean - valueLabel: string | null + valueLabel: string | string[] | null value: unknown } @@ -122,6 +122,7 @@ export interface AshbySearchCandidatesParams extends AshbyBaseParams { export interface AshbyListJobsParams extends AshbyBaseParams { cursor?: string perPage?: number + syncToken?: string status?: string createdAfter?: string openedAfter?: string @@ -266,6 +267,7 @@ export interface AshbyListJobsResponse extends ToolResponse { jobs: AshbyJob[] moreDataAvailable: boolean nextCursor: string | null + nextSyncCursor: string | null } } diff --git a/apps/sim/tools/ashby/utils.test.ts b/apps/sim/tools/ashby/utils.test.ts new file mode 100644 index 00000000000..9c4f5dd4fae --- /dev/null +++ b/apps/sim/tools/ashby/utils.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { ashbyErrorMessage } from '@/tools/ashby/utils' + +describe('ashbyErrorMessage', () => { + it('reads the message out of the documented { message, parameter } entries', () => { + // This is the shape Ashby's OpenAPI definition declares, and the one a 403 + // for a missing module permission arrives in. Stringifying the entry + // directly yields '[object Object]' and hides the real cause. + expect( + ashbyErrorMessage( + { success: false, errors: [{ message: 'missing_endpoint_permission' }] }, + 'fallback' + ) + ).toBe('missing_endpoint_permission') + }) + + it('names the offending parameter when Ashby supplies one', () => { + expect( + ashbyErrorMessage( + { success: false, errors: [{ message: 'Invalid value', parameter: 'fieldValue' }] }, + 'fallback' + ) + ).toBe('Invalid value (fieldValue)') + }) + + it('joins multiple errors', () => { + expect( + ashbyErrorMessage( + { success: false, errors: [{ message: 'a' }, { message: 'b' }] }, + 'fallback' + ) + ).toBe('a; b') + }) + + it('still handles the plain string array form', () => { + expect(ashbyErrorMessage({ success: false, errors: ['boom'] }, 'fallback')).toBe('boom') + }) + + it('prefers errorInfo.message, the other documented shape', () => { + expect( + ashbyErrorMessage({ success: false, errorInfo: { message: 'rate limited' } }, 'fallback') + ).toBe('rate limited') + }) + + it('falls back when the entries carry no usable message', () => { + expect(ashbyErrorMessage({ success: false, errors: [{ parameter: 'x' }] }, 'fallback')).toBe( + 'fallback' + ) + expect(ashbyErrorMessage({ success: false, errors: [] }, 'fallback')).toBe('fallback') + expect(ashbyErrorMessage(null, 'fallback')).toBe('fallback') + }) +}) diff --git a/apps/sim/tools/ashby/utils.ts b/apps/sim/tools/ashby/utils.ts index 0a992e4ccff..9e6a84afae7 100644 --- a/apps/sim/tools/ashby/utils.ts +++ b/apps/sim/tools/ashby/utils.ts @@ -31,7 +31,11 @@ export function ashbyAuthHeaders(apiKey: string): Record { /** * Extract a human-readable error message from an Ashby error response. Ashby - * returns errors as either `errorInfo.message` or an `errors` string array. + * documents two shapes and uses both: `errorInfo.message`, and an `errors` + * array whose entries are either plain strings or `{ message, parameter }` + * objects. An object entry stringifies to `[object Object]` unless its message + * is read explicitly, which is the form a 403 for a missing module permission + * arrives in. */ export function ashbyErrorMessage(data: unknown, fallback: string): string { if (!data || typeof data !== 'object') return fallback @@ -39,7 +43,20 @@ export function ashbyErrorMessage(data: unknown, fallback: string): string { const info = d.errorInfo as Unknown | undefined if (info && typeof info.message === 'string' && info.message) return info.message if (Array.isArray(d.errors) && d.errors.length > 0) { - return d.errors.map((e) => String(e)).join('; ') + const messages = d.errors + .map((e) => { + if (typeof e === 'string') return e + if (e && typeof e === 'object') { + const entry = e as Unknown + const message = typeof entry.message === 'string' ? entry.message : '' + const parameter = typeof entry.parameter === 'string' ? entry.parameter : '' + if (message && parameter) return `${message} (${parameter})` + if (message) return message + } + return '' + }) + .filter(Boolean) + if (messages.length > 0) return messages.join('; ') } return fallback } @@ -59,18 +76,44 @@ function mapContactArray(raw: unknown): AshbyContactInfo[] { return raw.map((c) => mapContact(c)).filter((c): c is AshbyContactInfo => c !== null) } +const CUSTOM_FIELD_OBJECT_TYPES = ['Application', 'Candidate', 'Job', 'Opening'] as const + +/** + * Normalize and validate the objectType a custom field write targets. Ashby's + * enum is case-sensitive, and this param is `user-or-llm` - a model emitting + * `candidate` instead of `Candidate` would otherwise fail at the API with a + * generic error instead of here with a message naming the allowed values. + */ +export function normalizeObjectType(value: string): string { + const trimmed = (value ?? '').trim() + const match = CUSTOM_FIELD_OBJECT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase()) + if (!match) { + throw new Error( + `Invalid Ashby object type "${value}". Expected one of: ${CUSTOM_FIELD_OBJECT_TYPES.join(', ')}.` + ) + } + return match +} + +/** + * Map a single custom field value as returned on an object. Ashby returns + * `valueLabel` as a string for ValueSelect fields and an array of strings for + * MultiValueSelect, and omits it entirely for every other field type. + */ +export function mapCustomFieldOnObject(raw: unknown): AshbyCustomField { + const cf = (raw ?? {}) as Unknown + return { + id: (cf.id as string) ?? null, + title: (cf.title as string) ?? '', + isPrivate: (cf.isPrivate as boolean) ?? false, + valueLabel: (cf.valueLabel as string | string[]) ?? null, + value: cf.value ?? null, + } +} + function mapCustomFields(raw: unknown): AshbyCustomField[] { if (!Array.isArray(raw)) return [] - return raw.map((f) => { - const cf = f as Unknown - return { - id: (cf.id as string) ?? null, - title: (cf.title as string) ?? '', - isPrivate: (cf.isPrivate as boolean) ?? false, - valueLabel: (cf.valueLabel as string) ?? null, - value: cf.value ?? null, - } - }) + return raw.map(mapCustomFieldOnObject) } function mapFileHandle(raw: unknown): AshbyFileHandle | null { @@ -373,18 +416,30 @@ export const CONTACT_INFO_OUTPUT = { }, } as const satisfies OutputProperty +/** + * Shape of a custom field as it exists on an Application, Candidate, Job, or + * Opening - the value, not the field definition. Shared by every tool that + * reads or writes custom field values. + */ +export const CUSTOM_FIELD_ON_OBJECT_OUTPUT = { + id: { type: 'string', description: 'Custom field UUID' }, + title: { type: 'string', description: 'Field title' }, + isPrivate: { type: 'boolean', description: 'Whether the field is private' }, + valueLabel: { + type: 'json', + description: + 'Human-readable value label, present only for ValueSelect and MultiValueSelect fields. A string for ValueSelect, an array of strings for MultiValueSelect.', + optional: true, + }, + value: { type: 'string', description: 'Raw field value (type depends on fieldType)' }, +} as const satisfies Record + export const CUSTOM_FIELDS_OUTPUT = { type: 'array', description: 'Custom field values', items: { type: 'object', - properties: { - id: { type: 'string', description: 'Custom field UUID' }, - title: { type: 'string', description: 'Field title' }, - isPrivate: { type: 'boolean', description: 'Whether the field is private' }, - valueLabel: { type: 'string', description: 'Human-readable value label', optional: true }, - value: { type: 'string', description: 'Raw field value (type depends on fieldType)' }, - }, + properties: CUSTOM_FIELD_ON_OBJECT_OUTPUT, }, } as const satisfies OutputProperty diff --git a/apps/sim/tools/azure_data_explorer/create_table.ts b/apps/sim/tools/azure_data_explorer/create_table.ts new file mode 100644 index 00000000000..bdc29956005 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/create_table.ts @@ -0,0 +1,135 @@ +import type { + AzureDataExplorerCreateTableParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + buildWithClause, + renderColumnSchema, + renderEntityName, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerCreateTableTool: ToolConfig< + AzureDataExplorerCreateTableParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_create_table', + name: 'Azure Data Explorer Create Table', + description: + 'Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database to create the table in', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to create', + }, + columnSchema: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated CSL column schema (e.g., Timestamp:datetime, Level:string, Count:long)', + }, + tableProperties: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Optional table properties clause contents, e.g. docstring="Raw logs", folder="Ingest"', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: `.create table ${renderEntityName(params.table)} (${renderColumnSchema( + params.columnSchema + )})${buildWithClause(params.tableProperties, 'docstring="Raw logs"')}`, + }), + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/drop_table.ts b/apps/sim/tools/azure_data_explorer/drop_table.ts new file mode 100644 index 00000000000..5f8200c68f5 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/drop_table.ts @@ -0,0 +1,128 @@ +import type { + AzureDataExplorerDropTableParams, + AzureDataExplorerTableListResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + renderEntityName, + transformColumnListResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerDropTableTool: ToolConfig< + AzureDataExplorerDropTableParams, + AzureDataExplorerTableListResponse +> = { + id: 'azure_data_explorer_drop_table', + name: 'Azure Data Explorer Drop Table', + description: + 'Drop a table from an Azure Data Explorer database. This permanently deletes the table and its data, and returns the tables that remain.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database containing the table', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to drop', + }, + ifExists: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Succeed instead of failing when the table does not exist', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: `.drop table ${renderEntityName(params.table)}${params.ifExists ? ' ifexists' : ''}`, + }), + }, + transformResponse: transformColumnListResponse('TableName', 'tables'), + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + tables: { + type: 'array', + description: 'Tables remaining in the database, read from the TableName column', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/index.ts b/apps/sim/tools/azure_data_explorer/index.ts new file mode 100644 index 00000000000..23bf530d4ad --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/index.ts @@ -0,0 +1,15 @@ +export { azureDataExplorerCreateTableTool } from '@/tools/azure_data_explorer/create_table' +export { azureDataExplorerDropTableTool } from '@/tools/azure_data_explorer/drop_table' +export { azureDataExplorerIngestFromQueryTool } from '@/tools/azure_data_explorer/ingest_from_query' +export { azureDataExplorerIngestInlineTool } from '@/tools/azure_data_explorer/ingest_inline' +export { azureDataExplorerListDatabasesTool } from '@/tools/azure_data_explorer/list_databases' +export { azureDataExplorerListFunctionsTool } from '@/tools/azure_data_explorer/list_functions' +export { azureDataExplorerListTablesTool } from '@/tools/azure_data_explorer/list_tables' +export { azureDataExplorerManagementTool } from '@/tools/azure_data_explorer/management' +export { azureDataExplorerQueryTool } from '@/tools/azure_data_explorer/query' +export { azureDataExplorerShowDatabaseSchemaTool } from '@/tools/azure_data_explorer/show_database_schema' +export { azureDataExplorerShowIngestionFailuresTool } from '@/tools/azure_data_explorer/show_ingestion_failures' +export { azureDataExplorerShowOperationsTool } from '@/tools/azure_data_explorer/show_operations' +export { azureDataExplorerShowTableDetailsTool } from '@/tools/azure_data_explorer/show_table_details' +export { azureDataExplorerShowTableSchemaTool } from '@/tools/azure_data_explorer/show_table_schema' +export * from '@/tools/azure_data_explorer/types' diff --git a/apps/sim/tools/azure_data_explorer/ingest_from_query.ts b/apps/sim/tools/azure_data_explorer/ingest_from_query.ts new file mode 100644 index 00000000000..21c54314e51 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/ingest_from_query.ts @@ -0,0 +1,148 @@ +import type { + AzureDataExplorerIngestFromQueryParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + buildWithClause, + renderEntityName, + renderIngestMode, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerIngestFromQueryTool: ToolConfig< + AzureDataExplorerIngestFromQueryParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_ingest_from_query', + name: 'Azure Data Explorer Ingest From Query', + description: + "Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns.", + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database containing the target table', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table to ingest the query result into', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'set (create, fail if it exists), append (add to an existing table), set-or-append (default), or set-or-replace (replace all data)', + }, + sourceQuery: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'KQL query whose result becomes the ingested data (e.g., LogsTable | where Level == "Error" | where Timestamp > ago(1h)). Project the columns in the target table\'s order — matching is positional, not by name', + }, + async: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Return immediately with an OperationId and keep ingesting in the background. Check progress with Show Operations', + }, + ingestionProperties: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: `Optional ingestion properties clause contents, e.g. distributed=true, tags='["daily"]'`, + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: `${renderIngestMode(params.mode)}${params.async ? ' async' : ''} ${renderEntityName( + params.table + )}${buildWithClause(params.ingestionProperties, 'distributed=true')} <|\n${params.sourceQuery}`, + }), + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/ingest_inline.ts b/apps/sim/tools/azure_data_explorer/ingest_inline.ts new file mode 100644 index 00000000000..e47fb87b07b --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/ingest_inline.ts @@ -0,0 +1,141 @@ +import type { + AzureDataExplorerIngestInlineParams, + AzureDataExplorerIngestResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + buildWithClause, + renderEntityName, + transformColumnListResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerIngestInlineTool: ToolConfig< + AzureDataExplorerIngestInlineParams, + AzureDataExplorerIngestResponse +> = { + id: 'azure_data_explorer_ingest_inline', + name: 'Azure Data Explorer Ingest Inline', + description: + 'Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database containing the target table', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table to ingest into. Its schema is the assumed schema for the data', + }, + data: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Rows to ingest, one record per line, parsed as CSV by default (e.g., "Shoes,1000\\nWide Shoes,50")', + }, + ingestionProperties: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Ingestion properties clause contents, e.g. format="json", ingestionMappingReference="mymapping"', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: `.ingest inline into table ${renderEntityName(params.table)}${buildWithClause( + params.ingestionProperties, + 'format="json"' + )} <|\n${params.data}`, + }), + }, + transformResponse: transformColumnListResponse('ExtentId', 'extentIds'), + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + extentIds: { + type: 'array', + description: + 'Extent IDs created by the ingestion — one per data shard. A single empty or zero-valued ID means no data shard was generated', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/list_databases.ts b/apps/sim/tools/azure_data_explorer/list_databases.ts new file mode 100644 index 00000000000..7dbde3b61de --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/list_databases.ts @@ -0,0 +1,108 @@ +import type { + AzureDataExplorerDatabaseListResponse, + AzureDataExplorerListDatabasesParams, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + transformColumnListResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerListDatabasesTool: ToolConfig< + AzureDataExplorerListDatabasesParams, + AzureDataExplorerDatabaseListResponse +> = { + id: 'azure_data_explorer_list_databases', + name: 'Azure Data Explorer List Databases', + description: + 'List the databases on an Azure Data Explorer cluster that the service principal can access.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + csl: '.show databases', + }), + }, + transformResponse: transformColumnListResponse('DatabaseName', 'databases'), + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + databases: { + type: 'array', + description: 'Database names, read from the DatabaseName column', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/list_functions.ts b/apps/sim/tools/azure_data_explorer/list_functions.ts new file mode 100644 index 00000000000..066689a1594 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/list_functions.ts @@ -0,0 +1,115 @@ +import type { + AzureDataExplorerFunctionListResponse, + AzureDataExplorerListFunctionsParams, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + transformColumnListResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerListFunctionsTool: ToolConfig< + AzureDataExplorerListFunctionsParams, + AzureDataExplorerFunctionListResponse +> = { + id: 'azure_data_explorer_list_functions', + name: 'Azure Data Explorer List Functions', + description: + 'List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database whose stored functions should be listed', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: '.show functions', + }), + }, + transformResponse: transformColumnListResponse('Name', 'functions'), + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + functions: { + type: 'array', + description: 'Stored function names, read from the Name column', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/list_tables.ts b/apps/sim/tools/azure_data_explorer/list_tables.ts new file mode 100644 index 00000000000..23cfb92d8fe --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/list_tables.ts @@ -0,0 +1,115 @@ +import type { + AzureDataExplorerListTablesParams, + AzureDataExplorerTableListResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + transformColumnListResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerListTablesTool: ToolConfig< + AzureDataExplorerListTablesParams, + AzureDataExplorerTableListResponse +> = { + id: 'azure_data_explorer_list_tables', + name: 'Azure Data Explorer List Tables', + description: + 'List the tables in an Azure Data Explorer database, with their folder and docstring.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database whose tables should be listed', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: '.show tables', + }), + }, + transformResponse: transformColumnListResponse('TableName', 'tables'), + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + tables: { + type: 'array', + description: 'Table names, read from the TableName column', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/management.ts b/apps/sim/tools/azure_data_explorer/management.ts new file mode 100644 index 00000000000..658782b345c --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/management.ts @@ -0,0 +1,123 @@ +import type { + AzureDataExplorerManagementParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerManagementTool: ToolConfig< + AzureDataExplorerManagementParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_management', + name: 'Azure Data Explorer Management Command', + description: + 'Run an Azure Data Explorer management command (a control command starting with ".") such as .show, .create, .alter, or .drop. Write commands change cluster state permanently; use the Query operation for reads.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + command: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Management command text, starting with "." (e.g., .show table Events details)', + }, + database: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Database context for the command. Required for all commands except cluster-level ones such as .show databases', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const command = params.command.trim() + if (!command.startsWith('.')) { + throw new Error('Management commands must start with "." — use the Query operation for KQL') + } + return { + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + ...(params.database ? { database: params.database } : {}), + csl: command, + } + }, + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/query.ts b/apps/sim/tools/azure_data_explorer/query.ts new file mode 100644 index 00000000000..692ea4d9f45 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/query.ts @@ -0,0 +1,152 @@ +import { isRecordLike } from '@sim/utils/object' +import type { + AzureDataExplorerQueryParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +function parseProperties( + input: AzureDataExplorerQueryParams['properties'] +): Record | undefined { + if (input === undefined || input === null || input === '') return undefined + if (typeof input === 'object') return input + try { + const parsed = JSON.parse(input) + if (isRecordLike(parsed)) { + return parsed as Record + } + } catch { + throw new Error('Invalid properties: must be a JSON object') + } + throw new Error('Invalid properties: must be a JSON object') +} + +export const azureDataExplorerQueryTool: ToolConfig< + AzureDataExplorerQueryParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_query', + name: 'Azure Data Explorer Query', + description: + 'Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database to run the query against', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'KQL query text (e.g., StormEvents | where State == "FLORIDA" | summarize count() by EventType)', + }, + properties: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Kusto request properties object, e.g. {"Options":{"servertimeout":"00:04:00","queryconsistency":"strongconsistency"}}', + }, + readOnly: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Send x-ms-readonly so the cluster rejects any request that would change data', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const properties = parseProperties(params.properties) + return { + ...azureDataExplorerAuthBody(params), + endpoint: 'query', + database: params.database, + csl: params.query, + ...(properties ? { properties } : {}), + ...(params.readOnly ? { readOnly: true } : {}), + } + }, + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/show_database_schema.ts b/apps/sim/tools/azure_data_explorer/show_database_schema.ts new file mode 100644 index 00000000000..c76ac0de9d3 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/show_database_schema.ts @@ -0,0 +1,111 @@ +import type { + AzureDataExplorerShowDatabaseSchemaParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + renderEntityName, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerShowDatabaseSchemaTool: ToolConfig< + AzureDataExplorerShowDatabaseSchemaParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_show_database_schema', + name: 'Azure Data Explorer Show Database Schema', + description: + 'Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database whose schema should be read', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: `.show database ${renderEntityName(params.database)} schema`, + }), + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/show_ingestion_failures.ts b/apps/sim/tools/azure_data_explorer/show_ingestion_failures.ts new file mode 100644 index 00000000000..5128ba830e9 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/show_ingestion_failures.ts @@ -0,0 +1,119 @@ +import type { + AzureDataExplorerShowIngestionFailuresParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + renderOperationId, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerShowIngestionFailuresTool: ToolConfig< + AzureDataExplorerShowIngestionFailuresParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_show_ingestion_failures', + name: 'Azure Data Explorer Show Ingestion Failures', + description: + 'List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database whose ingestion failures should be listed', + }, + operationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Limit results to a single ingestion operation ID', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: params.operationId + ? `.show ingestion failures with (OperationId = ${renderOperationId(params.operationId)})` + : '.show ingestion failures', + }), + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/show_operations.ts b/apps/sim/tools/azure_data_explorer/show_operations.ts new file mode 100644 index 00000000000..6bc5eeeee17 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/show_operations.ts @@ -0,0 +1,119 @@ +import type { + AzureDataExplorerShowOperationsParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + renderOperationId, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerShowOperationsTool: ToolConfig< + AzureDataExplorerShowOperationsParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_show_operations', + name: 'Azure Data Explorer Show Operations', + description: + 'Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Database context for the command', + }, + operationId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Operation ID to check, e.g. the ID returned by an async ingestion', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: params.operationId + ? `.show operations ${renderOperationId(params.operationId)}` + : '.show operations', + }), + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/show_table_details.ts b/apps/sim/tools/azure_data_explorer/show_table_details.ts new file mode 100644 index 00000000000..7de6131ebb0 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/show_table_details.ts @@ -0,0 +1,119 @@ +import type { + AzureDataExplorerShowTableDetailsParams, + AzureDataExplorerTableResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + renderEntityName, + transformAzureDataExplorerResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerShowTableDetailsTool: ToolConfig< + AzureDataExplorerShowTableDetailsParams, + AzureDataExplorerTableResponse +> = { + id: 'azure_data_explorer_show_table_details', + name: 'Azure Data Explorer Show Table Details', + description: + 'Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database to read table details from', + }, + table: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Table to describe. Omit to describe every table in the database', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: params.table + ? `.show table ${renderEntityName(params.table)} details` + : '.show tables details', + }), + }, + transformResponse: transformAzureDataExplorerResponse, + outputs: { + tableName: { + type: 'string', + description: 'Name Kusto assigned to the returned result table', + nullable: true, + }, + columns: { + type: 'array', + description: 'Column metadata for the result table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Kusto scalar type', nullable: true }, + dataType: { type: 'string', description: 'Approximate .NET type', nullable: true }, + }, + }, + }, + rows: { + type: 'array', + description: 'Result rows as positional arrays matching the columns order', + items: { type: 'array' }, + }, + records: { + type: 'array', + description: 'Result rows keyed by column name', + items: { type: 'object' }, + }, + rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' }, + totalRowCount: { + type: 'number', + description: 'Rows Kusto returned, before the row cap was applied', + }, + truncated: { + type: 'boolean', + description: + 'Whether rows were dropped to stay within the row cap — narrow the query if true', + }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/show_table_schema.ts b/apps/sim/tools/azure_data_explorer/show_table_schema.ts new file mode 100644 index 00000000000..87ef480c3a9 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/show_table_schema.ts @@ -0,0 +1,89 @@ +import type { + AzureDataExplorerShowTableSchemaParams, + AzureDataExplorerTableSchemaResponse, +} from '@/tools/azure_data_explorer/types' +import { + AZURE_DATA_EXPLORER_PROXY_URL, + azureDataExplorerAuthBody, + renderEntityName, + transformTableSchemaResponse, +} from '@/tools/azure_data_explorer/utils' +import type { ToolConfig } from '@/tools/types' + +export const azureDataExplorerShowTableSchemaTool: ToolConfig< + AzureDataExplorerShowTableSchemaParams, + AzureDataExplorerTableSchemaResponse +> = { + id: 'azure_data_explorer_show_table_schema', + name: 'Azure Data Explorer Show Table Schema', + description: + 'Read the column schema of an Azure Data Explorer table in CSL form (e.g., "Timestamp:datetime,Level:string"). Use this before writing a KQL query against an unfamiliar table.', + version: '1.0.0', + params: { + clusterUri: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)', + }, + tenantId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra tenant ID hosting the service principal', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application (client) ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Microsoft Entra application client secret', + }, + resource: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token audience override. Defaults to the cluster URI itself', + }, + database: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database containing the table', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table whose schema should be read', + }, + }, + request: { + url: AZURE_DATA_EXPLORER_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...azureDataExplorerAuthBody(params), + endpoint: 'mgmt', + database: params.database, + csl: `.show table ${renderEntityName(params.table)} cslschema`, + }), + }, + transformResponse: transformTableSchemaResponse, + outputs: { + tableName: { type: 'string', description: 'Name of the table', nullable: true }, + schema: { + type: 'string', + description: 'Comma-separated CSL column schema (name:type)', + nullable: true, + }, + databaseName: { type: 'string', description: "The table's database", nullable: true }, + folder: { type: 'string', description: "The table's folder", nullable: true }, + docString: { type: 'string', description: "The table's docstring", nullable: true }, + }, +} diff --git a/apps/sim/tools/azure_data_explorer/types.ts b/apps/sim/tools/azure_data_explorer/types.ts new file mode 100644 index 00000000000..76a86084492 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/types.ts @@ -0,0 +1,133 @@ +import type { ToolResponse } from '@/tools/types' + +export interface AzureDataExplorerBaseParams { + clusterUri: string + tenantId: string + clientId: string + clientSecret: string + resource?: string +} + +export interface AzureDataExplorerColumn { + name: string + type: string | null + dataType: string | null +} + +/** Primary result table of a Kusto query or management command. */ +export interface AzureDataExplorerTable { + tableName: string | null + columns: AzureDataExplorerColumn[] + rows: unknown[][] + records: Array> + /** Rows carried in this payload, after the row cap. */ + rowCount: number + /** Rows Kusto returned, before the row cap. */ + totalRowCount: number + truncated: boolean +} + +export interface AzureDataExplorerTableResponse extends ToolResponse { + output: AzureDataExplorerTable +} + +export interface AzureDataExplorerDatabaseListResponse extends ToolResponse { + output: AzureDataExplorerTable & { databases: string[] } +} + +export interface AzureDataExplorerTableListResponse extends ToolResponse { + output: AzureDataExplorerTable & { tables: string[] } +} + +export interface AzureDataExplorerIngestResponse extends ToolResponse { + output: AzureDataExplorerTable & { extentIds: string[] } +} + +export interface AzureDataExplorerTableSchemaResponse extends ToolResponse { + output: { + tableName: string | null + schema: string | null + databaseName: string | null + folder: string | null + docString: string | null + } +} + +export interface AzureDataExplorerQueryParams extends AzureDataExplorerBaseParams { + database: string + query: string + properties?: Record | string + readOnly?: boolean +} + +export interface AzureDataExplorerManagementParams extends AzureDataExplorerBaseParams { + command: string + database?: string +} + +export interface AzureDataExplorerListDatabasesParams extends AzureDataExplorerBaseParams {} + +export interface AzureDataExplorerListTablesParams extends AzureDataExplorerBaseParams { + database: string +} + +export interface AzureDataExplorerShowTableSchemaParams extends AzureDataExplorerBaseParams { + database: string + table: string +} + +export interface AzureDataExplorerShowDatabaseSchemaParams extends AzureDataExplorerBaseParams { + database: string +} + +export interface AzureDataExplorerFunctionListResponse extends ToolResponse { + output: AzureDataExplorerTable & { functions: string[] } +} + +export interface AzureDataExplorerCreateTableParams extends AzureDataExplorerBaseParams { + database: string + table: string + columnSchema: string + tableProperties?: string +} + +export interface AzureDataExplorerDropTableParams extends AzureDataExplorerBaseParams { + database: string + table: string + ifExists?: boolean +} + +export interface AzureDataExplorerIngestFromQueryParams extends AzureDataExplorerBaseParams { + database: string + table: string + mode?: string + sourceQuery: string + async?: boolean + ingestionProperties?: string +} + +export interface AzureDataExplorerShowTableDetailsParams extends AzureDataExplorerBaseParams { + database: string + table?: string +} + +export interface AzureDataExplorerShowIngestionFailuresParams extends AzureDataExplorerBaseParams { + database: string + operationId?: string +} + +export interface AzureDataExplorerListFunctionsParams extends AzureDataExplorerBaseParams { + database: string +} + +export interface AzureDataExplorerShowOperationsParams extends AzureDataExplorerBaseParams { + database?: string + operationId?: string +} + +export interface AzureDataExplorerIngestInlineParams extends AzureDataExplorerBaseParams { + database: string + table: string + data: string + ingestionProperties?: string +} diff --git a/apps/sim/tools/azure_data_explorer/utils.test.ts b/apps/sim/tools/azure_data_explorer/utils.test.ts new file mode 100644 index 00000000000..1f2ed7e3f9e --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/utils.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildWithClause, + renderColumnSchema, + renderEntityName, + renderIngestMode, + renderOperationId, + transformColumnListResponse, +} from '@/tools/azure_data_explorer/utils' + +describe('renderEntityName', () => { + it('leaves a plain identifier bare, as the reference commands are written', () => { + expect(renderEntityName('StormEvents')).toBe('StormEvents') + expect(renderEntityName('_internal_logs2')).toBe('_internal_logs2') + }) + + it('trims surrounding whitespace from a pasted name', () => { + expect(renderEntityName(' StormEvents ')).toBe('StormEvents') + }) + + it('quotes the identifier characters Kusto allows but cannot parse bare', () => { + expect(renderEntityName('My Table')).toBe('["My Table"]') + expect(renderEntityName('web-requests')).toBe('["web-requests"]') + expect(renderEntityName('prod.logs')).toBe('["prod.logs"]') + expect(renderEntityName('1day')).toBe('["1day"]') + }) +}) + +describe('buildWithClause', () => { + it('returns nothing when no properties are given', () => { + expect(buildWithClause(undefined, 'format="json"')).toBe('') + expect(buildWithClause(' ', 'format="json"')).toBe('') + expect(buildWithClause('with ()', 'format="json"')).toBe('') + }) + + it('builds the clause from bare name=value pairs', () => { + expect(buildWithClause('format="json"', 'format="json"')).toBe(' with (format="json")') + expect( + buildWithClause('format="json", ingestionMappingReference="mymapping"', 'format="json"') + ).toBe(' with (format="json", ingestionMappingReference="mymapping")') + }) + + it('accepts a clause the user already wrapped in with (...)', () => { + expect(buildWithClause('with (format="csv")', 'format="json"')).toBe(' with (format="csv")') + }) + + it('accepts unquoted and numeric values', () => { + expect(buildWithClause('ignoreFirstRecord=true', 'format="json"')).toBe( + ' with (ignoreFirstRecord=true)' + ) + expect(buildWithClause('creationTime=2024-01-01T00:00:00', 'format="json"')).toBe( + ' with (creationTime=2024-01-01T00:00:00)' + ) + }) + + it('keeps a comma that sits inside a quoted value', () => { + expect(buildWithClause('docstring="Raw logs, archived nightly"', 'format="json"')).toBe( + ' with (docstring="Raw logs, archived nightly")' + ) + expect(buildWithClause(`tags="['daily','prod']"`, 'format="json"')).toBe( + ` with (tags="['daily','prod']")` + ) + }) + + it('still separates properties on the commas between them', () => { + expect( + buildWithClause('docstring="Logs, raw", folder="Ingest", distributed=true', 'format="json"') + ).toBe(' with (docstring="Logs, raw", folder="Ingest", distributed=true)') + }) + + it('rejects an unterminated quote rather than swallowing the rest of the clause', () => { + expect(() => buildWithClause('docstring="never closed', 'format="json"')).toThrow( + /Unterminated " quote/ + ) + }) + + it('accepts the exact multi-property clause the Kusto reference shows', () => { + // .append OldExtents with(tags='["TagA","TagB"]', ingestIfNotExists='["myTag"]') + expect( + buildWithClause(`tags='["TagA","TagB"]', ingestIfNotExists='["myTag"]'`, 'distributed=true') + ).toBe(` with (tags='["TagA","TagB"]', ingestIfNotExists='["myTag"]')`) + }) + + it('rejects a value that would close the clause and extend the command', () => { + expect(() => buildWithClause('format="json") <| evil', 'format="json"')).toThrow( + /Invalid property/ + ) + }) + + it('rejects a property that is not a name=value pair', () => { + expect(() => buildWithClause('drop table StormEvents', 'format="json"')).toThrow( + /Invalid property/ + ) + }) +}) + +describe('renderColumnSchema', () => { + it('normalizes a CSL schema and lowercases the types', () => { + expect(renderColumnSchema('Timestamp:DateTime, Level:string, Count:LONG')).toBe( + 'Timestamp:datetime, Level:string, Count:long' + ) + }) + + it('accepts every documented scalar type and alias', () => { + expect(renderColumnSchema('a:bool, b:boolean, c:date, d:guid, e:uuid, f:double, g:time')).toBe( + 'a:bool, b:boolean, c:date, d:guid, e:uuid, f:double, g:time' + ) + }) + + it('quotes a column name that cannot be written bare', () => { + expect(renderColumnSchema('Event Time:datetime')).toBe('["Event Time"]:datetime') + }) + + it('rejects a type Kusto does not define', () => { + expect(() => renderColumnSchema('Amount:money')).toThrow(/Unknown column type "money"/) + }) + + it('rejects a column that is not a name:type pair', () => { + expect(() => renderColumnSchema('Timestamp')).toThrow(/expected name:type pairs/) + }) + + it('rejects a schema that would close the column list and extend the command', () => { + expect(() => renderColumnSchema('a:string) //')).toThrow(/Unknown column type/) + }) + + it('rejects an empty schema', () => { + expect(() => renderColumnSchema(' , ')).toThrow(/Column schema is required/) + }) +}) + +describe('renderIngestMode', () => { + it('defaults to the non-destructive set-or-append command', () => { + expect(renderIngestMode(undefined)).toBe('.set-or-append') + expect(renderIngestMode('')).toBe('.set-or-append') + }) + + it('maps each documented mode onto its command word', () => { + expect(renderIngestMode('set')).toBe('.set') + expect(renderIngestMode('append')).toBe('.append') + expect(renderIngestMode('set-or-replace')).toBe('.set-or-replace') + }) + + it('rejects a mode that is not one of the four commands', () => { + expect(() => renderIngestMode('drop')).toThrow(/Unknown ingest mode "drop"/) + }) +}) + +describe('renderOperationId', () => { + it('accepts a GUID', () => { + expect(renderOperationId(' 8f1e5c4a-1b2c-4d3e-9f80-0a1b2c3d4e5f ')).toBe( + '8f1e5c4a-1b2c-4d3e-9f80-0a1b2c3d4e5f' + ) + }) + + it('rejects anything that could extend the command it lands in', () => { + expect(() => renderOperationId('abc") | drop table X //')).toThrow(/Invalid operation ID/) + }) +}) + +/** Minimal stand-in for the proxy's JSON envelope. */ +function proxyResponse(records: Array>): Response { + return { + ok: true, + status: 200, + json: async () => ({ + success: true, + output: { + tableName: 'Table_0', + columns: [], + rows: [], + records, + rowCount: records.length, + totalRowCount: records.length, + truncated: false, + }, + }), + } as unknown as Response +} + +describe('transformColumnListResponse', () => { + it('collects the values of the named column', async () => { + const transform = transformColumnListResponse('TableName', 'tables') + const result = await transform( + proxyResponse([{ TableName: 'StormEvents' }, { TableName: 'Logs' }]) + ) + + expect(result.output.tables).toEqual(['StormEvents', 'Logs']) + }) + + it("keeps an empty extent ID, which is how Kusto reports 'no data shard was written'", async () => { + const transform = transformColumnListResponse('ExtentId', 'extentIds') + const result = await transform(proxyResponse([{ ExtentId: '' }])) + + expect(result.output.extentIds).toEqual(['']) + expect(result.output.rowCount).toBe(1) + }) + + it('skips a null value rather than coercing it to a string', async () => { + const transform = transformColumnListResponse('TableName', 'tables') + const result = await transform(proxyResponse([{ TableName: null }, { TableName: 'Logs' }])) + + expect(result.output.tables).toEqual(['Logs']) + }) +}) diff --git a/apps/sim/tools/azure_data_explorer/utils.ts b/apps/sim/tools/azure_data_explorer/utils.ts new file mode 100644 index 00000000000..78b2dbe8127 --- /dev/null +++ b/apps/sim/tools/azure_data_explorer/utils.ts @@ -0,0 +1,288 @@ +import type { + AzureDataExplorerBaseParams, + AzureDataExplorerTable, +} from '@/tools/azure_data_explorer/types' + +export const AZURE_DATA_EXPLORER_PROXY_URL = '/api/tools/azure_data_explorer/proxy' + +export function azureDataExplorerAuthBody(params: AzureDataExplorerBaseParams) { + return { + clusterUri: params.clusterUri, + tenantId: params.tenantId, + clientId: params.clientId, + clientSecret: params.clientSecret, + ...(params.resource ? { resource: params.resource } : {}), + } +} + +/** A name Kusto accepts bare, matching the form every documented example uses. */ +const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Renders a Kusto entity name for a command string. + * + * A plain identifier is emitted bare, exactly as the reference commands are + * written. A name carrying a space, dot, or dash — the other characters Kusto + * permits in an identifier — is wrapped in `["..."]`, the documented quoting for + * names with special characters. The proxy contract restricts names to Kusto's + * documented character set, so neither form can carry a quote or bracket and no + * escaping is needed. + */ +export function renderEntityName(name: string): string { + const trimmed = name.trim() + return PLAIN_IDENTIFIER.test(trimmed) ? trimmed : `["${trimmed}"]` +} + +/** One `name = value` command property, with a quoted, numeric, or bare value. */ +const COMMAND_PROPERTY = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"[^"\\]*"|'[^'\\]*'|[A-Za-z0-9_.:+-]+)$/ + +/** + * Splits a property list on the commas that separate properties, ignoring the + * ones inside a quoted value. + * + * A plain `split(',')` would break the documented cases where a value legally + * contains a comma — a `docstring` sentence, or a `tags` array with more than + * one entry. + */ +function splitProperties(input: string): string[] { + const parts: string[] = [] + let current = '' + let quote: '"' | "'" | null = null + + for (const char of input) { + if (quote) { + current += char + if (char === quote) quote = null + continue + } + if (char === '"' || char === "'") { + quote = char + current += char + continue + } + if (char === ',') { + parts.push(current) + current = '' + continue + } + current += char + } + + if (quote) throw new Error(`Unterminated ${quote} quote in property list: ${input}`) + parts.push(current) + + return parts.map((part) => part.trim()).filter(Boolean) +} + +/** + * Builds the `with (...)` clause shared by the create and ingest commands. + * + * The clause is interpolated into a command string, so each property is checked + * against the `name = value` grammar rather than passed through — an + * unvalidated value could otherwise close the clause and extend the command. + */ +export function buildWithClause(input: string | undefined, example: string): string { + const trimmed = input?.trim() + if (!trimmed) return '' + + const inner = trimmed + .replace(/^with\s*\(/i, '') + .replace(/\)$/, '') + .trim() + if (!inner) return '' + + const properties = splitProperties(inner) + for (const property of properties) { + if (!COMMAND_PROPERTY.test(property)) { + throw new Error( + `Invalid property "${property}": expected comma-separated name=value pairs, e.g. ${example}` + ) + } + } + + return ` with (${properties.join(', ')})` +} + +/** The scalar type names and aliases Kusto documents for a column. */ +const KUSTO_SCALAR_TYPES = new Set([ + 'bool', + 'boolean', + 'datetime', + 'date', + 'decimal', + 'dynamic', + 'guid', + 'uuid', + 'uniqueid', + 'int', + 'long', + 'real', + 'double', + 'string', + 'timespan', + 'time', +]) + +const COLUMN_NAME = /^[\p{L}\p{N}_ .-]+$/u + +/** + * Renders a CSL column schema — `Timestamp:datetime, Level:string` — for a + * create command. + * + * Each column name goes through {@link renderEntityName} and each type is + * checked against Kusto's documented scalar types, so the schema cannot carry + * anything that would extend the command. + */ +export function renderColumnSchema(schema: string): string { + const columns = schema + .split(',') + .map((column) => column.trim()) + .filter(Boolean) + + if (columns.length === 0) { + throw new Error('Column schema is required, e.g. Timestamp:datetime, Level:string') + } + + return columns + .map((column) => { + const separator = column.lastIndexOf(':') + if (separator <= 0) { + throw new Error( + `Invalid column "${column}": expected name:type pairs, e.g. Timestamp:datetime, Level:string` + ) + } + const name = column.slice(0, separator).trim() + const type = column + .slice(separator + 1) + .trim() + .toLowerCase() + + if (!COLUMN_NAME.test(name)) { + throw new Error( + `Invalid column name "${name}": may contain only letters, digits, underscores, spaces, dots, and dashes` + ) + } + if (!KUSTO_SCALAR_TYPES.has(type)) { + throw new Error( + `Unknown column type "${type}" for column "${name}": expected one of ${[...KUSTO_SCALAR_TYPES].join(', ')}` + ) + } + return `${renderEntityName(name)}:${type}` + }) + .join(', ') +} + +/** The four documented ingest-from-query commands. */ +const INGEST_MODE_COMMANDS = { + set: '.set', + append: '.append', + 'set-or-append': '.set-or-append', + 'set-or-replace': '.set-or-replace', +} as const + +export type AzureDataExplorerIngestMode = keyof typeof INGEST_MODE_COMMANDS + +/** + * Maps an ingest mode onto its command word. Unknown modes are rejected rather + * than passed through, since the word leads the command string. + */ +export function renderIngestMode(mode: string | undefined): string { + const key = (mode?.trim() || 'set-or-append') as AzureDataExplorerIngestMode + const command = INGEST_MODE_COMMANDS[key] + if (!command) { + throw new Error( + `Unknown ingest mode "${mode}": expected one of ${Object.keys(INGEST_MODE_COMMANDS).join(', ')}` + ) + } + return command +} + +/** + * Kusto operation IDs are GUIDs. The `.show operations` and + * `.show ingestion failures` syntax lines take the ID as a bare argument, so it + * is emitted unquoted and restricted to GUID characters. + */ +const OPERATION_ID = /^[0-9a-fA-F-]{1,64}$/ + +export function renderOperationId(operationId: string): string { + const trimmed = operationId.trim() + if (!OPERATION_ID.test(trimmed)) { + throw new Error(`Invalid operation ID "${operationId}": expected a GUID`) + } + return trimmed +} + +interface ProxyEnvelope { + success?: boolean + output?: AzureDataExplorerTable + error?: string +} + +async function readProxyEnvelope(response: Response): Promise { + const data = (await response.json().catch(() => ({}))) as ProxyEnvelope + + if (!response.ok || data.success === false) { + throw new Error(data.error || `Azure Data Explorer request failed: HTTP ${response.status}`) + } + + return ( + data.output ?? { + tableName: null, + columns: [], + rows: [], + records: [], + rowCount: 0, + totalRowCount: 0, + truncated: false, + } + ) +} + +export async function transformAzureDataExplorerResponse(response: Response) { + return { success: true as const, output: await readProxyEnvelope(response) } +} + +/** + * Adds a flat list of the strings in one documented column, so a `.show` or + * `.ingest` command surfaces its identifiers alongside the full result table. + * + * Every string value is kept, including an empty one: `.ingest inline` reports + * "no data shards were generated" as a single record carrying an empty + * (zero-valued) extent ID, so dropping it would turn a no-op load into what + * looks like a missing column. + */ +export function transformColumnListResponse(columnName: string, outputKey: K) { + return async (response: Response) => { + const output = await readProxyEnvelope(response) + const values = output.records + .map((record) => record[columnName]) + .filter((value): value is string => typeof value === 'string') + return { + success: true as const, + output: { ...output, [outputKey]: values } as AzureDataExplorerTable & Record, + } + } +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +/** + * Projects `.show table ... cslschema`, whose single row carries the documented + * TableName, Schema, DatabaseName, Folder, and DocString columns. + */ +export async function transformTableSchemaResponse(response: Response) { + const output = await readProxyEnvelope(response) + const record = output.records[0] ?? {} + return { + success: true as const, + output: { + tableName: stringOrNull(record.TableName), + schema: stringOrNull(record.Schema), + databaseName: stringOrNull(record.DatabaseName), + folder: stringOrNull(record.Folder), + docString: stringOrNull(record.DocString), + }, + } +} diff --git a/apps/sim/tools/azure_devops/utils.ts b/apps/sim/tools/azure_devops/utils.ts index b0955498554..9b59d7f5651 100644 --- a/apps/sim/tools/azure_devops/utils.ts +++ b/apps/sim/tools/azure_devops/utils.ts @@ -3,9 +3,6 @@ import type { AzureDevOpsComment, AzureDevOpsWorkItem } from '@/tools/azure_devo /** States for Azure DevOps Basic process work items (Issue, Task, Epic). */ export const AZURE_DEVOPS_BASIC_WORK_ITEM_STATES = ['To Do', 'Doing', 'Done'] as const -/** Work item types for Azure DevOps Basic process. */ -export const AZURE_DEVOPS_BASIC_WORK_ITEM_TYPES = ['Issue', 'Task', 'Epic'] as const - export type AzureDevOpsJsonPatchOp = { op: string path: string diff --git a/apps/sim/tools/calcom/types.ts b/apps/sim/tools/calcom/types.ts index def0cdee322..b7087f004cf 100644 --- a/apps/sim/tools/calcom/types.ts +++ b/apps/sim/tools/calcom/types.ts @@ -272,82 +272,6 @@ export const SCHEDULE_DATA_OUTPUT_PROPERTIES = { overrides: OVERRIDES_OUTPUT, } as const satisfies Record -/** - * Common event type data output properties - */ -export const EVENT_TYPE_DATA_OUTPUT_PROPERTIES = { - id: { type: 'number', description: 'Event type ID' }, - title: { type: 'string', description: 'Event type title' }, - slug: { type: 'string', description: 'URL-friendly slug' }, - description: { type: 'string', description: 'Event type description' }, - lengthInMinutes: { type: 'number', description: 'Duration in minutes' }, - slotInterval: { type: 'number', description: 'Minutes between available slots' }, - minimumBookingNotice: { type: 'number', description: 'Minimum advance notice in minutes' }, - beforeEventBuffer: { type: 'number', description: 'Buffer time before event in minutes' }, - afterEventBuffer: { type: 'number', description: 'Buffer time after event in minutes' }, - scheduleId: { type: 'number', description: 'Associated schedule ID' }, - disableGuests: { type: 'boolean', description: 'Whether guest invites are disabled' }, - locations: { - type: 'array', - description: 'Meeting location options', - items: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Location type (address, link, integration, phone, etc.)', - }, - address: { - type: 'string', - description: 'Physical address (for address type)', - optional: true, - }, - link: { type: 'string', description: 'Meeting URL (for link type)', optional: true }, - phone: { type: 'string', description: 'Phone number (for phone type)', optional: true }, - integration: { - type: 'string', - description: 'Integration name (for integration type)', - optional: true, - }, - public: { - type: 'boolean', - description: 'Whether location is publicly visible', - optional: true, - }, - }, - }, - }, - bookingFields: { - type: 'array', - description: 'Custom booking form fields', - items: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Field type (name, email, phone, text, select, etc.)', - }, - slug: { type: 'string', description: 'Field identifier', optional: true }, - label: { type: 'string', description: 'Field label' }, - required: { type: 'boolean', description: 'Whether field is required', optional: true }, - placeholder: { type: 'string', description: 'Placeholder text', optional: true }, - options: { - type: 'array', - description: 'Options for select/multiselect fields', - optional: true, - }, - hidden: { type: 'boolean', description: 'Whether field is hidden', optional: true }, - isDefault: { - type: 'boolean', - description: 'Whether this is a system default field', - optional: true, - }, - }, - }, - }, - metadata: { type: 'json', description: 'Custom metadata (dynamic key-value pairs)' }, -} as const satisfies Record - export interface CalcomCreateEventTypeParams { accessToken: string title: string diff --git a/apps/sim/tools/confluence/types.ts b/apps/sim/tools/confluence/types.ts index 560ee3c76d0..20d89f93a88 100644 --- a/apps/sim/tools/confluence/types.ts +++ b/apps/sim/tools/confluence/types.ts @@ -63,24 +63,6 @@ export const DETAILED_VERSION_OUTPUT_PROPERTIES = { }, } as const satisfies Record -/** - * Complete detailed version object output definition. - */ -export const DETAILED_VERSION_OUTPUT: OutputProperty = { - type: 'object', - description: 'Detailed version information', - properties: DETAILED_VERSION_OUTPUT_PROPERTIES, -} - -/** - * Complete version object output definition. - */ -export const VERSION_OUTPUT: OutputProperty = { - type: 'object', - description: 'Version information', - properties: VERSION_OUTPUT_PROPERTIES, -} - /** * Page item properties from Confluence API v2. * Based on GET /wiki/api/v2/pages response structure. @@ -113,18 +95,6 @@ export const PAGE_OUTPUT: OutputProperty = { properties: PAGE_ITEM_PROPERTIES, } -/** - * Pages array output definition for list endpoints. - */ -export const PAGES_OUTPUT: OutputProperty = { - type: 'array', - description: 'Array of Confluence pages', - items: { - type: 'object', - properties: PAGE_ITEM_PROPERTIES, - }, -} - /** * Space description object properties. * Based on Confluence API v2 space description structure. @@ -162,15 +132,6 @@ export const SPACE_ITEM_PROPERTIES = { }, } as const satisfies Record -/** - * Complete space object output definition. - */ -export const SPACE_OUTPUT: OutputProperty = { - type: 'object', - description: 'Confluence space object', - properties: SPACE_ITEM_PROPERTIES, -} - /** * Spaces array output definition for list endpoints. */ @@ -221,16 +182,6 @@ export const CONTENT_BODY_OUTPUT_PROPERTIES = { }, } as const satisfies Record -/** - * Complete body object output definition for pages and blog posts. - */ -export const CONTENT_BODY_OUTPUT: OutputProperty = { - type: 'object', - description: 'Page or blog post body content in requested format(s)', - properties: CONTENT_BODY_OUTPUT_PROPERTIES, - optional: true, -} - /** * Comment body object properties. * Based on Confluence API v2 comment body structure. @@ -447,14 +398,6 @@ export const SEARCH_RESULTS_OUTPUT: OutputProperty = { }, } -/** - * Pagination links properties for list responses. - */ -export const PAGINATION_LINKS_PROPERTIES = { - next: { type: 'string', description: 'URL to fetch the next page of results', optional: true }, - base: { type: 'string', description: 'Base URL for the API', optional: true }, -} as const satisfies Record - /** * Common timestamp output property. */ @@ -463,14 +406,6 @@ export const TIMESTAMP_OUTPUT: OutputProperty = { description: 'ISO 8601 timestamp of the operation', } -/** - * Common page ID output property. - */ -export const PAGE_ID_OUTPUT: OutputProperty = { - type: 'string', - description: 'Confluence page ID', -} - /** * Common success status output property. */ @@ -479,22 +414,6 @@ export const SUCCESS_OUTPUT: OutputProperty = { description: 'Operation success status', } -/** - * Common deleted status output property. - */ -export const DELETED_OUTPUT: OutputProperty = { - type: 'boolean', - description: 'Deletion status', -} - -/** - * Common URL output property. - */ -export const URL_OUTPUT: OutputProperty = { - type: 'string', - description: 'URL to view in Confluence', -} - export interface ConfluenceRetrieveParams { accessToken: string pageId: string diff --git a/apps/sim/tools/context_dev/types.ts b/apps/sim/tools/context_dev/types.ts index 5bf9b823aa5..e8b580c73bd 100644 --- a/apps/sim/tools/context_dev/types.ts +++ b/apps/sim/tools/context_dev/types.ts @@ -374,15 +374,6 @@ export const BRAND_OUTPUT_PROPERTIES = { primary_language: { type: 'string', description: 'Primary language of the brand site' }, } as const -/** Output schema for the reduced brand object returned by the simplified endpoint. */ -export const SIMPLIFIED_BRAND_OUTPUT_PROPERTIES = { - domain: { type: 'string', description: 'Brand domain' }, - title: { type: 'string', description: 'Brand title' }, - colors: { type: 'json', description: 'Brand colors (hex and name)' }, - logos: { type: 'json', description: 'Brand logos with mode, colors, resolution, and type' }, - backdrops: { type: 'json', description: 'Brand backdrop images' }, -} as const - /** Output schema for a single extracted product. */ export const PRODUCT_OUTPUT_PROPERTIES = { name: { type: 'string', description: 'Product name' }, diff --git a/apps/sim/tools/convex/list_tables.ts b/apps/sim/tools/convex/list_tables.ts index 0b7db9feb47..d73edba3e49 100644 --- a/apps/sim/tools/convex/list_tables.ts +++ b/apps/sim/tools/convex/list_tables.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type { ConvexListTablesParams, ConvexListTablesResponse } from '@/tools/convex/types' import { convexApiUrl, convexAuthHeaders, parseConvexResponse } from '@/tools/convex/utils' import type { ToolConfig } from '@/tools/types' @@ -32,10 +33,7 @@ export const listTablesTool: ToolConfig { const data = await parseConvexResponse(response) - const schemas = - data !== null && typeof data === 'object' && !Array.isArray(data) - ? (data as Record) - : {} + const schemas = isRecordLike(data) ? (data as Record) : {} return { success: true, diff --git a/apps/sim/tools/docusign/types.ts b/apps/sim/tools/docusign/types.ts index e7545f3a3f4..021ee1b81ee 100644 --- a/apps/sim/tools/docusign/types.ts +++ b/apps/sim/tools/docusign/types.ts @@ -52,12 +52,6 @@ export const TEMPLATE_OUTPUT_PROPERTIES = { lastModified: { type: 'string', description: 'ISO 8601 last modified date' }, } as const satisfies Record -export const ENVELOPE_OBJECT_OUTPUT: OutputProperty = { - type: 'object', - description: 'DocuSign envelope', - properties: ENVELOPE_OUTPUT_PROPERTIES, -} - export const ENVELOPES_ARRAY_OUTPUT: OutputProperty = { type: 'array', description: 'Array of DocuSign envelopes', @@ -67,12 +61,6 @@ export const ENVELOPES_ARRAY_OUTPUT: OutputProperty = { }, } -export const RECIPIENT_OBJECT_OUTPUT: OutputProperty = { - type: 'object', - description: 'DocuSign recipient', - properties: RECIPIENT_OUTPUT_PROPERTIES, -} - export const RECIPIENTS_ARRAY_OUTPUT: OutputProperty = { type: 'array', description: 'Array of DocuSign recipients', diff --git a/apps/sim/tools/dropcontact/types.ts b/apps/sim/tools/dropcontact/types.ts index fb64e9a6a14..be385cb9793 100644 --- a/apps/sim/tools/dropcontact/types.ts +++ b/apps/sim/tools/dropcontact/types.ts @@ -15,15 +15,6 @@ export const DROPCONTACT_EMAIL_ITEM_OUTPUT_PROPERTIES = { }, } as const satisfies Record -export const DROPCONTACT_EMAILS_OUTPUT: OutputProperty = { - type: 'array', - description: 'All email addresses found for the contact', - items: { - type: 'object', - properties: DROPCONTACT_EMAIL_ITEM_OUTPUT_PROPERTIES, - }, -} - // Enrich Contact (single-contact async enrichment) export interface DropcontactEnrichContactParams extends DropcontactBaseParams { diff --git a/apps/sim/tools/dynatrace/utils.ts b/apps/sim/tools/dynatrace/utils.ts index baac23920ae..cfe853e8129 100644 --- a/apps/sim/tools/dynatrace/utils.ts +++ b/apps/sim/tools/dynatrace/utils.ts @@ -1,3 +1,4 @@ +import { toRecord, toRecordOrNull } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import type { DynatraceAttack, @@ -164,18 +165,6 @@ function toStringArray(value: unknown): string[] { return Array.isArray(value) ? (value as string[]) : [] } -function toRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} -} - -function toRecordOrNull(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null -} - /** Flattens an `EntityStub` (`{ entityId: { id, type }, name }`) into a single object. */ export function mapEntityStub(stub: unknown): DynatraceEntityStub | null { const record = toRecordOrNull(stub) diff --git a/apps/sim/tools/emailbison/utils.ts b/apps/sim/tools/emailbison/utils.ts index 07e8c3b2d71..f580db63053 100644 --- a/apps/sim/tools/emailbison/utils.ts +++ b/apps/sim/tools/emailbison/utils.ts @@ -1,4 +1,4 @@ -import { filterUndefined, isRecordLike } from '@sim/utils/object' +import { filterUndefined, isRecordLike, toRecord } from '@sim/utils/object' import type { EmailBisonBaseParams, EmailBisonCampaign, @@ -441,10 +441,6 @@ function mapReplyAttachment(value: unknown): EmailBisonReplyAttachment { } } -function toRecord(value: unknown): Record { - return isRecordLike(value) ? value : {} -} - function toArray(value: unknown): unknown[] { return Array.isArray(value) ? value : [] } diff --git a/apps/sim/tools/file/parser.ts b/apps/sim/tools/file/parser.ts index 569cbf1ada0..44efaaf1b53 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import type { UserFile } from '@/executor/types' import type { @@ -15,11 +16,8 @@ import type { ToolConfig } from '@/tools/types' const logger = createLogger('FileParserTool') -const isRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' - const isUserFile = (value: unknown): value is UserFile => - isRecord(value) && + isRecordLike(value) && typeof value.id === 'string' && typeof value.name === 'string' && typeof value.url === 'string' && @@ -28,7 +26,7 @@ const isUserFile = (value: unknown): value is UserFile => typeof value.key === 'string' const isFileParseResult = (value: unknown): value is FileParseResult => - isRecord(value) && + isRecordLike(value) && typeof value.content === 'string' && typeof value.fileType === 'string' && typeof value.size === 'number' && @@ -50,7 +48,7 @@ const normalizeHeaders = (headers: FileParserInput['headers']): Record { - if (isRecord(value) && isFileParseResult(value.output)) { + if (isRecordLike(value) && isFileParseResult(value.output)) { return value.output } @@ -58,9 +56,9 @@ const normalizeFileParseResult = (value: unknown): FileParseResult => { return value } - const record = isRecord(value) ? value : {} + const record = isRecordLike(value) ? value : {} const file = isUserFile(record.file) ? record.file : undefined - const metadata = isRecord(record.metadata) ? record.metadata : undefined + const metadata = isRecordLike(record.metadata) ? record.metadata : undefined const fallback: FileParseResult = { content: typeof record.content === 'string' ? record.content : '', fileType: typeof record.fileType === 'string' ? record.fileType : '', @@ -90,15 +88,15 @@ const parseFileParserResponse = async (response: Response): Promise isRecord(fileResult) && fileResult.success === false + (fileResult) => isRecordLike(fileResult) && fileResult.success === false ) if (failedResults.length === result.results.length) { const firstError = failedResults.find( - (fileResult) => isRecord(fileResult) && typeof fileResult.error === 'string' + (fileResult) => isRecordLike(fileResult) && typeof fileResult.error === 'string' ) return { success: false, @@ -107,7 +105,7 @@ const parseFileParserResponse = async (response: Response): Promise !(isRecord(fileResult) && fileResult.success === false)) + .filter((fileResult) => !(isRecordLike(fileResult) && fileResult.success === false)) .map((fileResult) => normalizeFileParseResult(fileResult)) const processedFiles = fileResults.flatMap((file) => (file.file ? [file.file] : [])) @@ -144,7 +142,7 @@ const parseFileParserResponse = async (response: Response): Promise = { const determinedFileType: string | undefined = params.fileType const resolveFilePath = (fileInput: unknown): string | null => { - if (!isRecord(fileInput)) return null + if (!isRecordLike(fileInput)) return null if (typeof fileInput.path === 'string') { return fileInput.path diff --git a/apps/sim/tools/firecrawl/types.ts b/apps/sim/tools/firecrawl/types.ts index 9fed6555bb5..6f39710d2d4 100644 --- a/apps/sim/tools/firecrawl/types.ts +++ b/apps/sim/tools/firecrawl/types.ts @@ -100,28 +100,6 @@ export const SEARCH_METADATA_OUTPUT: OutputProperty = { properties: SEARCH_METADATA_OUTPUT_PROPERTIES, } -/** - * Output properties for scrape tool response - * Based on POST /v2/scrape response data object - */ -export const SCRAPE_OUTPUT_PROPERTIES = { - markdown: { type: 'string', description: 'Page content converted to clean markdown format' }, - html: { type: 'string', description: 'Processed HTML content of the page', optional: true }, - rawHtml: { type: 'string', description: 'Unprocessed raw HTML content', optional: true }, - links: { - type: 'array', - description: 'Array of links found on the page', - optional: true, - items: { type: 'string', description: 'URL found on the page' }, - }, - screenshot: { - type: 'string', - description: 'Base64-encoded screenshot or URL (expires after 24 hours)', - optional: true, - }, - metadata: PAGE_METADATA_OUTPUT, -} as const satisfies Record - /** * Output properties for crawled page items * Based on GET /v2/crawl/{id} response data[] array items @@ -144,31 +122,6 @@ export const CRAWLED_PAGE_OUTPUT_PROPERTIES = { metadata: CRAWL_METADATA_OUTPUT, } as const satisfies Record -/** - * Complete crawled page output definition - */ -export const CRAWLED_PAGE_OUTPUT: OutputProperty = { - type: 'object', - description: 'Crawled page data with content and metadata', - properties: CRAWLED_PAGE_OUTPUT_PROPERTIES, -} - -/** - * Output properties for crawl tool response - * Based on GET /v2/crawl/{id} response (completed status) - */ -export const CRAWL_OUTPUT_PROPERTIES = { - pages: { - type: 'array', - description: 'Array of crawled pages with their content and metadata', - items: { - type: 'object', - properties: CRAWLED_PAGE_OUTPUT_PROPERTIES, - }, - }, - total: { type: 'number', description: 'Total number of pages found during crawl' }, -} as const satisfies Record - /** * Output properties for search result items * Based on POST /v2/search response data[] array items @@ -219,73 +172,6 @@ export const SEARCH_RESULT_OUTPUT: OutputProperty = { properties: SEARCH_RESULT_OUTPUT_PROPERTIES, } -/** - * Output properties for search tool response - * Based on POST /v2/search response - */ -export const SEARCH_OUTPUT_PROPERTIES = { - data: { - type: 'array', - description: 'Array of search results with scraped content and metadata', - items: { - type: 'object', - properties: SEARCH_RESULT_OUTPUT_PROPERTIES, - }, - }, -} as const satisfies Record - -/** - * Output properties for map tool response - * Based on POST /v2/map response - */ -export const MAP_OUTPUT_PROPERTIES = { - success: { type: 'boolean', description: 'Whether the mapping operation completed successfully' }, - links: { - type: 'array', - description: 'Array of discovered URLs from the website', - items: { type: 'string', description: 'Discovered URL' }, - }, -} as const satisfies Record - -/** - * Output properties for extract tool response - * Based on GET /v2/extract/{id} response (completed status) - */ -export const EXTRACT_OUTPUT_PROPERTIES = { - success: { type: 'boolean', description: 'Whether the extraction completed successfully' }, - data: { - type: 'object', - description: 'Extracted structured data according to the provided schema or prompt', - }, -} as const satisfies Record - -/** - * Output properties for agent tool response - * Based on GET /v2/agent/{id} response (completed status) - */ -export const AGENT_OUTPUT_PROPERTIES = { - success: { type: 'boolean', description: 'Whether the agent task completed successfully' }, - status: { - type: 'string', - description: 'Current status of the agent job (processing, completed, failed)', - }, - data: { - type: 'object', - description: 'Extracted data from the agent based on the prompt and schema', - }, - expiresAt: { - type: 'string', - description: 'ISO timestamp when the results expire (24 hours after completion)', - optional: true, - }, - sources: { - type: 'array', - description: 'Array of source URLs visited and used by the agent', - optional: true, - items: { type: 'string', description: 'Source URL' }, - }, -} as const satisfies Record - // Common types interface LocationConfig { country?: string diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index a39962f6bb3..2b9301b7114 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_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","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_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_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_dashboard","grafana_delete_folder","grafana_get_alert_rule","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_update_alert_rule","grafana_update_annotation","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","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_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"]' ) export default toolIds diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index a7073f05ae3..f31b7ba2b19 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_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: , , , ,