- {/* Title at top */}
-
{title}
+
+
+
+
- {/* Footer: icon left, domain right */}
-
-
-
docs.sim.ai
+
+ {titleLines.map((line, index) => (
+ {line}
+ ))}
,
{
width: 1200,
- height: 630,
+ height: 675,
fonts: [
{
- name: 'Geist',
+ name: 'Season',
data: fontData,
style: 'normal',
+ weight: 600,
},
],
}
diff --git a/apps/docs/app/favicon.ico b/apps/docs/app/favicon.ico
index 9aa82bcf046..b88d2804414 100644
Binary files a/apps/docs/app/favicon.ico and b/apps/docs/app/favicon.ico differ
diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css
index cbfd2ef2639..09ffa108b86 100644
--- a/apps/docs/app/global.css
+++ b/apps/docs/app/global.css
@@ -296,12 +296,20 @@ aside#nd-sidebar [data-radix-scroll-area-viewport] {
min-height: var(--fd-docs-height) !important;
}
- /* Sidebar divider line — fixed to viewport, full height from navbar to bottom */
+ /* Sidebar divider line — sticky within the docs layout box, so it ends where
+ the layout does instead of bleeding into (or past) the footer below it.
+ #nd-docs-layout is a CSS grid (see fumadocs' Container slot); a sticky
+ pseudo-element stays in normal flow, so without an explicit grid-area it
+ gets auto-placed into a real content cell and skews that cell's sizing.
+ Spanning the full grid keeps it purely decorative/overlaid instead. */
#nd-docs-layout::before {
content: "";
- position: fixed;
+ display: block;
+ position: sticky;
+ grid-row: 1 / -1;
+ grid-column: 1 / -1;
top: 92px; /* below navbar */
- bottom: 0;
+ height: calc(100dvh - 92px);
left: calc(var(--sidebar-offset) + var(--fd-sidebar-width));
width: 1px;
background-color: var(--surface-active);
diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx
index 3b83d2aea64..249ef2b0de3 100644
--- a/apps/docs/app/layout.tsx
+++ b/apps/docs/app/layout.tsx
@@ -72,7 +72,7 @@ export const metadata = {
{
url: `${DOCS_BASE_URL}/api/og?title=Sim%20Documentation`,
width: 1200,
- height: 630,
+ height: 675,
alt: 'Sim Documentation',
},
],
diff --git a/apps/docs/components/footer/footer.tsx b/apps/docs/components/footer/footer.tsx
new file mode 100644
index 00000000000..fb8824b6806
--- /dev/null
+++ b/apps/docs/components/footer/footer.tsx
@@ -0,0 +1,160 @@
+import Link from 'next/link'
+import { SimWordmark } from '@/components/ui/sim-logo'
+import { SIM_SITE_URL } from '@/lib/urls'
+
+/**
+ * Docs footer - the same site link directory as the main app's landing
+ * footer (`apps/sim/app/(landing)/components/footer`), ported here so both
+ * apps share one consistent footer. Links that live on sim.ai are absolute
+ * (docs.sim.ai is a different origin); links that live on docs.sim.ai itself
+ * (Academy, API Reference, blocks, integrations guides, …) stay relative.
+ */
+
+const LINK_CLASS =
+ 'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
+
+interface FooterItem {
+ label: string
+ href: string
+ external?: boolean
+}
+
+const PRODUCT_LINKS: FooterItem[] = [
+ { label: 'Enterprise', href: `${SIM_SITE_URL}/enterprise`, external: true },
+ { label: 'Mothership', href: '/mothership' },
+ { label: 'Workflows', href: '/introduction' },
+ { label: 'Knowledge Base', href: '/knowledgebase' },
+ { label: 'Tables', href: '/tables' },
+ { label: 'MCP', href: '/agents/mcp' },
+ { label: 'API', href: '/api-reference/getting-started' },
+ { label: 'Self Hosting', href: '/platform/self-hosting' },
+ { label: 'Status', href: 'https://status.sim.ai', external: true },
+]
+
+const RESOURCES_LINKS: FooterItem[] = [
+ { label: 'Blog', href: `${SIM_SITE_URL}/blog`, external: true },
+ { label: 'Academy', href: '/academy' },
+ { label: 'Compare', href: `${SIM_SITE_URL}/comparison`, external: true },
+ { label: 'Careers', href: `${SIM_SITE_URL}/careers`, external: true },
+ { label: 'Changelog', href: `${SIM_SITE_URL}/changelog`, external: true },
+ { label: 'Contact', href: `${SIM_SITE_URL}/contact`, external: true },
+]
+
+/** Top model providers — mirrors the landing footer's top 8 catalog providers. */
+const MODEL_LINKS: FooterItem[] = [
+ { label: 'All Models', href: `${SIM_SITE_URL}/models`, external: true },
+ { label: 'OpenAI', href: `${SIM_SITE_URL}/models/openai`, external: true },
+ { label: 'Anthropic', href: `${SIM_SITE_URL}/models/anthropic`, external: true },
+ { label: 'Google', href: `${SIM_SITE_URL}/models/google`, external: true },
+ { label: 'DeepSeek', href: `${SIM_SITE_URL}/models/deepseek`, external: true },
+ { label: 'xAI', href: `${SIM_SITE_URL}/models/xai`, external: true },
+ { label: 'Cerebras', href: `${SIM_SITE_URL}/models/cerebras`, external: true },
+ { label: 'Groq', href: `${SIM_SITE_URL}/models/groq`, external: true },
+ { label: 'Sakana AI', href: `${SIM_SITE_URL}/models/sakana`, external: true },
+]
+
+const BLOCK_LINKS: FooterItem[] = [
+ { label: 'Agent', href: '/workflows/blocks/agent' },
+ { label: 'Router', href: '/workflows/blocks/router' },
+ { label: 'Function', href: '/workflows/blocks/function' },
+ { label: 'Condition', href: '/workflows/blocks/condition' },
+ { label: 'API Block', href: '/workflows/blocks/api' },
+ { label: 'Workflow', href: '/workflows/blocks/workflow' },
+ { label: 'Parallel', href: '/workflows/blocks/parallel' },
+ { label: 'Guardrails', href: '/workflows/blocks/guardrails' },
+ { label: 'Evaluator', href: '/workflows/blocks/evaluator' },
+ { label: 'Loop', href: '/workflows/blocks/loop' },
+]
+
+const INTEGRATION_LINKS: FooterItem[] = [
+ { label: 'All Integrations', href: `${SIM_SITE_URL}/integrations`, external: true },
+ { label: 'Slack', href: '/integrations/slack' },
+ { label: 'GitHub', href: '/integrations/github' },
+ { label: 'Gmail', href: '/integrations/gmail' },
+ { label: 'Notion', href: '/integrations/notion' },
+ { label: 'Salesforce', href: '/integrations/salesforce' },
+ { label: 'Jira', href: '/integrations/jira' },
+ { label: 'Linear', href: '/integrations/linear' },
+ { label: 'Supabase', href: '/integrations/supabase' },
+ { label: 'Stripe', href: '/integrations/stripe' },
+]
+
+const SOCIAL_LINKS: FooterItem[] = [
+ { label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
+ {
+ label: 'LinkedIn',
+ href: 'https://www.linkedin.com/company/simstudioai/',
+ external: true,
+ },
+ { label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
+ {
+ label: 'GitHub',
+ href: 'https://github.com/simstudioai/sim',
+ external: true,
+ },
+]
+
+const LEGAL_LINKS: FooterItem[] = [
+ { label: 'Terms of Service', href: `${SIM_SITE_URL}/terms`, external: true },
+ { label: 'Privacy Policy', href: `${SIM_SITE_URL}/privacy`, external: true },
+]
+
+function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
+ return (
+
+
{title}
+
+ {items.map(({ label, href, external }) =>
+ external ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ )
+ )}
+
+
+ )
+}
+
+export function Footer() {
+ return (
+
+ )
+}
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx
index fe869a5b77e..4c58dddc322 100644
--- a/apps/docs/components/icons.tsx
+++ b/apps/docs/components/icons.tsx
@@ -1274,6 +1274,31 @@ export function xAIIcon(props: SVGProps
) {
)
}
+export function TikTokIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+ )
+}
+
export function xIcon(props: SVGProps) {
return (
diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx
index 186210dd0db..4e86e39f6fd 100644
--- a/apps/docs/components/navbar/navbar.tsx
+++ b/apps/docs/components/navbar/navbar.tsx
@@ -5,7 +5,7 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { LanguageDropdown } from '@/components/ui/language-dropdown'
import { SearchTrigger } from '@/components/ui/search-trigger'
-import { SimLogoFull } from '@/components/ui/sim-logo'
+import { SimWordmark } from '@/components/ui/sim-logo'
import { ThemeToggle } from '@/components/ui/theme-toggle'
import { cn } from '@/lib/utils'
@@ -44,8 +44,8 @@ export function Navbar() {
paddingRight: 'calc(var(--toc-offset) + var(--nav-inset))',
}}
>
-
-
+
+
diff --git a/apps/docs/components/ui/block-info-card.tsx b/apps/docs/components/ui/block-info-card.tsx
index f5085c6da17..39f3c25fbd2 100644
--- a/apps/docs/components/ui/block-info-card.tsx
+++ b/apps/docs/components/ui/block-info-card.tsx
@@ -9,12 +9,40 @@ interface BlockInfoCardProps {
icon?: React.ComponentType<{ className?: string }>
}
+/**
+ * Brightness above which a tile background is "clearly light" and a white
+ * foreground icon would wash out. Mirrors apps/sim's LIGHT_TILE_THRESHOLD
+ * (blocks/icon-color.ts) so monochrome `currentColor` icons (e.g. Daytona,
+ * Notion) stay legible on white/pale tiles instead of white-on-white.
+ */
+const LIGHT_TILE_THRESHOLD = 0.75
+
+function isLightTileColor(color: string): boolean {
+ const hex = color.trim().replace('#', '').toLowerCase()
+ let r: number
+ let g: number
+ let b: number
+ if (/^[0-9a-f]{3}$/.test(hex)) {
+ r = Number.parseInt(hex[0] + hex[0], 16)
+ g = Number.parseInt(hex[1] + hex[1], 16)
+ b = Number.parseInt(hex[2] + hex[2], 16)
+ } else if (/^[0-9a-f]{6}$/.test(hex)) {
+ r = Number.parseInt(hex.slice(0, 2), 16)
+ g = Number.parseInt(hex.slice(2, 4), 16)
+ b = Number.parseInt(hex.slice(4, 6), 16)
+ } else {
+ return false
+ }
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > LIGHT_TILE_THRESHOLD
+}
+
export function BlockInfoCard({
type,
color,
icon: IconComponent,
}: BlockInfoCardProps): React.ReactNode {
const ResolvedIcon = IconComponent || blockTypeToIconMap[type] || null
+ const iconColorClass = isLightTileColor(color) ? 'text-black' : 'text-white'
return (
{ResolvedIcon ? (
-
+
) : (
-
{type.substring(0, 2)}
+
+ {type.substring(0, 2)}
+
)}
)
diff --git a/apps/docs/components/ui/sim-logo.tsx b/apps/docs/components/ui/sim-logo.tsx
index 1dd70657d5a..f505e778c91 100644
--- a/apps/docs/components/ui/sim-logo.tsx
+++ b/apps/docs/components/ui/sim-logo.tsx
@@ -1,17 +1,93 @@
'use client'
+import { useId } from 'react'
import { cn } from '@/lib/utils'
interface SimLogoProps {
className?: string
}
+/**
+ * Inline "sim" wordmark, no separate icon mark — same paths as the landing
+ * footer's `SimWordmark` (`apps/sim/app/(landing)/components/navbar/components/sim-wordmark`),
+ * ported here so the docs footer can match it exactly. Filled with
+ * `var(--text-body)` so it reads as one solid ink matching surrounding text.
+ */
+export function SimWordmark({ className }: SimLogoProps) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Icon-only Sim mark, no wordmark text. Same brandbook icon geometry as
+ * {@link SimLogoFull}'s icon, at its native square viewBox.
+ */
+export function SimLogoIcon({ className }: SimLogoProps) {
+ const gradientId = `sim-logo-icon-gradient-${useId()}`
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
/**
* Full Sim logo with icon and "Sim" text.
* Uses the same SVG source as the landing page navbar for exact visual alignment.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogoFull({ className }: SimLogoProps) {
+ const gradientId = `sim-logo-full-gradient-${useId()}`
+
return (
diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx
index 6a3c01ae21e..15bfeb5c049 100644
--- a/apps/docs/components/workflow-preview/format-references.tsx
+++ b/apps/docs/components/workflow-preview/format-references.tsx
@@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] {
const isReference =
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
return isReference ? (
- // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
{part}
) : (
- // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
{part}
)
})
diff --git a/apps/docs/content/docs/en/integrations/brandfetch.mdx b/apps/docs/content/docs/en/integrations/brandfetch.mdx
index bd4d7ee6501..ef9e2b45a63 100644
--- a/apps/docs/content/docs/en/integrations/brandfetch.mdx
+++ b/apps/docs/content/docs/en/integrations/brandfetch.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#000000"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Brandfetch](https://brandfetch.com/) is a brand data API that provides logos, colors, fonts, and firmographic information for companies, looked up by domain, stock ticker, ISIN, or crypto symbol.
+
+With Brandfetch, you can:
+
+- **Retrieve brand assets**: Pull logos, icons, and symbols in multiple formats and themes for a given brand
+- **Get brand style data**: Access brand colors and fonts, including type, theme, and origin
+- **Look up company info**: Retrieve firmographic data such as employees, location, and industries, along with a data quality score
+- **Search for brands**: Find brands by name and get back their domains and icons
+
+In Sim, the Brandfetch integration allows your agents to look up a brand's logos, colors, fonts, links, and company data by domain, ticker, ISIN, or crypto symbol, or search for a brand by name to find its domain and icon. This lets agents automate tasks like enriching company records with brand assets, verifying brand identity details, or sourcing logos and color palettes for design and reporting workflows.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Brandfetch into your workflow. Retrieve brand logos, colors, fonts, and company data by domain, ticker, or name search.
diff --git a/apps/docs/content/docs/en/integrations/brightdata.mdx b/apps/docs/content/docs/en/integrations/brightdata.mdx
index 2cbf754f4eb..bbdac221c94 100644
--- a/apps/docs/content/docs/en/integrations/brightdata.mdx
+++ b/apps/docs/content/docs/en/integrations/brightdata.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Bright Data](https://brightdata.com/) is a web data platform that provides tools for scraping, searching, and extracting structured data from websites at scale, bypassing anti-bot protections, CAPTCHAs, and IP blocks along the way.
+
+With Bright Data, you can:
+
+- **Scrape any URL**: Fetch page content through Web Unlocker, with automatic anti-bot bypassing and optional Markdown conversion
+- **Search the web**: Query Google, Bing, DuckDuckGo, or Yandex through the SERP API and get structured results
+- **Discover content by intent**: Run AI-powered searches that rank results by relevance to a stated goal, with optional cleaned page content for RAG
+- **Run pre-built scrapers**: Trigger one of 660+ dataset scrapers for platforms like Amazon, LinkedIn, and Instagram, either synchronously for quick jobs or asynchronously via snapshots for larger ones
+
+In Sim, the Bright Data integration allows your agents to scrape web pages, run search-engine queries, discover and rank content by intent, and trigger structured data extraction jobs against sites like Amazon and LinkedIn. Agents can check the status of async scraping jobs, download completed snapshot results, and cancel jobs still in progress—making it possible to build workflows that gather, monitor, and retrieve web data end to end.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Bright Data into the workflow. Scrape any URL with Web Unlocker, search Google and other engines with SERP API, discover web content ranked by intent, or trigger pre-built scrapers for structured data extraction.
diff --git a/apps/docs/content/docs/en/integrations/codepipeline.mdx b/apps/docs/content/docs/en/integrations/codepipeline.mdx
index 155de0cb7d6..0fe8d3350e7 100644
--- a/apps/docs/content/docs/en/integrations/codepipeline.mdx
+++ b/apps/docs/content/docs/en/integrations/codepipeline.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="linear-gradient(45deg, #2E27AD 0%, #527FFF 100%)"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[AWS CodePipeline](https://aws.amazon.com/codepipeline/) is a continuous delivery service that automates the build, test, and deploy phases of software release pipelines. It orchestrates pipeline stages and actions, tracking execution status, source revisions, and approval steps across a release process.
+
+With this integration, you can:
+
+- **Inspect pipelines**: List pipelines, retrieve a pipeline's stage and action structure, and check its current execution state
+- **Track executions**: Get details on a specific execution or list recent executions and action-level history, including status, triggers, and source revisions
+- **Control pipeline flow**: Start or stop executions, retry failed stages, and approve or reject pending manual approval actions
+- **Manage stage transitions**: Disable or re-enable artifact transitions into or out of a stage to freeze or resume a pipeline
+
+In Sim, the AWS CodePipeline integration allows your agents to monitor and control release pipelines programmatically — listing pipelines and their structure, checking execution and action status, starting or stopping executions, retrying failed stages, approving or rejecting manual approvals, and enabling or disabling stage transitions. This lets your agents build deployment automation and approval workflows that respond to pipeline state in real time.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate AWS CodePipeline into workflows. Start, stop, and monitor pipeline executions, retry failed stages, and approve or reject manual approval actions. Requires AWS access key and secret access key.
diff --git a/apps/docs/content/docs/en/integrations/context_dev.mdx b/apps/docs/content/docs/en/integrations/context_dev.mdx
index 3cd1687a0fc..6f70c82773a 100644
--- a/apps/docs/content/docs/en/integrations/context_dev.mdx
+++ b/apps/docs/content/docs/en/integrations/context_dev.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#ffffff"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Context.dev](https://context.dev/) is a web data API that scrapes, crawls, searches, and extracts data from the web, and resolves brand and company data from a domain, name, email, ticker, or transaction descriptor.
+
+With Context.dev, you can:
+
+- **Scrape and crawl pages**: Convert URLs to clean markdown or HTML, capture screenshots, discover images, crawl entire sites, and map sitemaps
+- **Search the web**: Run natural language searches with domain filters and optional markdown scraping of results
+- **Extract structured data**: Pull data matching a JSON schema, or detect and extract product details and catalogs from a page or domain
+- **Analyze brand and design data**: Extract a domain's fonts and design system, classify a brand into NAICS/SIC industry codes, and resolve brand data (logos, colors, socials, address) from a domain, company name, email, ticker, or transaction descriptor
+
+In Sim, the Context.dev integration allows your agents to scrape and crawl web pages into markdown or HTML, capture screenshots, search the web, extract structured data and product information, pull a site's fonts and style guide, classify a brand's industry, and look up brand assets and company details by domain, name, email, ticker, or transaction — all through a single set of API calls in your workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Context.dev into the workflow. Scrape pages to markdown or HTML, capture screenshots, list images, crawl entire sites, map sitemaps, search the web, extract structured data and products, pull design systems, classify industries, and retrieve brand assets by domain, name, email, ticker, or transaction — all from one API.
@@ -570,31 +584,6 @@ Retrieve brand data for a public company by its stock ticker symbol.
| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) |
| ↳ `primary_language` | string | Primary language of the brand site |
-### `context_dev_get_brand_simplified`
-
-Retrieve essential brand data for a domain: title, colors, logos, and backdrops.
-
-#### Input
-
-| Parameter | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `domain` | string | Yes | The domain to retrieve simplified brand data for \(e.g., "airbnb.com"\) |
-| `maxAgeMs` | number | No | Cache max age in milliseconds \(86400000-31536000000, default: 7776000000\) |
-| `timeoutMS` | number | No | Request timeout in milliseconds \(1000-300000\) |
-| `apiKey` | string | Yes | Context.dev API key |
-
-#### Output
-
-| Parameter | Type | Description |
-| --------- | ---- | ----------- |
-| `status` | string | Retrieval status |
-| `brand` | object | Simplified brand data \(domain, title, colors, logos, backdrops\) |
-| ↳ `domain` | string | Brand domain |
-| ↳ `title` | string | Brand title |
-| ↳ `colors` | json | Brand colors \(hex and name\) |
-| ↳ `logos` | json | Brand logos with mode, colors, resolution, and type |
-| ↳ `backdrops` | json | Brand backdrop images |
-
### `context_dev_identify_transaction`
Identify the brand behind a raw bank/card transaction descriptor and return its brand data.
@@ -637,44 +626,4 @@ Identify the brand behind a raw bank/card transaction descriptor and return its
| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) |
| ↳ `primary_language` | string | Primary language of the brand site |
-### `context_dev_prefetch_domain`
-
-Queue a domain for brand-data prefetching to reduce latency on later requests (subscribers; 0 credits).
-
-#### Input
-
-| Parameter | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `domain` | string | Yes | The domain to prefetch brand data for \(e.g., "example.com"\) |
-| `timeoutMS` | number | No | Request timeout in milliseconds \(1000-300000\) |
-| `apiKey` | string | Yes | Context.dev API key |
-
-#### Output
-
-| Parameter | Type | Description |
-| --------- | ---- | ----------- |
-| `status` | string | Prefetch status |
-| `message` | string | Human-readable prefetch result message |
-| `domain` | string | The domain queued for prefetching |
-
-### `context_dev_prefetch_by_email`
-
-Queue an email's domain for brand-data prefetching to reduce later latency (subscribers; 0 credits). Free/disposable emails are rejected.
-
-#### Input
-
-| Parameter | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `email` | string | Yes | Work email address whose domain should be prefetched \(free providers rejected\) |
-| `timeoutMS` | number | No | Request timeout in milliseconds \(1000-300000\) |
-| `apiKey` | string | Yes | Context.dev API key |
-
-#### Output
-
-| Parameter | Type | Description |
-| --------- | ---- | ----------- |
-| `status` | string | Prefetch status |
-| `message` | string | Human-readable prefetch result message |
-| `domain` | string | The domain queued for prefetching |
-
diff --git a/apps/docs/content/docs/en/integrations/crowdstrike.mdx b/apps/docs/content/docs/en/integrations/crowdstrike.mdx
index 92abd6aa84d..97b1e939963 100644
--- a/apps/docs/content/docs/en/integrations/crowdstrike.mdx
+++ b/apps/docs/content/docs/en/integrations/crowdstrike.mdx
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#E01F3D"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[CrowdStrike](https://www.crowdstrike.com/) is a cybersecurity platform providing endpoint protection, threat intelligence, and identity security through its Falcon suite. This integration connects to the Falcon Identity Protection API to query sensor data.
+
+With this integration, you can:
+
+- **Search sensors**: Query CrowdStrike identity protection sensors by hostname, IP, or related fields using Falcon Query Language filters
+- **Fetch sensor details**: Retrieve documented sensor details, including protection status, policy assignments, and protocol configuration, for one or more device IDs
+- **Run sensor aggregates**: Execute documented JSON aggregate queries to summarize sensor data into buckets and metrics
+
+In Sim, the CrowdStrike integration allows your agents to search identity protection sensors, look up detailed sensor records by device ID, and run aggregate queries against sensor data—all authenticated with a Falcon API client ID and secret against a specified cloud region. This lets agents surface device protection status, policy coverage, and protocol configuration (Kerberos, LDAP, NTLM, RDP, SMB) as part of security monitoring and reporting workflows.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries.
diff --git a/apps/docs/content/docs/en/integrations/deployments.mdx b/apps/docs/content/docs/en/integrations/deployments.mdx
index 17251cc0269..6b13fd2e622 100644
--- a/apps/docs/content/docs/en/integrations/deployments.mdx
+++ b/apps/docs/content/docs/en/integrations/deployments.mdx
@@ -10,6 +10,17 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#0C0C0C"
/>
+{/* MANUAL-CONTENT-START:intro */}
+Deployments is Sim's built-in system for taking a workflow's draft state live for API execution and managing its version history. Every deploy creates a new version, and past versions stay available for review or rollback.
+
+- **Deploy and undeploy**: Push the current draft live as a new version, or take a live workflow offline and remove its triggers, webhooks, and schedules
+- **Promote or roll back**: Make any past version the live one without creating a new version, including re-deploying an undeployed workflow at a known-good version
+- **Inspect version history**: List every deployment version with its metadata, or fetch the full workflow state snapshot for a specific version
+
+In Sim, the Deployments block allows your agents to deploy and undeploy workflows, promote a specific version to live for rollbacks, list all deployment versions of a workflow, and retrieve the deployed state snapshot of any past version—all programmatically as part of another workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Deploy, undeploy, and roll back workflows in the current workspace. Promote a previous deployment version to live, list every version, or fetch the deployed workflow state for a specific version.
diff --git a/apps/docs/content/docs/en/integrations/downdetector.mdx b/apps/docs/content/docs/en/integrations/downdetector.mdx
index 25bf65df90a..6aa3768cd64 100644
--- a/apps/docs/content/docs/en/integrations/downdetector.mdx
+++ b/apps/docs/content/docs/en/integrations/downdetector.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Downdetector](https://downdetector.com/) is an outage-tracking service that aggregates user-submitted reports to detect and visualize real-time service disruptions for thousands of companies. The Downdetector Enterprise API exposes this data programmatically for monitoring and alerting.
+
+With Downdetector, you can:
+
+- **Search and identify companies**: Look up monitored companies by name, slug, country, or category to get their ids and status page URLs
+- **Check current status and trends**: Read a company's cached status, 24h report statistics, and baseline to judge whether current activity is abnormal
+- **Inspect problem indicators**: See which specific issues (e.g. "Login", "App crashing") are being reported and in what proportion
+- **Track incidents and events**: Pull incident timelines, published events, and attribution data (internal vs. external cause, user impact) for outages
+
+In Sim, the Downdetector integration allows your agents to search for monitored companies, check their current status and baseline reports, retrieve near-real-time report counts and problem indicators, and pull incident, event, and attribution data — all programmatically through API calls. This enables your agents to power outage alerts, monitoring dashboards, and automated incident summaries that stay current with service disruptions across the companies and regions Downdetector tracks.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Track real-time service outages with the Downdetector Enterprise API. Search monitored companies, read their current status and report trends, inspect problem indicators, and pull incident timelines to power outage alerts and dashboards. Requires a Downdetector Enterprise API plan.
diff --git a/apps/docs/content/docs/en/integrations/emailbison.mdx b/apps/docs/content/docs/en/integrations/emailbison.mdx
index 8a3050d5d50..75b90e492f0 100644
--- a/apps/docs/content/docs/en/integrations/emailbison.mdx
+++ b/apps/docs/content/docs/en/integrations/emailbison.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FB7A22"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Email Bison](https://emailbison.com/) is a cold email outreach and deliverability platform for managing leads, sending sequences, and tracking campaign performance.
+
+With Email Bison, you can:
+
+- **Manage leads**: Create, update, retrieve, and tag leads, and track their engagement across campaigns
+- **Run campaigns**: Create campaigns, attach leads, and pause, resume, or archive them as needed
+- **Track replies**: List and filter incoming replies by status, folder, campaign, sender, lead, or tag
+- **React to events**: Trigger workflows on first email sent, interested replies, unsubscribes, bounces, opens, and sender account changes
+
+In Sim, the Email Bison integration allows your agents to create and update leads, list and filter leads/campaigns/replies, attach leads to campaigns, manage campaign status, and organize leads with tags — all programmatically through API calls. Combined with Email Bison triggers, agents can also react in real time to events like a contact being emailed, replying, marking interest, unsubscribing, or an email bouncing, making it possible to automate outbound outreach workflows end to end.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Email Bison into workflows. Create and update leads, manage campaigns, attach leads to campaigns, list replies, and organize leads with tags.
diff --git a/apps/docs/content/docs/en/integrations/enrichment.mdx b/apps/docs/content/docs/en/integrations/enrichment.mdx
index 702ceb50cf8..664eac18132 100644
--- a/apps/docs/content/docs/en/integrations/enrichment.mdx
+++ b/apps/docs/content/docs/en/integrations/enrichment.mdx
@@ -10,6 +10,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#9333EA"
/>
+{/* MANUAL-CONTENT-START:intro */}
+Data Enrichment is a built-in Sim block that looks up missing data — work email, phone number, company domain, company info, and more — from fields you map in from your workflow. It runs a registered enrichment (like "Work Email" or "Phone Number") against the same provider cascade used by table enrichments, trying multiple providers until one returns a match.
+
+In Sim, the enrichment block allows your agents to fill in gaps in a record by supplying an enrichment id and a map of input values, then reading back whether a match was found and which provider (e.g. Hunter, People Data Labs) supplied the result. This lets workflows enrich leads or contacts inline — for example, taking a name and company and resolving a work email — without agents needing to call each provider's API directly.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Run a Sim enrichment to look up data — work email, phone number, company domain, company info, and more — from the fields you map in. Uses the same provider cascade as table enrichments.
diff --git a/apps/docs/content/docs/en/integrations/extend.mdx b/apps/docs/content/docs/en/integrations/extend.mdx
index d0a31696b13..234a361a039 100644
--- a/apps/docs/content/docs/en/integrations/extend.mdx
+++ b/apps/docs/content/docs/en/integrations/extend.mdx
@@ -10,6 +10,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#000000"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Extend](https://www.extend.ai/) is a document processing platform that uses AI to parse and extract structured content from documents, turning unstructured files into usable data.
+
+In Sim, the Extend integration allows your agents to submit a document for parsing and receive back the processed content as structured chunks and block-level elements, along with the page count and credits consumed by the run. This enables workflows to pull structured data out of documents—such as PDFs or other file references—for downstream steps to consume, without manually building document-parsing logic.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Extend AI into the workflow. Parse and extract structured content from documents or file references.
diff --git a/apps/docs/content/docs/en/integrations/file.mdx b/apps/docs/content/docs/en/integrations/file.mdx
index f4c7da583c9..f0710cb9bf3 100644
--- a/apps/docs/content/docs/en/integrations/file.mdx
+++ b/apps/docs/content/docs/en/integrations/file.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#F5F5F5"
/>
+{/* MANUAL-CONTENT-START:intro */}
+The File block is a built-in Sim block for working with files stored in the workspace, or fetched from external URLs. It handles reading, writing, appending, compressing, decompressing, and sharing files as part of a workflow.
+
+With the File block, you can:
+
+- **Read and extract content**: Load workspace file objects and extract their text content
+- **Fetch from URLs**: Retrieve and parse files from external URLs with custom headers
+- **Write and append**: Create new workspace files or append content to existing ones
+- **Compress and decompress**: Bundle files into a .zip archive or extract an archive into the workspace
+- **Manage sharing**: Enable or disable a public share link for a file, with public, password, email, or SSO access modes
+
+In Sim, the File block allows your agents to read and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. This makes it possible to move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link.
+{/* MANUAL-CONTENT-END */}
+
## Usage Instructions
Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file.
diff --git a/apps/docs/content/docs/en/integrations/google_contacts.mdx b/apps/docs/content/docs/en/integrations/google_contacts.mdx
index 3a98b2aea67..242143a7f7b 100644
--- a/apps/docs/content/docs/en/integrations/google_contacts.mdx
+++ b/apps/docs/content/docs/en/integrations/google_contacts.mdx
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Google Contacts](https://contacts.google.com/) is Google's contact management service, letting people store and organize names, emails, phone numbers, and organization details for everyone they communicate with.
+
+With this block, you can:
+
+- **Create and update contacts**: Add new contacts or edit existing ones with names, emails, phone numbers, organization, job title, and notes
+- **Retrieve and list contacts**: Fetch a specific contact by resource name or list contacts with pagination and sort order
+- **Search and delete contacts**: Find contacts by name, email, phone, or organization, or remove a contact entirely
+
+In Sim, the Google Contacts integration allows your agents to create, read, update, delete, list, and search contacts programmatically within a workflow. This enables agents to keep a contact directory in sync, look up contact details before sending communications, or search for matching contacts by name, email, phone, or organization as part of a larger automation.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Google Contacts into the workflow. Can create, read, update, delete, list, and search contacts.
diff --git a/apps/docs/content/docs/en/integrations/granola.mdx b/apps/docs/content/docs/en/integrations/granola.mdx
index e1d96ad531d..9f5371c782b 100644
--- a/apps/docs/content/docs/en/integrations/granola.mdx
+++ b/apps/docs/content/docs/en/integrations/granola.mdx
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#B2C147"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Granola](https://www.granola.ai/) is an AI notepad for meetings that automatically records and transcribes calls, then generates structured notes and summaries alongside your own typed notes.
+
+With Granola, you can:
+
+- **List meeting notes**: Browse notes with date filters, folder scoping, and pagination
+- **Retrieve full note details**: Get summaries, attendees, calendar event details, and transcripts for a specific note
+- **Organize by folders**: List and filter notes using Granola's folder structure
+
+In Sim, the Granola integration allows your agents to pull meeting notes, summaries, and transcripts directly into a workflow. Agents can list recent notes with date or folder filters, fetch a specific note's summary text, attendees, and calendar details, retrieve the full transcript with speaker labels when needed, and browse folders to organize retrieval. This makes it possible to build workflows that surface meeting outcomes, route action items, or feed meeting context into downstream agent reasoning.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Granola into your workflow to retrieve meeting notes, summaries, attendees, and transcripts.
diff --git a/apps/docs/content/docs/en/integrations/instantly.mdx b/apps/docs/content/docs/en/integrations/instantly.mdx
index 2dd0ae4923f..538ae932aec 100644
--- a/apps/docs/content/docs/en/integrations/instantly.mdx
+++ b/apps/docs/content/docs/en/integrations/instantly.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Instantly](https://instantly.ai/) is a cold email outreach platform used to send, manage, and scale email campaigns to leads. It provides tools for lead management, campaign scheduling, and reply tracking through its Unibox inbox.
+
+With Instantly, you can:
+
+- **Manage leads**: Create, update, search, and delete leads across campaigns and lead lists
+- **Run campaigns**: Create, update, activate, pause, and delete email campaigns with custom schedules and sequences
+- **Handle replies**: List Unibox emails and reply to leads directly from a connected sending account
+- **Organize lead lists**: Create and list lead lists, and track lead interest status
+
+In Sim, the Instantly integration allows your agents to manage leads and campaigns programmatically — retrieving and creating leads, updating lead interest status, listing and controlling campaigns (create, patch, activate, pause, delete), reading and replying to Unibox emails, and creating or listing lead lists. This lets your agents automate outbound email workflows, such as syncing new leads into campaigns, monitoring campaign status, and responding to replies without leaving the workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Instantly API V2 into workflows. Create, update, and list leads, manage lead interest status, delete leads in bulk, list, create, patch, activate, pause, and delete campaigns, reply to emails, and manage lead lists.
diff --git a/apps/docs/content/docs/en/integrations/jupyter.mdx b/apps/docs/content/docs/en/integrations/jupyter.mdx
index 81c333a73f2..e62f270a9e6 100644
--- a/apps/docs/content/docs/en/integrations/jupyter.mdx
+++ b/apps/docs/content/docs/en/integrations/jupyter.mdx
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Jupyter](https://jupyter.org/) is an open-source platform for interactive computing, best known for the Jupyter Notebook interface that combines live code, visualizations, and narrative text. It runs as a server that exposes files, notebooks, kernels, and sessions over an HTTP API.
+
+With Jupyter, you can:
+
+- **Manage files and notebooks**: List, read, create, upload, rename, copy, and delete files, notebooks, and directories
+- **Control kernels**: List, start, stop, restart, and interrupt the runtimes that execute notebook code
+- **Manage sessions**: List, create, and delete sessions that bind a notebook path to a running kernel
+
+In Sim, the Jupyter integration allows your agents to connect to a self-hosted Jupyter server and drive it programmatically—browsing and editing files and notebooks, uploading content, and managing kernels and sessions that bind notebooks to running code environments. This lets your agents automate notebook-based workflows, such as provisioning a kernel, writing or updating notebook content, and cleaning up sessions and files as part of a larger pipeline.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate a self-hosted Jupyter server into the workflow. Browse, read, create, upload, rename, copy, and delete files and notebooks; start, stop, restart, and interrupt kernels; and manage sessions that bind notebooks to kernels.
diff --git a/apps/docs/content/docs/en/integrations/logs.mdx b/apps/docs/content/docs/en/integrations/logs.mdx
index 2295215662c..6237f56c9c1 100644
--- a/apps/docs/content/docs/en/integrations/logs.mdx
+++ b/apps/docs/content/docs/en/integrations/logs.mdx
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#EAB308"
/>
+{/* MANUAL-CONTENT-START:intro */}
+The Logs block gives agents programmatic access to workflow run history in the current workspace. It queries the same run data shown on the Logs page and can fetch full execution detail for any individual run.
+
+With Logs, you can:
+
+- **Query run history**: Filter runs by workflow, folder, status, trigger type, date range, cost, and duration, with sorting and free-text search
+- **Fetch run details**: Retrieve a specific run's status, trigger, timing, cost, and final output by run ID
+- **Inspect execution traces**: Pull the full trace spans for a run to see how it executed step by step
+
+In Sim, the Logs block allows your agents to search and filter workflow run history using the same filters available on the Logs page, then drill into a specific run to retrieve its status, cost, duration, final output, and full trace spans. This lets agents monitor other workflows, audit past executions, and pull execution details programmatically as part of a larger workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Query workflow run logs in the current workspace with the same filters as the Logs page, returning matching run IDs. Fetch full details for a single run, including its trace spans.
diff --git a/apps/docs/content/docs/en/integrations/monday.mdx b/apps/docs/content/docs/en/integrations/monday.mdx
index 35a2e25eced..4e4941f2c9e 100644
--- a/apps/docs/content/docs/en/integrations/monday.mdx
+++ b/apps/docs/content/docs/en/integrations/monday.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Monday.com](https://monday.com/) is a work operating system that teams use to plan, track, and manage projects through customizable boards, items, and columns. Boards organize work into groups of items, with columns tracking status, dates, people, and other structured data for each item.
+
+With Monday.com, you can:
+
+- **Manage boards**: List, retrieve, and create boards along with their groups and columns
+- **Manage items**: Fetch, search, create, update, duplicate, archive, and delete items
+- **Organize work**: Create subitems, move items between groups, and add updates or comments
+- **React to events**: Trigger workflows on column changes, item creation, status changes, and more
+
+In Sim, the Monday.com integration allows your agents to list and inspect boards, fetch and search items by column values, create and update items and subitems, change column values, move items between groups, post updates, and create new boards, groups, and columns—all programmatically through API calls. Triggers let workflows react automatically to events such as column value changes, item creation or deletion, status changes, items being moved between groups, and updates being posted, making it possible to keep external systems in sync with activity on a Monday.com board.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate with Monday.com to list boards, get board details, fetch and search items, create and update items, archive or delete items, create subitems, move items between groups, add updates, and create groups.
diff --git a/apps/docs/content/docs/en/integrations/mysql.mdx b/apps/docs/content/docs/en/integrations/mysql.mdx
index 35202422f41..b9ffd5979c5 100644
--- a/apps/docs/content/docs/en/integrations/mysql.mdx
+++ b/apps/docs/content/docs/en/integrations/mysql.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[MySQL](https://www.mysql.com/) is a widely used open-source relational database system. The MySQL block connects your workflow directly to a MySQL database, letting you read and write data without leaving Sim. It handles the connection details—host, port, database, credentials, and SSL mode—so blocks can query, insert, update, delete, and execute raw SQL against your database.
+
+With the MySQL block, you can:
+
+- **Query data**: Run SELECT statements and get back rows with a row count
+- **Insert, update, and delete records**: Write key-value data to a table or modify existing rows with a WHERE clause
+- **Execute raw SQL**: Run arbitrary SQL statements, such as DDL for creating tables
+- **Introspect the schema**: Retrieve table structures, columns, keys, indexes, and the list of available databases on the server
+
+In Sim, the MySQL block allows your agents to read from and write to a MySQL database as part of a workflow—querying records to feed downstream logic, inserting or updating rows based on agent output, cleaning up data with deletes, running custom SQL for one-off operations, and introspecting the schema to understand table structure before generating queries.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate MySQL into the workflow. Can query, insert, update, delete, and execute raw SQL.
diff --git a/apps/docs/content/docs/en/integrations/postgresql.mdx b/apps/docs/content/docs/en/integrations/postgresql.mdx
index a864f4b4b4f..ac0e70b4382 100644
--- a/apps/docs/content/docs/en/integrations/postgresql.mdx
+++ b/apps/docs/content/docs/en/integrations/postgresql.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#336791"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[PostgreSQL](https://www.postgresql.org/) is a widely used open-source relational database system known for reliability, extensibility, and standards compliance. It stores structured data in tables and supports SQL for querying, transactions, and schema management.
+
+With this integration, you can:
+
+- **Query data**: Run SELECT statements to retrieve rows from a database
+- **Insert, update, and delete data**: Write, modify, and remove rows in a table
+- **Execute raw SQL**: Run arbitrary SQL statements directly against the database
+- **Introspect schemas**: Retrieve table structures, columns, primary/foreign keys, and indexes
+
+In Sim, the PostgreSQL integration allows your agents to connect to a PostgreSQL database and query, insert, update, delete, and execute raw SQL against it, as well as introspect the database schema to discover tables, columns, and relationships. This gives your agents the ability to read and write structured data as part of a workflow, and to understand a database's structure before generating or running queries against it.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate PostgreSQL into the workflow. Can query, insert, update, delete, and execute raw SQL.
diff --git a/apps/docs/content/docs/en/integrations/rb2b.mdx b/apps/docs/content/docs/en/integrations/rb2b.mdx
index 45096416cfc..35d5234e709 100644
--- a/apps/docs/content/docs/en/integrations/rb2b.mdx
+++ b/apps/docs/content/docs/en/integrations/rb2b.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#51FF00"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[RB2B](https://rb2b.com/) is a website visitor identification and B2B enrichment platform that resolves anonymous website traffic into person-level identity data. It matches IP addresses, hashed emails, and LinkedIn profiles against its data graph to surface who is visiting a site and how to reach them.
+
+With RB2B, you can:
+
+- **Resolve IPs into identity signals**: Convert an IP address into hashed emails, mobile advertising IDs, or company domains
+- **Enrich emails and LinkedIn profiles**: Turn a hashed email or LinkedIn slug into a full business profile with name, title, company, and contact details
+- **Look up contact and activity data**: Retrieve mobile phone numbers, personal emails, and last-active dates for a known contact
+- **Search for people**: Find a LinkedIn profile from a first name, last name, and company domain
+
+In Sim, the RB2B integration allows your agents to identify anonymous website visitors and enrich them into actionable contact records — resolving an IP address to a company or hashed email, expanding a hashed email or LinkedIn profile into a full business profile, and retrieving mobile phone numbers or personal emails for outreach. Agents can also check remaining API credits and confirm when a contact was last active, making the integration useful for lead identification, sales prospecting, and B2B enrichment workflows.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Resolve IP addresses, hashed emails, and LinkedIn profiles into person-level identity and B2B enrichment data using the RB2B API. Convert IPs to hashed emails, MAIDs, and company domains; enrich emails into LinkedIn profiles, business profiles, and mobile IDs; and look up emails or phone numbers from LinkedIn. Requires an RB2B API key.
diff --git a/apps/docs/content/docs/en/integrations/sftp.mdx b/apps/docs/content/docs/en/integrations/sftp.mdx
index 43fdffe9a9f..65a8a68fba6 100644
--- a/apps/docs/content/docs/en/integrations/sftp.mdx
+++ b/apps/docs/content/docs/en/integrations/sftp.mdx
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#2D3748"
/>
+{/* MANUAL-CONTENT-START:intro */}
+SFTP (SSH File Transfer Protocol) is a secure file transfer protocol that runs over an SSH connection, letting you move and manage files on a remote server without exposing credentials or data in plaintext. It supports authentication via password or private key, making it a standard choice for secure file exchange with servers, hosting providers, and legacy systems.
+
+With this block, you can:
+
+- **Upload files**: Send files or direct text content to a directory on a remote server
+- **Download files**: Retrieve a file from the remote server as text or base64-encoded content
+- **List directory contents**: Browse files and folders, optionally with detailed metadata like size and permissions
+- **Delete files or directories**: Remove remote files, with optional recursive deletion for directories
+- **Create directories**: Make new directories on the remote server, optionally creating parent directories as needed
+
+In Sim, the SFTP block allows your agents to upload, download, list, delete, and create files and directories on a remote server, all authenticated with a password or private key. This enables workflows that move generated files to a remote destination, pull files from a server for processing, sync directory contents, or manage remote file structures as part of a larger automation.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.
diff --git a/apps/docs/content/docs/en/integrations/sixtyfour.mdx b/apps/docs/content/docs/en/integrations/sixtyfour.mdx
index cec9052552a..6a10ddd42c1 100644
--- a/apps/docs/content/docs/en/integrations/sixtyfour.mdx
+++ b/apps/docs/content/docs/en/integrations/sixtyfour.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#000000"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Sixtyfour AI](https://sixtyfour.ai/) is an AI-powered research platform that finds contact information and enriches lead and company data by researching the web. It combines automated search with structured data extraction to surface emails, phone numbers, and detailed profile information.
+
+With Sixtyfour AI, you can:
+
+- **Find phone numbers**: Locate phone numbers for a lead using their name, company, LinkedIn URL, or other identifying details
+- **Find email addresses**: Discover professional or personal email addresses, complete with validation status
+- **Enrich leads**: Research a person and return structured data matching custom fields, backed by source references and a confidence score
+- **Enrich companies**: Research a company, optionally finding associated people and a full org chart, with results matching custom fields
+
+In Sim, the Sixtyfour AI integration allows your agents to look up phone numbers and emails for a lead, and enrich lead or company records with structured research data, source references, and confidence scores—all programmatically through API calls. This enables your agents to turn partial contact information into complete, verified profiles, and to build out organizational data such as employee counts and org charts as part of a workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Find emails, phone numbers, and enrich lead or company data with contact information, social profiles, and detailed research using Sixtyfour AI.
diff --git a/apps/docs/content/docs/en/integrations/smtp.mdx b/apps/docs/content/docs/en/integrations/smtp.mdx
index 0e4bec244ac..1e65fc3c0ce 100644
--- a/apps/docs/content/docs/en/integrations/smtp.mdx
+++ b/apps/docs/content/docs/en/integrations/smtp.mdx
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#2D3748"
/>
+{/* MANUAL-CONTENT-START:intro */}
+The SMTP block sends email through any standard SMTP mail server, including Gmail, Outlook, and self-hosted or custom mail servers. It connects directly using the server hostname, port, and credentials you provide rather than a vendor-specific API.
+
+With the SMTP block, you can:
+
+- **Connect to any SMTP server**: Configure host, port, and security protocol (TLS, SSL, or none) for the mail server of your choice
+- **Control message content and formatting**: Set sender, recipients, subject, and body, with support for either plain text or HTML content
+- **Reach multiple recipients and attach files**: Add CC and BCC recipients, set a reply-to address, and attach files to the outgoing email
+
+In Sim, the SMTP block allows your agents to send emails as part of a workflow by supplying connection details and message content, then confirming delivery through the returned success status and message ID. This makes it possible to notify recipients, deliver generated reports, or trigger email-based alerts directly from a workflow, with the block reporting the message ID on success or an error message if sending fails.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Send emails using any SMTP server (Gmail, Outlook, custom servers, etc.). Configure SMTP connection settings and send emails with full control over content, recipients, and attachments.
diff --git a/apps/docs/content/docs/en/integrations/sportmonks.mdx b/apps/docs/content/docs/en/integrations/sportmonks.mdx
index f3d3d673742..90285fda99e 100644
--- a/apps/docs/content/docs/en/integrations/sportmonks.mdx
+++ b/apps/docs/content/docs/en/integrations/sportmonks.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#171534"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Sportmonks](https://www.sportmonks.com/) is a sports data provider offering structured APIs for football, motorsport, odds, and reference data used to power scores, statistics, and betting-related applications.
+
+With Sportmonks, you can access:
+
+- **Football data**: fixtures, livescores, leagues, seasons, stages, rounds, teams, squads, players, coaches, referees, venues, standings, topscorers, transfers, schedules, commentaries, TV stations, rivals, expected goals (xG), and predictions
+- **Motorsport data**: sessions, drivers, teams, championship standings, laps, and pitstops
+- **Odds data**: pre-match and in-play odds, bookmakers, and markets
+- **Core reference data**: continents, countries, regions, cities, types, and time zones
+
+In Sim, the Sportmonks integration allows your agents to query fixtures, player and team statistics, transfer activity, standings, and odds directly within a workflow. Agents can pull expected goals (xG) by player or team, retrieve match commentaries and brackets, look up coaches and their histories, and enrich any of these with related data through the `include` parameter—enabling workflows that build match reports, track transfer rumours, or surface live odds and predictions.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate the Sportmonks sports data APIs into the workflow from a single block. Football: fixtures, livescores, leagues, seasons, stages, rounds, teams, squads, players, coaches, referees, venues, standings, topscorers, transfers, schedules, commentaries, TV stations, rivals, expected goals (xG), and predictions. Motorsport: sessions, drivers, teams, championship standings, laps, and pitstops. Odds: pre-match and in-play odds, bookmakers, and markets. Core: continents, countries, regions, cities, types, and time zones.
diff --git a/apps/docs/content/docs/en/integrations/square.mdx b/apps/docs/content/docs/en/integrations/square.mdx
index d741dbdb24d..9508ce539f5 100644
--- a/apps/docs/content/docs/en/integrations/square.mdx
+++ b/apps/docs/content/docs/en/integrations/square.mdx
@@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#000000"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Square](https://squareup.com/) is a commerce platform that provides payment processing, point-of-sale, and business management tools for businesses of all sizes. It offers APIs for accepting payments, managing customers, and handling orders, invoices, and catalog data across online and in-person sales channels.
+
+With Square, you can:
+
+- **Process payments and refunds**: Take, retrieve, list, cancel, complete, and refund payments
+- **Manage customers**: Create, retrieve, list, search, update, and delete customer profiles
+- **Manage orders and invoices**: Create, retrieve, search, and pay for orders, and create, publish, cancel, and delete invoices
+- **Manage catalog and inventory**: Upsert, retrieve, list, and search catalog objects, upload catalog images, and check inventory counts
+
+In Sim, the Square integration allows your agents to take and refund payments, manage customer profiles, look up business locations, create and track orders and invoices, and manage catalog items and inventory — all programmatically through API calls. This enables agents to handle real commerce operations such as charging a customer, issuing a refund, keeping customer records up to date, sending invoices, and maintaining product catalog and stock data, directly from a workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Square into the workflow. Take and refund payments, manage customers, build catalog items and images, create and search orders, and issue invoices. Authenticate with a Square access token (personal access token).
diff --git a/apps/docs/content/docs/en/integrations/ssh.mdx b/apps/docs/content/docs/en/integrations/ssh.mdx
index 74a830b9edc..3b059a04bb8 100644
--- a/apps/docs/content/docs/en/integrations/ssh.mdx
+++ b/apps/docs/content/docs/en/integrations/ssh.mdx
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#000000"
/>
+{/* MANUAL-CONTENT-START:intro */}
+The SSH block connects to remote servers using the Secure Shell protocol, supporting both password and private key (OpenSSH format) authentication. It provides direct command execution and file system access on any server reachable over SSH.
+
+With SSH, you can:
+
+- **Run commands and scripts**: Execute shell commands or upload and run multi-line scripts with a configurable interpreter
+- **Manage the file system**: Upload, download, read, write, move, rename, and delete files and directories
+- **Inspect the remote environment**: Check whether a command or path exists, list directory contents, and retrieve system information such as OS, architecture, uptime, and disk/memory usage
+
+In Sim, the SSH block allows your agents to operate remote servers as part of a workflow—running commands and scripts, transferring and manipulating files, and gathering system diagnostics—all through authenticated SSH connections. This makes it possible to automate server administration, deployment steps, and remote data processing directly from an agent's workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Execute commands, transfer files, and manage remote servers via SSH. Supports password and private key authentication for secure server access.
diff --git a/apps/docs/content/docs/en/integrations/thrive.mdx b/apps/docs/content/docs/en/integrations/thrive.mdx
index d2987593bad..fa2be184eb7 100644
--- a/apps/docs/content/docs/en/integrations/thrive.mdx
+++ b/apps/docs/content/docs/en/integrations/thrive.mdx
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Thrive Learning](https://thrivelearning.com/) is a learning management and compliance platform that helps organizations manage user onboarding, training assignments, and CPD tracking. It gives HR and L&D teams a central system for organizing learners into audiences, assigning content, and monitoring completion and compliance status.
+
+With Thrive, you can:
+
+- **Manage the user lifecycle**: Create, update, suspend, and delete users, and look them up by ID or ref
+- **Organize audiences and structures**: Create and manage audiences, move them between parents, and manage their members and managers
+- **Assign and track compliance**: Create assignments tied to content and audiences, and list or inspect the resulting enrolments
+- **Record learning outcomes**: Create and query completion records, including skills acquired and RPL imports
+- **Access content and activity data**: Look up content items and query user activity records
+
+In Sim, the Thrive Learning integration allows your agents to manage the full user lifecycle, organize audiences and their members and managers, create and track compliance assignments and enrolments, record and query learning completions, and retrieve content and activity records — all programmatically through API calls. This enables agents to automate onboarding, training assignment, and compliance reporting workflows directly against Thrive's tenant.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Thrive Learning into the workflow. Manage user lifecycle, audiences and their members and managers, content assignments and enrolments, learning completions, content and activity records, CPD, tags, and skills.
diff --git a/apps/docs/content/docs/en/integrations/twilio.mdx b/apps/docs/content/docs/en/integrations/twilio.mdx
index 6de83ac0136..eb72d6f7405 100644
--- a/apps/docs/content/docs/en/integrations/twilio.mdx
+++ b/apps/docs/content/docs/en/integrations/twilio.mdx
@@ -10,6 +10,18 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#6B7280"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Twilio](https://www.twilio.com/) is a cloud communications platform that lets developers programmatically send and receive SMS, MMS, and voice calls. It provides a widely used API for building messaging into applications, including delivery status callbacks and inbound message handling.
+
+With the Twilio triggers, you can:
+
+- **React to message status changes**: Start a workflow whenever a sent message's status updates (sent, delivered, failed, undelivered)
+- **React to inbound messages**: Start a workflow whenever an SMS or MMS is received on a Twilio number
+- **Access full message context**: Read sender/recipient details, message body, media attachments, and error codes from the triggering event
+
+In Sim, the Twilio triggers allow your agents to start workflows directly from Twilio messaging events. The Twilio Message Status trigger fires when a message's delivery status changes, exposing fields like `messageSid`, `smsStatus`, `messageStatus`, and `errorCode` so a workflow can react to delivery failures or confirmations. The Twilio SMS Received trigger fires on inbound SMS/MMS messages, exposing the sender and recipient numbers, message body, and any attached media, so a workflow can process incoming texts, route them, or trigger automated replies.
+{/* MANUAL-CONTENT-END */}
+
## Triggers
A **Trigger** is a block that starts a workflow when an event happens in this service.
diff --git a/apps/docs/content/docs/en/integrations/uptimerobot.mdx b/apps/docs/content/docs/en/integrations/uptimerobot.mdx
index eb875883b30..bb541e6d41e 100644
--- a/apps/docs/content/docs/en/integrations/uptimerobot.mdx
+++ b/apps/docs/content/docs/en/integrations/uptimerobot.mdx
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#111921"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[UptimeRobot](https://uptimerobot.com/) is a website and API monitoring service that tracks the availability of servers, endpoints, and heartbeat checks, alerting you when something goes down. It supports HTTP, keyword, ping, port, heartbeat, DNS, API, and UDP monitors, and can publish public status pages to keep users informed during incidents.
+
+With UptimeRobot, you can:
+
+- **Monitor uptime**: Create, update, pause, resume, and delete monitors across multiple check types
+- **Track incidents**: List and inspect incidents, including root cause details like HTTP response codes
+- **Schedule maintenance windows**: Suppress alerts during planned downtime, with one-time or recurring schedules
+- **Manage alert contacts**: Create, retrieve, and delete email alert contacts for notifications
+- **Publish status pages**: Create and update public status pages, optionally with a custom domain, logo, and icon
+
+In Sim, the UptimeRobot integration allows your agents to check monitor status, list and investigate incidents, create or update monitors and maintenance windows, manage alert contacts, and publish status pages—all through the UptimeRobot v3 API. This enables workflows that react to downtime, automate incident triage, schedule maintenance around deployments, and keep public status pages current without manual intervention.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate UptimeRobot into your workflow. Create and manage monitors, inspect incidents, schedule maintenance windows, manage alert contacts, and publish public status pages using the UptimeRobot v3 API.
diff --git a/apps/docs/content/docs/en/integrations/workday.mdx b/apps/docs/content/docs/en/integrations/workday.mdx
index 35652e80e9b..32e46699600 100644
--- a/apps/docs/content/docs/en/integrations/workday.mdx
+++ b/apps/docs/content/docs/en/integrations/workday.mdx
@@ -10,6 +10,22 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#F5F0EB"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Workday](https://www.workday.com/) is a cloud-based Human Capital Management (HCM) and finance system used by organizations to manage their workforce, from hiring through termination. It centralizes worker records, organizational structure, compensation, and HR business processes.
+
+With this integration, you can:
+
+- **Manage worker records**: Retrieve individual worker profiles or list and search workers with pagination
+- **Run the hire lifecycle**: Create pre-hire records and convert them into active employees with position and start date
+- **Update worker data**: Change fields on existing worker records, such as business title or work email
+- **Handle job and org changes**: Process transfers, promotions, demotions, and lateral moves, and retrieve organizations, departments, and cost centers
+- **Manage onboarding and compensation**: Assign onboarding plans and retrieve compensation plan details for a worker
+- **Process terminations**: Initiate the termination business process with a reason and effective date
+
+In Sim, the Workday integration allows your agents to look up and update worker profiles, create pre-hires and hire them into positions, assign onboarding plans, process job changes and terminations, and pull organization and compensation data—all through Workday's Integration System User authentication. This lets your agents automate HR operations such as onboarding new employees, keeping worker records current, and managing the employee lifecycle directly from a workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Workday HRIS into your workflow. Create pre-hires, hire employees, manage worker profiles, assign onboarding plans, handle job changes, retrieve compensation data, and process terminations.
diff --git a/apps/docs/lib/urls.ts b/apps/docs/lib/urls.ts
index cae36d7d597..8001a5d55ed 100644
--- a/apps/docs/lib/urls.ts
+++ b/apps/docs/lib/urls.ts
@@ -1 +1,9 @@
export const DOCS_BASE_URL = process.env.NEXT_PUBLIC_DOCS_URL ?? 'https://docs.sim.ai'
+/**
+ * The public marketing site's fixed canonical origin — not `NEXT_PUBLIC_APP_URL`.
+ * That env var reflects wherever *this* deployment (self-hosted or otherwise)
+ * happens to run, but the footer's marketing links (`/blog`, `/enterprise`,
+ * `/models`, `/terms`, `/privacy`, …) only ever exist on sim.ai itself, so
+ * they must stay hardcoded to it regardless of where docs is hosted.
+ */
+export const SIM_SITE_URL = 'https://sim.ai'
diff --git a/apps/docs/public/favicon/android-chrome-192x192.png b/apps/docs/public/favicon/android-chrome-192x192.png
index 891e730eabf..01fa63be6f0 100644
Binary files a/apps/docs/public/favicon/android-chrome-192x192.png and b/apps/docs/public/favicon/android-chrome-192x192.png differ
diff --git a/apps/docs/public/favicon/android-chrome-512x512.png b/apps/docs/public/favicon/android-chrome-512x512.png
index e22c3b295b0..70db080feed 100644
Binary files a/apps/docs/public/favicon/android-chrome-512x512.png and b/apps/docs/public/favicon/android-chrome-512x512.png differ
diff --git a/apps/docs/public/favicon/apple-touch-icon.png b/apps/docs/public/favicon/apple-touch-icon.png
index a09d065ec40..0a9d90cd818 100644
Binary files a/apps/docs/public/favicon/apple-touch-icon.png and b/apps/docs/public/favicon/apple-touch-icon.png differ
diff --git a/apps/docs/public/favicon/favicon-16x16.png b/apps/docs/public/favicon/favicon-16x16.png
index 4ac7ae4717e..5b907892e13 100644
Binary files a/apps/docs/public/favicon/favicon-16x16.png and b/apps/docs/public/favicon/favicon-16x16.png differ
diff --git a/apps/docs/public/favicon/favicon-32x32.png b/apps/docs/public/favicon/favicon-32x32.png
index ee774f542d2..289d9bcb15f 100644
Binary files a/apps/docs/public/favicon/favicon-32x32.png and b/apps/docs/public/favicon/favicon-32x32.png differ
diff --git a/apps/docs/public/favicon/favicon-96x96.png b/apps/docs/public/favicon/favicon-96x96.png
index 34d58ba41d0..b32f9af3737 100644
Binary files a/apps/docs/public/favicon/favicon-96x96.png and b/apps/docs/public/favicon/favicon-96x96.png differ
diff --git a/apps/docs/public/favicon/web-app-manifest-192x192.png b/apps/docs/public/favicon/web-app-manifest-192x192.png
index ca2cabd6df9..01fa63be6f0 100644
Binary files a/apps/docs/public/favicon/web-app-manifest-192x192.png and b/apps/docs/public/favicon/web-app-manifest-192x192.png differ
diff --git a/apps/docs/public/favicon/web-app-manifest-512x512.png b/apps/docs/public/favicon/web-app-manifest-512x512.png
index aceecf70b33..70db080feed 100644
Binary files a/apps/docs/public/favicon/web-app-manifest-512x512.png and b/apps/docs/public/favicon/web-app-manifest-512x512.png differ
diff --git a/apps/docs/public/icon.svg b/apps/docs/public/icon.svg
index f96f875249b..d1b94a8d2e1 100644
--- a/apps/docs/public/icon.svg
+++ b/apps/docs/public/icon.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/apps/docs/public/static/fonts/SeasonSans-600-static.ttf b/apps/docs/public/static/fonts/SeasonSans-600-static.ttf
new file mode 100644
index 00000000000..56dcc2b803b
Binary files /dev/null and b/apps/docs/public/static/fonts/SeasonSans-600-static.ttf differ
diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts
index 7f514bda77f..697ba4099c0 100644
--- a/apps/realtime/src/database/operations.ts
+++ b/apps/realtime/src/database/operations.ts
@@ -24,7 +24,15 @@ import {
import { randomFloat } from '@sim/utils/random'
import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load'
import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks'
-import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
+import {
+ filterAcyclicEdges,
+ filterUniqueWorkflowEdges,
+ getWorkflowBlockNameConflict,
+ getWorkflowEdgeScopeDropReason,
+ isKnownWorkflowTriggerBlock,
+ isWorkflowAnnotationOnlyBlockType,
+ isWorkflowBlockProtected,
+} from '@sim/workflow-types/workflow'
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
@@ -32,6 +40,171 @@ import { env } from '@/env'
const logger = createLogger('SocketDatabase')
+interface PersistedEdgeRecord {
+ sourceBlockId: string
+ targetBlockId: string
+ sourceHandle: string | null
+ targetHandle: string | null
+}
+
+function toEdgeHandles(edge: PersistedEdgeRecord) {
+ return {
+ source: edge.sourceBlockId,
+ target: edge.targetBlockId,
+ sourceHandle: edge.sourceHandle,
+ targetHandle: edge.targetHandle,
+ }
+}
+
+interface EdgeAddCandidate {
+ id?: string
+ source: string
+ target: string
+ sourceHandle?: string | null
+ targetHandle?: string | null
+}
+
+interface FilterEdgesForPersistResult {
+ safeEdges: T[]
+ droppedCounts: Record
+ droppedDuplicates: number
+ droppedCyclic: number
+}
+
+/**
+ * Runs the full server-side edge-add validation pipeline — missing block,
+ * protected target, annotation-only block, trigger-block target, scope
+ * boundary, duplicate, cycle — against one or more candidate edges. Shared by
+ * the single-edge `ADD` and `BATCH_ADD_EDGES` handlers so the two paths
+ * can't drift out of sync with each other, the same way the client and
+ * realtime layers already share these rules via `@sim/workflow-types`.
+ */
+async function filterEdgesForPersist(
+ tx: any,
+ workflowId: string,
+ candidates: T[]
+): Promise> {
+ const connectedBlockIds = new Set()
+ for (const edge of candidates) {
+ connectedBlockIds.add(edge.source)
+ connectedBlockIds.add(edge.target)
+ }
+
+ const connectedBlocks = await tx
+ .select({
+ id: workflowBlocks.id,
+ type: workflowBlocks.type,
+ locked: workflowBlocks.locked,
+ triggerMode: workflowBlocks.triggerMode,
+ data: workflowBlocks.data,
+ })
+ .from(workflowBlocks)
+ .where(
+ and(
+ eq(workflowBlocks.workflowId, workflowId),
+ inArray(workflowBlocks.id, Array.from(connectedBlockIds))
+ )
+ )
+
+ type EdgeBlockRecord = (typeof connectedBlocks)[number]
+ const blocksById: Record = Object.fromEntries(
+ connectedBlocks.map((b: EdgeBlockRecord) => [b.id, b])
+ )
+
+ const parentIds = new Set()
+ for (const block of connectedBlocks) {
+ const parentId = (block.data as Record | null)?.parentId as string | undefined
+ if (parentId && !blocksById[parentId]) {
+ parentIds.add(parentId)
+ }
+ }
+
+ if (parentIds.size > 0) {
+ const parentBlocks = await tx
+ .select({
+ id: workflowBlocks.id,
+ type: workflowBlocks.type,
+ locked: workflowBlocks.locked,
+ data: workflowBlocks.data,
+ })
+ .from(workflowBlocks)
+ .where(
+ and(
+ eq(workflowBlocks.workflowId, workflowId),
+ inArray(workflowBlocks.id, Array.from(parentIds))
+ )
+ )
+ for (const b of parentBlocks) {
+ blocksById[b.id] = b
+ }
+ }
+
+ const droppedCounts: Record = {}
+ const countDrop = (reason: string) => {
+ droppedCounts[reason] = (droppedCounts[reason] ?? 0) + 1
+ }
+
+ // Mirrors the client's validateEdges rule order: missing block, protected
+ // target, annotation-only block, trigger-block target, scope boundary.
+ // `scopeDropReason`'s detail (which names the specific block ids/scopes)
+ // is dropped in favor of a stable 'scope boundary' key so droppedCounts
+ // aggregates across edges instead of gaining one free-text key per edge.
+ const structurallyValidEdges = candidates.filter((edge) => {
+ const sourceBlock = blocksById[edge.source]
+ const targetBlock = blocksById[edge.target]
+
+ if (!sourceBlock || !targetBlock) {
+ countDrop('missing block')
+ return false
+ }
+ if (isWorkflowBlockProtected(edge.target, blocksById)) {
+ countDrop('protected target block')
+ return false
+ }
+ if (
+ isWorkflowAnnotationOnlyBlockType(sourceBlock.type) ||
+ isWorkflowAnnotationOnlyBlockType(targetBlock.type)
+ ) {
+ countDrop('annotation-only block')
+ return false
+ }
+ if (isKnownWorkflowTriggerBlock(targetBlock)) {
+ countDrop('trigger block target')
+ return false
+ }
+ if (getWorkflowEdgeScopeDropReason(edge, blocksById)) {
+ countDrop('scope boundary')
+ return false
+ }
+ return true
+ })
+
+ if (structurallyValidEdges.length === 0) {
+ return { safeEdges: [], droppedCounts, droppedDuplicates: 0, droppedCyclic: 0 }
+ }
+
+ const existingEdgesForCycleCheck: PersistedEdgeRecord[] = await tx
+ .select({
+ sourceBlockId: workflowEdges.sourceBlockId,
+ targetBlockId: workflowEdges.targetBlockId,
+ sourceHandle: workflowEdges.sourceHandle,
+ targetHandle: workflowEdges.targetHandle,
+ })
+ .from(workflowEdges)
+ .where(eq(workflowEdges.workflowId, workflowId))
+ const existingEdgeEndpoints = existingEdgesForCycleCheck.map(toEdgeHandles)
+
+ const uniqueEdges = filterUniqueWorkflowEdges(structurallyValidEdges, existingEdgeEndpoints)
+ const safeEdges = filterAcyclicEdges(uniqueEdges, existingEdgeEndpoints)
+
+ return {
+ safeEdges,
+ droppedCounts,
+ droppedDuplicates: structurallyValidEdges.length - uniqueEdges.length,
+ droppedCyclic: uniqueEdges.length - safeEdges.length,
+ }
+}
+
// Both realtime pools (this socketDb + the shared @sim/db pool) resolve the
// realtime-keyed URL when set, falling back to the shared DATABASE_URL.
const connectionString =
@@ -229,6 +402,15 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
}
await db.transaction(async (tx) => {
+ // This UPDATE is also this workflow's write-serialization point, not
+ // just a timestamp bump: it takes a row lock on `workflow` for the
+ // rest of the transaction, so two concurrent persistWorkflowOperation
+ // calls for the same workflowId cannot interleave — the second blocks
+ // here until the first commits or rolls back. Every handler dispatched
+ // below (including the edge-add validate-then-insert sequence in
+ // filterEdgesForPersist) relies on this to read a consistent snapshot;
+ // do not make this UPDATE conditional/skippable as an optimization
+ // without replacing the serialization it provides.
await tx
.update(workflow)
.set({ updatedAt: new Date(timestamp) })
@@ -406,6 +588,22 @@ async function handleBlockOperationTx(
}
}
+ const siblingBlocks = await tx
+ .select({ id: workflowBlocks.id, name: workflowBlocks.name })
+ .from(workflowBlocks)
+ .where(eq(workflowBlocks.workflowId, workflowId))
+ const siblingNamesById: Record = Object.fromEntries(
+ siblingBlocks.map((b: { id: string; name: string }) => [b.id, b.name])
+ )
+
+ const nameConflict = getWorkflowBlockNameConflict(payload.id, payload.name, siblingNamesById)
+ if (nameConflict) {
+ logger.info(
+ `Skipping rename of block ${payload.id} - name conflict: ${nameConflict.reason}`
+ )
+ break
+ }
+
await tx
.update(workflowBlocks)
.set({
@@ -820,27 +1018,54 @@ async function handleBlocksOperationTx(
}
if (edges && edges.length > 0) {
- const edgeValues = edges.map((edge: Record) => ({
- id: edge.id as string,
- workflowId,
- sourceBlockId: edge.source as string,
- targetBlockId: edge.target as string,
- sourceHandle: (edge.sourceHandle as string | null) || null,
- targetHandle: (edge.targetHandle as string | null) || null,
- }))
+ // Runs after the block insert above, so filterEdgesForPersist's
+ // blocksById lookup (a plain `tx.select` from `workflowBlocks`) also
+ // sees the blocks this same batch just inserted — reads observe a
+ // transaction's own prior writes.
+ const candidates: EdgeAddCandidate[] = (edges as Array>).map(
+ (e) => ({
+ id: e.id as string,
+ source: e.source as string,
+ target: e.target as string,
+ sourceHandle: (e.sourceHandle as string | null) ?? null,
+ targetHandle: (e.targetHandle as string | null) ?? null,
+ })
+ )
- await tx
- .insert(workflowEdges)
- .values(edgeValues)
- .onConflictDoUpdate({
- target: workflowEdges.id,
- set: {
- sourceBlockId: sql`excluded.source_block_id`,
- targetBlockId: sql`excluded.target_block_id`,
- sourceHandle: sql`excluded.source_handle`,
- targetHandle: sql`excluded.target_handle`,
- },
+ const { safeEdges, droppedCounts, droppedDuplicates, droppedCyclic } =
+ await filterEdgesForPersist(tx, workflowId, candidates)
+
+ if (safeEdges.length < edges.length) {
+ logger.info(`Dropped ${edges.length - safeEdges.length} invalid edge(s)`, {
+ droppedCounts,
+ droppedDuplicates,
+ droppedCyclic,
})
+ }
+
+ if (safeEdges.length > 0) {
+ const edgeValues = safeEdges.map((edge) => ({
+ id: edge.id as string,
+ workflowId,
+ sourceBlockId: edge.source,
+ targetBlockId: edge.target,
+ sourceHandle: edge.sourceHandle || null,
+ targetHandle: edge.targetHandle || null,
+ }))
+
+ await tx
+ .insert(workflowEdges)
+ .values(edgeValues)
+ .onConflictDoUpdate({
+ target: workflowEdges.id,
+ set: {
+ sourceBlockId: sql`excluded.source_block_id`,
+ targetBlockId: sql`excluded.target_block_id`,
+ sourceHandle: sql`excluded.source_handle`,
+ targetHandle: sql`excluded.target_handle`,
+ },
+ })
+ }
}
if (loops && Object.keys(loops).length > 0) {
@@ -1282,57 +1507,28 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
throw new Error('Missing required fields for add edge operation')
}
- const edgeBlocks = await tx
- .select({
- id: workflowBlocks.id,
- locked: workflowBlocks.locked,
- data: workflowBlocks.data,
- })
- .from(workflowBlocks)
- .where(
- and(
- eq(workflowBlocks.workflowId, workflowId),
- inArray(workflowBlocks.id, [payload.source, payload.target])
- )
- )
-
- type EdgeBlockRecord = (typeof edgeBlocks)[number]
- const blocksById: Record = Object.fromEntries(
- edgeBlocks.map((b: EdgeBlockRecord) => [b.id, b])
+ const { safeEdges, droppedCounts, droppedDuplicates } = await filterEdgesForPersist(
+ tx,
+ workflowId,
+ [
+ {
+ id: payload.id,
+ source: payload.source,
+ target: payload.target,
+ sourceHandle: payload.sourceHandle ?? null,
+ targetHandle: payload.targetHandle ?? null,
+ },
+ ]
)
- const parentIds = new Set()
- for (const block of edgeBlocks) {
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && !blocksById[parentId]) {
- parentIds.add(parentId)
- }
- }
-
- // Fetch parent blocks if needed
- if (parentIds.size > 0) {
- const parentBlocks = await tx
- .select({
- id: workflowBlocks.id,
- locked: workflowBlocks.locked,
- data: workflowBlocks.data,
- })
- .from(workflowBlocks)
- .where(
- and(
- eq(workflowBlocks.workflowId, workflowId),
- inArray(workflowBlocks.id, Array.from(parentIds))
- )
- )
- for (const b of parentBlocks) {
- blocksById[b.id] = b
- }
- }
-
- if (isWorkflowBlockProtected(payload.target, blocksById)) {
- logger.info(`Skipping edge add - target block is protected`)
+ if (safeEdges.length === 0) {
+ const [structuralDropReason] = Object.keys(droppedCounts)
+ const reason = structuralDropReason
+ ? structuralDropReason
+ : droppedDuplicates > 0
+ ? 'duplicate edge already exists'
+ : `would create a cycle: ${payload.source} -> ${payload.target}`
+ logger.info(`Skipping edge add - ${reason}`)
break
}
@@ -1557,81 +1753,37 @@ async function handleEdgesOperationTx(
logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`)
- // Get all connected block IDs to check lock status
- const connectedBlockIds = new Set()
- edges.forEach((e: Record) => {
- connectedBlockIds.add(e.source as string)
- connectedBlockIds.add(e.target as string)
- })
-
- // Fetch blocks to check lock status
- const connectedBlocks = await tx
- .select({
- id: workflowBlocks.id,
- locked: workflowBlocks.locked,
- data: workflowBlocks.data,
- })
- .from(workflowBlocks)
- .where(
- and(
- eq(workflowBlocks.workflowId, workflowId),
- inArray(workflowBlocks.id, Array.from(connectedBlockIds))
- )
- )
-
- type AddEdgeBlockRecord = (typeof connectedBlocks)[number]
- const blocksById: Record = Object.fromEntries(
- connectedBlocks.map((b: AddEdgeBlockRecord) => [b.id, b])
- )
+ const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => ({
+ id: e.id as string,
+ source: e.source as string,
+ target: e.target as string,
+ sourceHandle: (e.sourceHandle as string | null) ?? null,
+ targetHandle: (e.targetHandle as string | null) ?? null,
+ }))
- // Collect parent IDs that need to be fetched
- const parentIds = new Set()
- for (const block of connectedBlocks) {
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && !blocksById[parentId]) {
- parentIds.add(parentId)
- }
- }
+ const { safeEdges, droppedCounts, droppedDuplicates, droppedCyclic } =
+ await filterEdgesForPersist(tx, workflowId, candidates)
- // Fetch parent blocks if needed
- if (parentIds.size > 0) {
- const parentBlocks = await tx
- .select({
- id: workflowBlocks.id,
- locked: workflowBlocks.locked,
- data: workflowBlocks.data,
- })
- .from(workflowBlocks)
- .where(
- and(
- eq(workflowBlocks.workflowId, workflowId),
- inArray(workflowBlocks.id, Array.from(parentIds))
- )
- )
- for (const b of parentBlocks) {
- blocksById[b.id] = b
- }
+ if (safeEdges.length < edges.length) {
+ logger.info(`Dropped ${edges.length - safeEdges.length} invalid edge(s)`, {
+ droppedCounts,
+ droppedDuplicates,
+ droppedCyclic,
+ })
}
- // Filter edges - only add edges where target block is not protected
- const safeEdges = (edges as Array>).filter(
- (e) => !isWorkflowBlockProtected(e.target as string, blocksById)
- )
-
if (safeEdges.length === 0) {
- logger.info('All edges target protected blocks, skipping add')
+ logger.info('All edges were invalid, duplicate, or would create a cycle, skipping add')
return
}
- const edgeValues = safeEdges.map((edge: Record) => ({
+ const edgeValues = safeEdges.map((edge) => ({
id: edge.id as string,
workflowId,
- sourceBlockId: edge.source as string,
- targetBlockId: edge.target as string,
- sourceHandle: (edge.sourceHandle as string | null) || null,
- targetHandle: (edge.targetHandle as string | null) || null,
+ sourceBlockId: edge.source,
+ targetBlockId: edge.target,
+ sourceHandle: edge.sourceHandle || null,
+ targetHandle: edge.targetHandle || null,
}))
await tx
diff --git a/apps/sim/.env.example b/apps/sim/.env.example
index 4536f8a8638..9366575fd54 100644
--- a/apps/sim/.env.example
+++ b/apps/sim/.env.example
@@ -77,6 +77,9 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# PEOPLEDATALABS_API_KEY_COUNT=2 # Number of People Data Labs keys for hosted PDL blocks
# PEOPLEDATALABS_API_KEY_1= # People Data Labs API key #1
# PEOPLEDATALABS_API_KEY_2= # People Data Labs API key #2
+# CONTEXT_DEV_API_KEY_COUNT=2 # Number of Context.dev keys for hosted Context.dev blocks
+# CONTEXT_DEV_API_KEY_1= # Context.dev API key #1
+# CONTEXT_DEV_API_KEY_2= # Context.dev API key #2
# File Storage (Optional - defaults to local disk; use S3 or Azure Blob for production)
# AWS_REGION=us-east-1 # Required with S3_BUCKET_NAME to enable S3. Use "auto" for Cloudflare R2
diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx
index 0607613b4e1..b0849740fba 100644
--- a/apps/sim/app/(landing)/components/footer/footer.tsx
+++ b/apps/sim/app/(landing)/components/footer/footer.tsx
@@ -27,6 +27,7 @@ interface FooterItem {
}
const PRODUCT_LINKS: FooterItem[] = [
+ { label: 'Enterprise', href: '/enterprise' },
{ label: 'Mothership', href: 'https://docs.sim.ai/mothership', external: true },
{ label: 'Workflows', href: 'https://docs.sim.ai', external: true },
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx b/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx
new file mode 100644
index 00000000000..c2e9b566956
--- /dev/null
+++ b/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from 'react'
+import { cn } from '@sim/emcn'
+import { HeroStat } from '@/app/(landing)/components/hero/components/hero-stat'
+import { HeroCta } from '@/app/(landing)/components/hero-cta'
+import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout'
+
+interface LandingHeroHeaderProps {
+ description: string
+ eyebrow?: ReactNode
+ heading: ReactNode
+ headingId: string
+}
+
+/**
+ * Shared homepage hero header geometry. Marketing routes use this component so
+ * the headline measure, CTA stack, and right-side global-work stat cannot drift.
+ */
+export function LandingHeroHeader({
+ description,
+ eyebrow,
+ heading,
+ headingId,
+}: LandingHeroHeaderProps) {
+ return (
+
+
+ {eyebrow}
+
+
+ {heading}
+
+
+
+ {description}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-header/index.ts b/apps/sim/app/(landing)/components/hero/components/hero-header/index.ts
new file mode 100644
index 00000000000..8d84bd58bca
--- /dev/null
+++ b/apps/sim/app/(landing)/components/hero/components/hero-header/index.ts
@@ -0,0 +1 @@
+export { LandingHeroHeader } from './hero-header'
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx
index 3265e49e389..20d1ffbb057 100644
--- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx
+++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx
@@ -10,7 +10,10 @@ import {
STAGE_EDGES,
verticalSmoothStep,
} from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
-import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
+import {
+ BLOCK_WIDTH,
+ type BlockDef,
+} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
/** Upper bound on the canvas render scale (the scale at the full 1300px cap). */
const MAX_STAGE_SCALE = 0.71
@@ -18,12 +21,16 @@ const MAX_STAGE_SCALE = 0.71
const STAGE_MARGIN = 20
interface HeroWorkflowStageProps {
- /** How many of {@link STAGE_BLOCKS} (in build order) are on canvas. */
+ /** How many of the stage's blocks (in build order) are on canvas. */
builtCount: number
+ /** Blocks to stage, in build order. Defaults to the homepage's lead flow. */
+ blocks?: BlockDef[]
+ /** Source → target pairs among {@link blocks}. Defaults with them. */
+ edges?: ReadonlyArray
+ /** Design-space bounding box of the block layout. Defaults with them. */
+ canvas?: { width: number; height: number }
}
-const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b]))
-
/**
* The hero window's live workflow canvas - the right-pane counterpart of the
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
@@ -38,10 +45,20 @@ const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b]))
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
* (top in / bottom out), matching the real editor's vertical layout.
+ *
+ * The staged flow is injectable (`blocks`/`edges`/`canvas`), defaulting to the
+ * homepage's lead-enrichment flow - the enterprise loop stages its own flow
+ * through the same component.
*/
-export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
+export function HeroWorkflowStage({
+ builtCount,
+ blocks = STAGE_BLOCKS,
+ edges = STAGE_EDGES,
+ canvas = STAGE_CANVAS,
+}: HeroWorkflowStageProps) {
const containerRef = useRef(null)
const [scale, setScale] = useState(MAX_STAGE_SCALE)
+ const blocksById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks])
// Fit the design canvas to the card: scale down when the pane narrows so the
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
@@ -58,8 +75,8 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
setScale(
Math.min(
MAX_STAGE_SCALE,
- (w - STAGE_MARGIN) / STAGE_CANVAS.width,
- (h - STAGE_MARGIN) / STAGE_CANVAS.height
+ (w - STAGE_MARGIN) / canvas.width,
+ (h - STAGE_MARGIN) / canvas.height
)
)
}
@@ -67,11 +84,11 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
- }, [])
+ }, [canvas.width, canvas.height])
const builtIds = useMemo(
- () => new Set(STAGE_BLOCKS.slice(0, builtCount).map((b) => b.id)),
- [builtCount]
+ () => new Set(blocks.slice(0, builtCount).map((b) => b.id)),
+ [blocks, builtCount]
)
return (
@@ -82,30 +99,30 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
- {STAGE_EDGES.map(([from, to]) => {
- const source = STAGE_BLOCKS_BY_ID.get(from)
- const target = STAGE_BLOCKS_BY_ID.get(to)
+ {edges.map(([from, to]) => {
+ const source = blocksById.get(from)
+ const target = blocksById.get(to)
if (!source || !target) return null
const visible = builtIds.has(from) && builtIds.has(to)
const s = handleAnchors(source).out
@@ -125,7 +142,7 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
})}
- {STAGE_BLOCKS.map((block) => {
+ {blocks.map((block) => {
const built = builtIds.has(block.id)
return (
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect
@@ -72,25 +82,16 @@ export function Hero() {
production-ready for teams of every size.
-
-
-
+
Sim is your AI workspace
for building agentic workflows.
-
-
-
- The open-source workspace where teams build, deploy, and manage AI agents.
-
-
-
-
-
-
-
+ >
+ }
+ description='The open-source workspace where teams build, deploy, and manage AI agents.'
+ />
setOpen(false)}
>
Log in
diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx
index 852fd78d9f6..27ace3935de 100644
--- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx
+++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx
@@ -37,13 +37,14 @@ interface NavbarShellProps {
* Sticky navbar chrome that frosts to glass once the page scrolls (or, on mobile,
* while the dropdown menu is open).
*
- * At the very top the bar is transparent, seamless over the hero canvas. A 1px
- * sentinel at the top of the scroll content is watched by an
- * {@link IntersectionObserver} - no scroll listener (landing perf rules) and no
- * per-frame work; it fires once when the sentinel leaves the viewport. Past that
- * point the bar gains the shared {@link NAVBAR_GLASS_SURFACE} (`--bg` at 92% via
- * `color-mix` plus a strong 40px backdrop blur) - a white/glass surface built
- * entirely from the platform's light tokens, not invented colors.
+ * At the very top the bar uses the same solid canvas token as the hero, so it is
+ * visually seamless while still preventing route content from painting through
+ * the sticky header. A 1px sentinel at the top of the landing shell's internal
+ * scroll port is watched by an {@link IntersectionObserver} - no scroll listener
+ * and no per-frame work. Past that point the bar gains the shared
+ * {@link NAVBAR_GLASS_SURFACE} (`--bg` at 92% via `color-mix` plus a strong 40px
+ * backdrop blur) - a white/glass surface built entirely from the platform's
+ * light tokens, not invented colors.
*
* Only `background-color` is transitioned, NOT `backdrop-filter`: animating the
* blur re-runs every time the threshold is re-crossed, which on mobile reads as a
@@ -78,7 +79,12 @@ export function NavbarShell({ children }: NavbarShellProps) {
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
- const observer = new IntersectionObserver(([entry]) => setScrolled(!entry.isIntersecting))
+ const scrollPort = sentinel.parentElement
+ if (!scrollPort) return
+
+ const observer = new IntersectionObserver(([entry]) => setScrolled(!entry.isIntersecting), {
+ root: scrollPort,
+ })
observer.observe(sentinel)
return () => observer.disconnect()
}, [])
@@ -92,8 +98,8 @@ export function NavbarShell({ children }: NavbarShellProps) {
{children}
diff --git a/apps/sim/app/(landing)/components/navbar/navbar.tsx b/apps/sim/app/(landing)/components/navbar/navbar.tsx
index e0fcbc8f9c9..eeb48979949 100644
--- a/apps/sim/app/(landing)/components/navbar/navbar.tsx
+++ b/apps/sim/app/(landing)/components/navbar/navbar.tsx
@@ -74,6 +74,9 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
{NAV_MENUS.map((menu) => (
))}
+
+ Enterprise
+
Pricing
@@ -84,7 +87,7 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
Log in
-
+
Contact sales
diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx
index f52250eef1e..5dabef8f612 100644
--- a/apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx
+++ b/apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx
@@ -1,5 +1,6 @@
import { cn } from '@sim/emcn'
import { HeroCta } from '@/app/(landing)/components/hero-cta'
+import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout'
import { PlatformVisualFrame } from '@/app/(landing)/components/platform-page/components/platform-visual-frame'
import { PLATFORM_SPACING } from '@/app/(landing)/components/platform-page/constants'
import type { PlatformHeroConfig } from '@/app/(landing)/components/platform-page/types'
@@ -49,7 +50,9 @@ export function PlatformHero({ hero }: PlatformHeroProps) {
{hero.description}
-
+
+
+
{hero.visual}
diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx
index ad072672645..0a173ba0231 100644
--- a/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx
+++ b/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx
@@ -9,11 +9,16 @@ import { Logos } from '@/app/(landing)/components/logos'
*
* Wrapped as a labelled `
` so it is a discrete, crawlable landmark; the
* heading is sr-only because the logos are a proof band rather than a content
- * section, but the H2 keeps the page's heading hierarchy intact.
+ * section, but the H2 keeps the page's heading hierarchy intact. The section is
+ * `relative` so the `sr-only` (`position: absolute`) heading is contained by it
+ * rather than falling back to the document root - matching {@link Features}'s
+ * `relative` wrapper for its own `sr-only` heading, and avoiding a phantom root
+ * scrollbar (the heading's un-offset static position would otherwise inflate
+ * `document.documentElement`'s scroll height by its own position in the page).
*/
export function PlatformLogosRow() {
return (
-
diff --git a/apps/sim/app/(landing)/landing.tsx b/apps/sim/app/(landing)/landing.tsx
index 60e4eb83b54..5eabaaaeb2c 100644
--- a/apps/sim/app/(landing)/landing.tsx
+++ b/apps/sim/app/(landing)/landing.tsx
@@ -1,3 +1,4 @@
+import { cn } from '@sim/emcn'
import {
Cta,
Features,
@@ -6,6 +7,7 @@ import {
Mothership,
ProductDemo,
} from '@/app/(landing)/components'
+import { LANDING_SECTION_RHYTHM } from '@/app/(landing)/components/landing-layout'
/**
* Landing page root - owns the section order and the `` content region.
@@ -23,7 +25,7 @@ import {
*/
export default function Landing() {
return (
-
+
diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx
index 7e320fad1b8..740deccce9f 100644
--- a/apps/sim/app/(landing)/layout.tsx
+++ b/apps/sim/app/(landing)/layout.tsx
@@ -1,7 +1,13 @@
import type { ReactNode } from 'react'
+import { Suspense } from 'react'
import type { Metadata } from 'next'
+import Script from 'next/script'
+import { isHosted } from '@/lib/core/config/env-flags'
import { SITE_URL } from '@/lib/core/utils/urls'
import { LandingShell } from '@/app/(landing)/components'
+import { HubspotPageViewTracker } from '@/app/(landing)/hubspot-page-view-tracker'
+
+const HUBSPOT_SCRIPT_SRC = 'https://js-na2.hs-scripts.com/246720681.js' as const
/**
* Route-group layout for the entire landing family - the home page, platform and
@@ -24,5 +30,18 @@ export const metadata: Metadata = {
}
export default function LandingLayout({ children }: { children: ReactNode }) {
- return {children}
+ return (
+
+ {children}
+ {/* HubSpot tracking — hosted only */}
+ {isHosted && (
+ <>
+
+
+
+
+ >
+ )}
+
+ )
}
diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx
index 76c7a06e342..3c59189fc90 100644
--- a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx
+++ b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx
@@ -5,6 +5,7 @@ import { SITE_URL } from '@/lib/core/utils/urls'
import { BackLink } from '@/app/(landing)/components'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
+import { ShareButton } from '@/app/(landing)/components/share-button'
import { FeaturedModelCard, ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
import {
ALL_CATALOG_MODELS,
@@ -181,6 +182,7 @@ export default async function ModelPage({
All {provider.name} models
+
diff --git a/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx b/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx
index 36ba0f43178..3d4f3111728 100644
--- a/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx
+++ b/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx
@@ -8,10 +8,11 @@ import {
MODEL_CATALOG_PROVIDERS,
} from '@/app/(landing)/models/utils'
-/** Providers that host other providers' models - deprioritized to avoid duplicates. */
-const RESELLER_PROVIDERS = new Set(
- MODEL_CATALOG_PROVIDERS.flatMap((p) => (p.isReseller ? [p.id] : []))
-)
+/** Flagship providers featured in the landing-page comparison, in display order. */
+const FEATURED_COMPARISON_PROVIDER_IDS = ['anthropic', 'openai', 'google']
+
+/** Max latest models pulled from each featured provider. */
+const MAX_MODELS_PER_PROVIDER = 4
const PROVIDER_ICON_MAP: Record> = (() => {
const map: Record> = {}
@@ -27,22 +28,22 @@ function selectComparisonModels(models: CatalogModel[]): CatalogModel[] {
const seen = new Set()
const result: CatalogModel[] = []
- const sorted = [...models].sort((a, b) => {
- const score = (m: CatalogModel) => {
- const reseller = RESELLER_PROVIDERS.has(m.providerId) ? -50 : 0
- const reasoning = m.capabilities.reasoningEffort || m.capabilities.thinking ? 10 : 0
- const context = (m.contextWindow ?? 0) / 100000
- return reseller + reasoning + context
+ for (const providerId of FEATURED_COMPARISON_PROVIDER_IDS) {
+ const providerModels = models
+ .filter((model) => model.providerId === providerId && !model.deprecated)
+ .sort((a, b) => (b.releaseDate ?? '').localeCompare(a.releaseDate ?? ''))
+
+ let takenForProvider = 0
+ for (const model of providerModels) {
+ if (takenForProvider >= MAX_MODELS_PER_PROVIDER) break
+
+ const nameKey = model.displayName.toLowerCase()
+ if (seen.has(nameKey)) continue
+
+ seen.add(nameKey)
+ result.push(model)
+ takenForProvider += 1
}
- return score(b) - score(a)
- })
-
- for (const model of sorted) {
- if (result.length >= 10) break
- const nameKey = model.displayName.toLowerCase()
- if (seen.has(nameKey)) continue
- seen.add(nameKey)
- result.push(model)
}
return result
diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts
index e35e9a67d95..6f54de56b6c 100644
--- a/apps/sim/app/(landing)/models/utils.ts
+++ b/apps/sim/app/(landing)/models/utils.ts
@@ -122,6 +122,7 @@ export interface CatalogModel {
providerSlug: string
contextWindow: number | null
releaseDate: string | null
+ deprecated: boolean
pricing: PricingInfo
capabilities: ModelCapabilities
capabilityTags: string[]
@@ -482,6 +483,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
providerSlug,
contextWindow: model.contextWindow ?? null,
releaseDate: model.releaseDate ?? null,
+ deprecated: model.deprecated ?? false,
pricing: model.pricing,
capabilities: mergedCapabilities,
capabilityTags,
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index 91b4d55ea4b..ccc557389cb 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -11,15 +11,13 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
import {
- getAtlassianServiceAccountSecret,
getCredential,
getOAuthToken,
- getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
+ resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -170,25 +168,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
try {
- if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
- const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
- emitServiceAccountAccess()
- return NextResponse.json(
- {
- accessToken: secret.apiToken,
- cloudId: secret.cloudId,
- domain: secret.domain,
- },
- { status: 200 }
- )
- }
- const accessToken = await getServiceAccountToken(
+ const result = await resolveServiceAccountToken(
resolved.credentialId,
+ resolved.providerId,
scopes ?? [],
impersonateEmail
)
emitServiceAccountAccess()
- return NextResponse.json({ accessToken }, { status: 200 })
+ return NextResponse.json(
+ {
+ accessToken: result.accessToken,
+ cloudId: result.cloudId,
+ domain: result.domain,
+ },
+ { status: 200 }
+ )
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts
index 78fdecf2975..77457cf3328 100644
--- a/apps/sim/app/api/auth/oauth/utils.test.ts
+++ b/apps/sim/app/api/auth/oauth/utils.test.ts
@@ -14,13 +14,25 @@ vi.mock('@/lib/oauth/oauth', () => ({
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
+const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
+vi.mock('@/lib/core/security/encryption', () => ({
+ decryptSecret: mockDecryptSecret,
+ encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })),
+}))
+
import { db } from '@sim/db'
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
import { refreshOAuthToken } from '@/lib/oauth'
+import {
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ SLACK_CUSTOM_BOT_PROVIDER_ID,
+} from '@/lib/oauth/types'
import {
getCredential,
refreshAccessTokenIfNeeded,
refreshTokenIfNeeded,
+ resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'
const mockDb = db as any
@@ -262,4 +274,61 @@ describe('OAuth Utils', () => {
expect(token).toBeNull()
})
})
+
+ describe('resolveServiceAccountToken', () => {
+ it('throws loudly for an unknown provider (never silently attempts Google)', async () => {
+ await expect(resolveServiceAccountToken('cred-1', 'mystery-provider')).rejects.toThrow(
+ /Unsupported service-account provider/
+ )
+ })
+
+ it('returns the decrypted bot token for a custom Slack bot', async () => {
+ mockSelectChain([
+ {
+ type: 'service_account',
+ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
+ encryptedServiceAccountKey: 'enc',
+ },
+ ])
+ mockDecryptSecret.mockResolvedValueOnce({
+ decrypted: JSON.stringify({ signingSecret: 's', botToken: 'xoxb-tok', teamId: 'T1' }),
+ })
+ const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
+ expect(result.accessToken).toBe('xoxb-tok')
+ })
+
+ it('throws when the Slack bot credential is missing', async () => {
+ mockSelectChain([])
+ await expect(
+ resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
+ ).rejects.toThrow(/Slack bot credential not found/)
+ })
+
+ it('returns apiToken + cloudId + domain for Atlassian', async () => {
+ mockSelectChain([{ encryptedServiceAccountKey: 'enc' }])
+ mockDecryptSecret.mockResolvedValueOnce({
+ decrypted: JSON.stringify({
+ type: 'atlassian_service_account',
+ apiToken: 'atk',
+ domain: 'acme.atlassian.net',
+ cloudId: 'cloud-1',
+ }),
+ })
+ const result = await resolveServiceAccountToken(
+ 'cred-1',
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
+ )
+ expect(result).toMatchObject({
+ accessToken: 'atk',
+ cloudId: 'cloud-1',
+ domain: 'acme.atlassian.net',
+ })
+ })
+
+ it('requires scopes for a Google service account', async () => {
+ await expect(
+ resolveServiceAccountToken('cred-1', GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, [])
+ ).rejects.toThrow(/Scopes are required/)
+ })
+ })
})
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index 7574b789443..367821cb7d0 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -21,6 +21,8 @@ import {
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
const logger = createLogger('OAuthUtilsAPI')
@@ -222,6 +224,64 @@ export async function getServiceAccountToken(
return tokenData.access_token
}
+export interface SlackBotCredentialSecrets {
+ signingSecret: string
+ botToken: string
+ teamId: string
+ botUserId?: string
+ teamName?: string
+ /** Owning workspace — callers with a user/workflow context must verify it. */
+ workspaceId: string | null
+}
+
+/**
+ * Decrypt a reusable custom Slack bot credential — a `service_account` credential
+ * with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own
+ * app's signing secret + bot token + derived team_id/bot_user_id. Returns null if
+ * the id is not such a credential (or its blob is incomplete).
+ *
+ * @remarks Server-internal. The native custom ingest route authenticates each
+ * request via the app's signing secret (not a user session), so this reader does
+ * no per-user authorization; callers with a user context authorize separately.
+ */
+export async function getSlackBotCredential(
+ credentialId: string
+): Promise {
+ const [row] = await db
+ .select({
+ type: credential.type,
+ providerId: credential.providerId,
+ encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
+ workspaceId: credential.workspaceId,
+ })
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ .limit(1)
+
+ if (
+ !row ||
+ row.type !== 'service_account' ||
+ row.providerId !== SLACK_CUSTOM_BOT_PROVIDER_ID ||
+ !row.encryptedServiceAccountKey
+ ) {
+ return null
+ }
+
+ const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
+ const blob = JSON.parse(decrypted) as Partial
+ if (!blob.signingSecret || !blob.botToken || !blob.teamId) {
+ return null
+ }
+ return {
+ signingSecret: blob.signingSecret,
+ botToken: blob.botToken,
+ teamId: blob.teamId,
+ botUserId: blob.botUserId,
+ teamName: blob.teamName,
+ workspaceId: row.workspaceId ?? null,
+ }
+}
+
interface AtlassianServiceAccountSecret {
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
apiToken: string
@@ -264,9 +324,45 @@ export async function getAtlassianServiceAccountSecret(
* blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
* No exchange or refresh is needed; we just decrypt and return the raw token.
*/
-async function getAtlassianServiceAccountToken(credentialId: string): Promise {
- const secret = await getAtlassianServiceAccountSecret(credentialId)
- return secret.apiToken
+export interface ServiceAccountTokenResult {
+ accessToken: string
+ /** Atlassian only — the resolved Jira/Confluence cloud id. */
+ cloudId?: string
+ /** Atlassian only — the site domain. */
+ domain?: string
+}
+
+/**
+ * Single dispatch point for turning a `service_account` credential into an
+ * access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
+ * `POST /api/auth/oauth/token` route go through here, so a new service-account
+ * provider is one edit and an unknown provider fails loudly instead of silently
+ * attempting a Google JWT.
+ */
+export async function resolveServiceAccountToken(
+ credentialId: string,
+ providerId: string | null | undefined,
+ scopes?: string[],
+ impersonateEmail?: string
+): Promise {
+ if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
+ const secret = await getAtlassianServiceAccountSecret(credentialId)
+ return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
+ }
+ if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
+ const botCredential = await getSlackBotCredential(credentialId)
+ if (!botCredential) {
+ throw new Error('Slack bot credential not found')
+ }
+ return { accessToken: botCredential.botToken }
+ }
+ if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
+ if (!scopes?.length) {
+ throw new Error('Scopes are required for service account credentials')
+ }
+ return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
+ }
+ throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
}
/**
@@ -511,15 +607,14 @@ export async function refreshAccessTokenIfNeeded(
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
- if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
- logger.info(`[${requestId}] Using Atlassian service account token for credential`)
- return getAtlassianServiceAccountToken(resolved.credentialId)
- }
- if (!scopes?.length) {
- throw new Error('Scopes are required for service account credentials')
- }
logger.info(`[${requestId}] Using service account token for credential`)
- return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
+ const { accessToken } = await resolveServiceAccountToken(
+ resolved.credentialId,
+ resolved.providerId,
+ scopes,
+ impersonateEmail
+ )
+ return accessToken
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts
index 3dc507ed5ae..19f8ba89f1d 100644
--- a/apps/sim/app/api/credentials/[id]/route.ts
+++ b/apps/sim/app/api/credentials/[id]/route.ts
@@ -82,6 +82,10 @@ export const PUT = withRouteHandler(
displayName: body.displayName,
description: body.description,
serviceAccountJson: body.serviceAccountJson,
+ signingSecret: body.signingSecret,
+ botToken: body.botToken,
+ apiToken: body.apiToken,
+ domain: body.domain,
request,
})
if (!result.success) {
@@ -95,7 +99,13 @@ export const PUT = withRouteHandler(
: result.errorCode === 'validation'
? 400
: 500
- return NextResponse.json({ error: result.error }, { status })
+ return NextResponse.json(
+ {
+ error: result.error,
+ ...(result.providerErrorCode ? { code: result.providerErrorCode } : {}),
+ },
+ { status }
+ )
}
const access = await getCredentialActorContext(id, session.user.id)
diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts
index 318b905987b..db1dc91c47c 100644
--- a/apps/sim/app/api/credentials/route.ts
+++ b/apps/sim/app/api/credentials/route.ts
@@ -10,11 +10,9 @@ import {
createWorkspaceCredentialContract,
credentialsListGetQuerySchema,
normalizeCredentialEnvKey,
- serviceAccountJsonSchema,
} from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
@@ -22,18 +20,15 @@ import {
isSharedCredentialType,
SHARED_CREDENTIAL_TYPES,
} from '@/lib/credentials/access'
-import {
- AtlassianValidationError,
- normalizeAtlassianDomain,
- validateAtlassianServiceAccount,
-} from '@/lib/credentials/atlassian-service-account'
+import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
import { getWorkspaceMembership } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
-import { getServiceConfigByProviderId } from '@/lib/oauth'
import {
- ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
- ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
-} from '@/lib/oauth/types'
+ ServiceAccountSecretError,
+ verifyAndBuildServiceAccountSecret,
+} from '@/lib/credentials/service-account-secret'
+import { getServiceConfigByProviderId } from '@/lib/oauth'
+import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -314,6 +309,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
serviceAccountJson,
apiToken,
domain,
+ id: clientCredentialId,
+ signingSecret,
+ botToken,
} = parsed.data.body
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
@@ -364,68 +362,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'service_account') {
- if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
- if (!apiToken || !domain) {
- return NextResponse.json(
- { error: 'apiToken and domain are required for Atlassian service account credentials' },
- { status: 400 }
- )
- }
-
- const normalizedDomain = normalizeAtlassianDomain(domain)
- const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
-
- resolvedProviderId = ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
- resolvedAccountId = null
- resolvedEnvOwnerUserId = null
-
- if (!resolvedDisplayName) {
- resolvedDisplayName = validation.displayName
- }
-
- const blob = JSON.stringify({
- type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+ try {
+ const secret = await verifyAndBuildServiceAccountSecret(providerId ?? '', {
+ signingSecret,
+ botToken,
apiToken,
- domain: normalizedDomain,
- cloudId: validation.cloudId,
- atlassianAccountId: validation.accountId,
+ domain,
+ serviceAccountJson,
})
- const { encrypted } = await encryptSecret(blob)
- resolvedEncryptedServiceAccountKey = encrypted
- extraAuditMetadata.atlassianDomain = normalizedDomain
- extraAuditMetadata.atlassianCloudId = validation.cloudId
- } else {
- if (!serviceAccountJson) {
- return NextResponse.json(
- { error: 'serviceAccountJson is required for service account credentials' },
- { status: 400 }
- )
- }
-
- const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
- if (!jsonParseResult.success) {
- return NextResponse.json(
- {
- error: getValidationErrorMessage(
- jsonParseResult.error,
- 'Invalid service account JSON'
- ),
- },
- { status: 400 }
- )
- }
-
- const parsedKey = jsonParseResult.data
- resolvedProviderId = 'google-service-account'
+ resolvedProviderId = secret.providerId
resolvedAccountId = null
resolvedEnvOwnerUserId = null
-
if (!resolvedDisplayName) {
- resolvedDisplayName = parsedKey.client_email
+ resolvedDisplayName = secret.displayName
}
-
- const { encrypted } = await encryptSecret(serviceAccountJson)
- resolvedEncryptedServiceAccountKey = encrypted
+ resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey
+ Object.assign(extraAuditMetadata, secret.auditMetadata)
+ } catch (error) {
+ if (error instanceof ServiceAccountSecretError) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ throw error
}
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
@@ -460,6 +417,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (existingCredential) {
+ // A retried custom-bot create with the SAME pre-generated id is an
+ // idempotent replay and falls through to the normal existing-credential
+ // path. Any other name collision must fail loudly: returning the existing
+ // row as success would orphan the new id already embedded in the user's
+ // Slack Request URL (Slack would post to a URL no credential resolves).
+ if (
+ resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID &&
+ clientCredentialId &&
+ existingCredential.id !== clientCredentialId
+ ) {
+ return NextResponse.json(
+ {
+ code: 'duplicate_display_name',
+ error: `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`,
+ },
+ { status: 409 }
+ )
+ }
+
const access = await getCredentialActorContext(existingCredential.id, session.user.id, {
workspaceAccess,
})
@@ -506,7 +482,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const now = new Date()
- const credentialId = generateId()
+ // Honor a client-supplied id only for custom Slack bots — the setup modal
+ // shows the ingest URL `/api/webhooks/slack/custom/{id}` before secrets exist.
+ const credentialId =
+ resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId
+ ? clientCredentialId
+ : generateId()
const { ownerId: workspaceOwnerId, memberUserIds: workspaceMemberUserIds } =
await getWorkspaceMembership(workspaceId)
diff --git a/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts
new file mode 100644
index 00000000000..11c241986e1
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts
@@ -0,0 +1,35 @@
+import { NextResponse } from 'next/server'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { getCustomBlockManageContext } from '@/lib/workflows/custom-blocks/operations'
+import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
+
+export type ManageContext = NonNullable>>
+
+/**
+ * Confirm the caller can manage (edit/delete) the block: admin of the block's
+ * SOURCE workflow's workspace — matching who could publish it. Org admins/owners
+ * hold admin on every org workspace, so they pass too; a workspace admin from a
+ * different workspace does not, so they cannot alter another workspace's block or
+ * its exposed outputs.
+ */
+export async function authorizeManage(
+ userId: string,
+ id: string
+): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
+ const ctx = await getCustomBlockManageContext(id)
+ if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
+ return {
+ error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
+ ctx: null,
+ }
+ }
+ if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
+ return {
+ error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
+ ctx: null,
+ }
+ }
+ return { error: null, ctx }
+}
diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts
index 14d7d06ee9f..474d8ea273f 100644
--- a/apps/sim/app/api/custom-blocks/[id]/route.ts
+++ b/apps/sim/app/api/custom-blocks/[id]/route.ts
@@ -9,51 +9,19 @@ import {
} from '@/lib/api/contracts/custom-blocks'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
CustomBlockValidationError,
deleteCustomBlock,
- getCustomBlockManageContext,
+ getCustomBlockUsageCounts,
updateCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
-import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
+import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'
const logger = createLogger('CustomBlockAPI')
type RouteContext = { params: Promise<{ id: string }> }
-/**
- * Confirm the caller can manage (edit/delete) the block: admin of the block's
- * SOURCE workflow's workspace — matching who could publish it. Org admins/owners
- * hold admin on every org workspace, so they pass too; a workspace admin from a
- * different workspace does not, so they cannot alter another workspace's block or
- * its exposed outputs.
- */
-type ManageContext = NonNullable>>
-
-async function authorizeManage(
- userId: string,
- id: string
-): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
- const ctx = await getCustomBlockManageContext(id)
- if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }
-
- if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
- return {
- error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
- ctx: null,
- }
- }
- if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
- return {
- error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
- ctx: null,
- }
- }
- return { error: null, ctx }
-}
-
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
@@ -115,6 +83,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
if (authz.error) return authz.error
const { ctx } = authz
+ const usageCounts = await getCustomBlockUsageCounts(ctx.organizationId, ctx.type)
await deleteCustomBlock(id)
recordAudit({
workspaceId: ctx.sourceWorkspaceId,
@@ -126,7 +95,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
resourceId: id,
resourceName: ctx.name,
description: `Unpublished custom block "${ctx.name}"`,
- metadata: { organizationId: ctx.organizationId, type: ctx.type },
+ metadata: {
+ organizationId: ctx.organizationId,
+ type: ctx.type,
+ usageCount: usageCounts.usageCount,
+ deployedUsageCount: usageCounts.deployedUsageCount,
+ },
request,
})
return NextResponse.json({ success: true as const })
diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts
new file mode 100644
index 00000000000..836f7960969
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts
@@ -0,0 +1,91 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } =
+ vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockIsFeatureEnabled: vi.fn(),
+ mockHasWorkspaceAdminAccess: vi.fn(),
+ mockOperations: {
+ getCustomBlockManageContext: vi.fn(),
+ getCustomBlockUsageCounts: vi.fn(),
+ },
+ }))
+
+vi.mock('@/lib/auth', () => ({
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isFeatureEnabled: mockIsFeatureEnabled,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess,
+}))
+
+vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations)
+
+import { GET } from '@/app/api/custom-blocks/[id]/usages/route'
+
+const MANAGE_CONTEXT = {
+ organizationId: 'org-1',
+ sourceWorkspaceId: 'ws-1',
+ type: 'custom_block_abc123',
+ name: 'Invoice Parser',
+}
+
+const USAGE_COUNTS = { usageCount: 3, deployedUsageCount: 2 }
+
+function callRoute(id = 'cb-1') {
+ return GET(createMockRequest('GET'), { params: Promise.resolve({ id }) })
+}
+
+describe('GET /api/custom-blocks/[id]/usages', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ mockIsFeatureEnabled.mockResolvedValue(true)
+ mockHasWorkspaceAdminAccess.mockResolvedValue(true)
+ mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT)
+ mockOperations.getCustomBlockUsageCounts.mockResolvedValue(USAGE_COUNTS)
+ })
+
+ it('returns 401 without a session', async () => {
+ mockGetSession.mockResolvedValue(null)
+ const response = await callRoute()
+ expect(response.status).toBe(401)
+ })
+
+ it('returns 404 for an unknown block', async () => {
+ mockOperations.getCustomBlockManageContext.mockResolvedValue(null)
+ const response = await callRoute()
+ expect(response.status).toBe(404)
+ })
+
+ it('returns 403 when the feature flag is off', async () => {
+ mockIsFeatureEnabled.mockResolvedValue(false)
+ const response = await callRoute()
+ expect(response.status).toBe(403)
+ })
+
+ it('returns 403 for a non-admin of the source workspace', async () => {
+ mockHasWorkspaceAdminAccess.mockResolvedValue(false)
+ const response = await callRoute()
+ expect(response.status).toBe(403)
+ expect(mockOperations.getCustomBlockUsageCounts).not.toHaveBeenCalled()
+ })
+
+ it('returns the org-scoped usage counts for the block type', async () => {
+ const response = await callRoute()
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual(USAGE_COUNTS)
+ expect(mockOperations.getCustomBlockUsageCounts).toHaveBeenCalledWith(
+ 'org-1',
+ 'custom_block_abc123'
+ )
+ })
+})
diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts
new file mode 100644
index 00000000000..eee59b2c20a
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts
@@ -0,0 +1,26 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { getCustomBlockUsageCountsContract } from '@/lib/api/contracts/custom-blocks'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operations'
+import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'
+
+type RouteContext = { params: Promise<{ id: string }> }
+
+export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getCustomBlockUsageCountsContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const authz = await authorizeManage(session.user.id, parsed.data.params.id)
+ if (authz.error) return authz.error
+
+ const counts = await getCustomBlockUsageCounts(authz.ctx.organizationId, authz.ctx.type)
+ return NextResponse.json(counts)
+})
diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts
index 3c12cb21bbf..ff0c38862d9 100644
--- a/apps/sim/app/api/tools/jupyter/upload/route.ts
+++ b/apps/sim/app/api/tools/jupyter/upload/route.ts
@@ -18,6 +18,7 @@ import {
buildJupyterAuthHeaders,
encodeJupyterPath,
normalizeJupyterServerUrl,
+ parseJupyterContentModel,
UnsafeJupyterPathError,
} from '@/tools/jupyter/utils'
@@ -128,17 +129,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- const uploaded = await response.json()
+ const uploaded = parseJupyterContentModel(await response.json())
+ if (!uploaded) {
+ logger.error(`[${requestId}] Jupyter returned an invalid upload response`)
+ return NextResponse.json(
+ { success: false, error: 'Jupyter returned an invalid upload response' },
+ { status: 502 }
+ )
+ }
+
+ const uploadedName = uploaded.name ?? fileName
+ const uploadedPath = uploaded.path ?? destinationPath
+ const uploadedSize = uploaded.size ?? fileBuffer.length
+ const lastModified = uploaded.lastModified ?? null
- logger.info(`[${requestId}] File uploaded to Jupyter: ${uploaded.path}`)
+ logger.info(`[${requestId}] File uploaded to Jupyter: ${uploadedPath}`)
return NextResponse.json({
success: true,
output: {
- name: uploaded.name ?? fileName,
- path: uploaded.path ?? destinationPath,
- size: uploaded.size ?? fileBuffer.length,
- lastModified: uploaded.last_modified ?? null,
+ name: uploadedName,
+ path: uploadedPath,
+ size: uploadedSize,
+ lastModified,
},
})
} catch (error) {
diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts
index 4eaeae69cd9..feb5a7b5153 100644
--- a/apps/sim/app/api/tools/slack/channels/route.ts
+++ b/apps/sim/app/api/tools/slack/channels/route.ts
@@ -91,11 +91,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
accessToken = resolvedToken
- logger.info('Using OAuth token for Slack API')
// resolvedCredentialId is an account.id only for OAuth credentials
// (the service_account path returns a credential.id).
if (authz.credentialType === 'oauth' && authz.resolvedCredentialId) {
+ logger.info('Using OAuth token for Slack API')
const [accountRow] = await db
.select({ accountId: account.accountId })
.from(account)
@@ -104,6 +104,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (accountRow) {
scopedUserId = parseScopedSlackUserId(accountRow.accountId)
}
+ } else {
+ // A custom-bot service_account credential resolves to a bot token with
+ // no scoped user; treat it like a direct bot token so the private ->
+ // public channel fallback applies.
+ isBotToken = true
+ logger.info('Using custom bot token for Slack API')
}
}
diff --git a/apps/sim/app/api/tools/tiktok/publish-video/route.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.ts
new file mode 100644
index 00000000000..d60157807df
--- /dev/null
+++ b/apps/sim/app/api/tools/tiktok/publish-video/route.ts
@@ -0,0 +1,287 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { tiktokPublishVideoContract } from '@/lib/api/contracts/tiktok-tools'
+import { parseRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { isPayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ getFileExtension,
+ getMimeTypeFromExtension,
+ processSingleFileToUserFile,
+} from '@/lib/uploads/utils/file-utils'
+import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { assertToolFileAccess } from '@/app/api/files/authorization'
+import type { UserFile } from '@/executor/types'
+import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas'
+import { readTikTokApiResponse } from '@/tools/tiktok/utils'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('TikTokPublishVideoAPI')
+
+const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm'])
+
+/** TikTok requires each chunk between 5MB and 64MB; the final chunk absorbs the remainder (up to ~2x this size, well under the 128MB cap). Capped at 1000 chunks total, which this default comfortably satisfies up to TikTok's 4GB video size limit. */
+const DEFAULT_CHUNK_SIZE = 10_000_000
+const TIKTOK_ERROR_RESPONSE_MAX_BYTES = 64 * 1024
+
+/** Maximum size this route will buffer in memory for a single file-upload request. TikTok's
+ * own limit is 4GB, but relaying that much through this server's memory per request isn't
+ * safe under concurrent load. Enforced before downloading the file so an oversized upload
+ * fails fast with a clean 413 instead of materializing hundreds of MB to multiple GB
+ * in-process. */
+const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024
+
+function computeChunkPlan(totalBytes: number): { chunkSize: number; totalChunkCount: number } {
+ if (totalBytes <= DEFAULT_CHUNK_SIZE) {
+ return { chunkSize: totalBytes, totalChunkCount: 1 }
+ }
+ const totalChunkCount = Math.floor(totalBytes / DEFAULT_CHUNK_SIZE)
+ return { chunkSize: DEFAULT_CHUNK_SIZE, totalChunkCount }
+}
+
+function resolveVideoMimeType(fileName: string, fileType: string | undefined): string | null {
+ if (fileType && TIKTOK_VIDEO_MIME_TYPES.has(fileType)) return fileType
+ const fromExtension = getMimeTypeFromExtension(getFileExtension(fileName))
+ return TIKTOK_VIDEO_MIME_TYPES.has(fromExtension) ? fromExtension : null
+}
+
+async function validateDirectPostSettings(
+ accessToken: string,
+ postInfo: { privacy_level: string; brand_content_toggle: boolean },
+ requestId: string
+): Promise {
+ if (postInfo.brand_content_toggle && postInfo.privacy_level === 'SELF_ONLY') {
+ return 'Branded content cannot use Only Me privacy.'
+ }
+
+ const response = await fetch('https://open.tiktokapis.com/v2/post/publish/creator_info/query/', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json; charset=UTF-8',
+ },
+ body: '{}',
+ })
+ const data = await response.json()
+ if (!response.ok || (data.error?.code && data.error.code !== 'ok')) {
+ logger.warn(`[${requestId}] TikTok creator-info preflight failed`, {
+ status: response.status,
+ code: data.error?.code,
+ })
+ return data.error?.message || 'TikTok creator information could not be verified.'
+ }
+
+ const privacyOptions: unknown = data.data?.privacy_level_options
+ if (
+ !Array.isArray(privacyOptions) ||
+ !privacyOptions.some((option) => option === postInfo.privacy_level)
+ ) {
+ return `The selected privacy level (${postInfo.privacy_level}) is not currently available for this TikTok account. Run Query Creator Info and choose one of the returned options.`
+ }
+
+ return null
+}
+
+async function uploadChunks(
+ uploadUrl: string,
+ buffer: Buffer,
+ mimeType: string,
+ requestId: string
+): Promise {
+ const totalBytes = buffer.length
+ const { chunkSize, totalChunkCount } = computeChunkPlan(totalBytes)
+
+ for (let i = 0; i < totalChunkCount; i++) {
+ const start = i * chunkSize
+ const isLastChunk = i === totalChunkCount - 1
+ const end = isLastChunk ? totalBytes - 1 : start + chunkSize - 1
+ const chunk = buffer.subarray(start, end + 1)
+
+ const response = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': mimeType,
+ 'Content-Length': String(chunk.length),
+ 'Content-Range': `bytes ${start}-${end}/${totalBytes}`,
+ },
+ body: new Uint8Array(chunk),
+ })
+
+ if (!response.ok) {
+ const errorText = await readResponseTextWithLimit(response, {
+ maxBytes: TIKTOK_ERROR_RESPONSE_MAX_BYTES,
+ label: 'TikTok upload error response',
+ }).catch(() => 'Error response exceeded the allowed size')
+ logger.error(`[${requestId}] TikTok chunk upload failed`, {
+ chunkIndex: i,
+ status: response.status,
+ errorText,
+ })
+ throw new Error(
+ `TikTok rejected video chunk ${i + 1}/${totalChunkCount}: ${response.status} ${errorText || response.statusText}`
+ )
+ }
+ }
+}
+
+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 TikTok publish-video attempt: ${authResult.error}`)
+ return NextResponse.json(
+ { success: false, error: authResult.error || 'Authentication required' },
+ { status: 401 }
+ )
+ }
+
+ const parsed = await parseRequest(tiktokPublishVideoContract, request, {})
+ if (!parsed.success) return parsed.response
+ const data = parsed.data.body
+
+ let userFile: UserFile
+ try {
+ userFile = processSingleFileToUserFile(data.file, requestId, logger)
+ } catch (error) {
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to process file') },
+ { status: 400 }
+ )
+ }
+
+ const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
+ if (denied) return denied
+
+ if (data.mode === 'direct') {
+ const settingsError = await validateDirectPostSettings(
+ data.accessToken,
+ data.postInfo,
+ requestId
+ )
+ if (settingsError) {
+ return NextResponse.json({ success: false, error: settingsError }, { status: 400 })
+ }
+ }
+
+ const mimeType = resolveVideoMimeType(userFile.name, userFile.type)
+ if (!mimeType) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Unsupported video type. TikTok accepts MP4, MOV/QuickTime, or WebM files.',
+ },
+ { status: 400 }
+ )
+ }
+
+ logger.info(`[${requestId}] Downloading video from storage`, {
+ fileName: userFile.name,
+ size: userFile.size,
+ })
+
+ const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger, {
+ maxBytes: TIKTOK_MAX_VIDEO_BYTES,
+ })
+ if (fileBuffer.length === 0) {
+ return NextResponse.json(
+ { success: false, error: 'The video file is empty.' },
+ { status: 400 }
+ )
+ }
+ const { chunkSize, totalChunkCount } = computeChunkPlan(fileBuffer.length)
+
+ const initUrl =
+ data.mode === 'draft'
+ ? 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/'
+ : 'https://open.tiktokapis.com/v2/post/publish/video/init/'
+
+ const initBody: Record = {
+ source_info: {
+ source: 'FILE_UPLOAD',
+ video_size: fileBuffer.length,
+ chunk_size: chunkSize,
+ total_chunk_count: totalChunkCount,
+ },
+ }
+ if (data.mode === 'direct') {
+ initBody.post_info = data.postInfo
+ }
+
+ logger.info(`[${requestId}] Initializing TikTok video ${data.mode}`, {
+ videoSize: fileBuffer.length,
+ chunkSize,
+ totalChunkCount,
+ })
+
+ const initResponse = await fetch(initUrl, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${data.accessToken}`,
+ 'Content-Type': 'application/json; charset=UTF-8',
+ },
+ body: JSON.stringify(initBody),
+ })
+
+ const { data: initData, error: initError } = await readTikTokApiResponse(
+ initResponse,
+ tiktokPublishInitApiDataSchema
+ )
+
+ if (initError) {
+ logger.error(`[${requestId}] TikTok init failed`, { error: initError })
+ return NextResponse.json(
+ { success: false, error: initError.message || 'Failed to initialize TikTok upload' },
+ { status: initResponse.status >= 400 ? initResponse.status : 502 }
+ )
+ }
+
+ const publishId = initData?.publish_id
+ const uploadUrl = initData?.upload_url
+
+ if (!publishId || !uploadUrl) {
+ return NextResponse.json(
+ { success: false, error: 'TikTok did not return a publish ID and upload URL' },
+ { status: 502 }
+ )
+ }
+
+ try {
+ await uploadChunks(uploadUrl, fileBuffer, mimeType, requestId)
+ } catch (error) {
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to upload video to TikTok') },
+ { status: 502 }
+ )
+ }
+
+ logger.info(`[${requestId}] TikTok video upload complete`, { publishId })
+
+ return NextResponse.json({ success: true, output: { publishId } })
+ } catch (error) {
+ if (isPayloadSizeLimitError(error)) {
+ logger.warn(`[${requestId}] Rejected oversized TikTok video upload`, {
+ maxBytes: error.maxBytes,
+ observedBytes: error.observedBytes,
+ })
+ const maxMb = Math.floor(TIKTOK_MAX_VIDEO_BYTES / (1024 * 1024))
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Video exceeds the ${maxMb}MB limit for file uploads.`,
+ },
+ { status: 413 }
+ )
+ }
+ logger.error(`[${requestId}] Error publishing video to TikTok:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Internal server error') },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts
index a27b0d599b0..0efbb4b3f0f 100644
--- a/apps/sim/app/api/webhooks/route.ts
+++ b/apps/sim/app/api/webhooks/route.ts
@@ -240,7 +240,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.limit(1)
if (existingForBlock.length > 0) {
- finalPath = existingForBlock[0].path
+ finalPath = existingForBlock[0].path ?? ''
logger.info(
`[${requestId}] Reusing existing generated path for ${provider} trigger: ${finalPath}`
)
diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts
new file mode 100644
index 00000000000..ae51cee4d19
--- /dev/null
+++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts
@@ -0,0 +1,136 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockParseWebhookBody,
+ mockFindWebhooksByRoutingKey,
+ mockDispatchResolvedWebhookTarget,
+ mockGetSlackBotCredential,
+ mockHandleChallenge,
+ mockVerifySignature,
+} = vi.hoisted(() => ({
+ mockParseWebhookBody: vi.fn(),
+ mockFindWebhooksByRoutingKey: vi.fn(),
+ mockDispatchResolvedWebhookTarget: vi.fn(),
+ mockGetSlackBotCredential: vi.fn(),
+ mockHandleChallenge: vi.fn(),
+ mockVerifySignature: vi.fn(),
+}))
+
+vi.mock('@/lib/core/admission/gate', () => ({
+ tryAdmit: () => ({ release: vi.fn() }),
+ admissionRejectedResponse: () => new Response(null, { status: 503 }),
+}))
+
+vi.mock('@/app/api/auth/oauth/utils', () => ({
+ getSlackBotCredential: mockGetSlackBotCredential,
+}))
+
+vi.mock('@/lib/webhooks/processor', () => ({
+ parseWebhookBody: mockParseWebhookBody,
+ findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey,
+ dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget,
+}))
+
+vi.mock('@/lib/webhooks/providers/slack', () => ({
+ handleSlackChallenge: mockHandleChallenge,
+ verifySlackRequestSignature: mockVerifySignature,
+ resolveSlackEventKey: () => null,
+}))
+
+import { POST } from '@/app/api/webhooks/slack/custom/[credentialId]/route'
+
+const CREDENTIAL_ID = 'cred-123'
+
+function makeRequest() {
+ return new Request('https://sim.test/api/webhooks/slack/custom/cred-123', {
+ method: 'POST',
+ headers: { 'x-slack-request-timestamp': '1700000000' },
+ }) as unknown as import('next/server').NextRequest
+}
+
+const context = { params: Promise.resolve({ credentialId: CREDENTIAL_ID }) }
+
+const messageBody = {
+ team_id: 'T1',
+ api_app_id: 'A1',
+ event: { type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.1' },
+}
+
+function webhook(id: string) {
+ return { webhook: { id, blockId: `blk-${id}`, providerConfig: {} }, workflow: { id: `wf-${id}` } }
+}
+
+describe('Slack custom-bot webhook route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockHandleChallenge.mockReturnValue(null)
+ mockVerifySignature.mockReturnValue(null)
+ mockParseWebhookBody.mockResolvedValue({
+ body: messageBody,
+ rawBody: JSON.stringify(messageBody),
+ })
+ mockGetSlackBotCredential.mockResolvedValue({
+ signingSecret: 'sec',
+ botToken: 'xoxb-x',
+ teamId: 'T1',
+ })
+ mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')])
+ mockDispatchResolvedWebhookTarget.mockResolvedValue({
+ outcome: 'queued',
+ response: new Response(null, { status: 200 }),
+ reason: 'queued',
+ })
+ })
+
+ it('echoes the url_verification challenge without loading the credential', async () => {
+ mockHandleChallenge.mockReturnValue(new Response('ok', { status: 200 }))
+ await POST(makeRequest(), context)
+ expect(mockGetSlackBotCredential).not.toHaveBeenCalled()
+ expect(mockVerifySignature).not.toHaveBeenCalled()
+ })
+
+ it('404s an unknown credential', async () => {
+ mockGetSlackBotCredential.mockResolvedValue(null)
+ const res = await POST(makeRequest(), context)
+ expect(res.status).toBe(404)
+ expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
+ })
+
+ it('verifies with the credential signing secret and rejects a bad signature', async () => {
+ mockVerifySignature.mockReturnValue(new Response(null, { status: 401 }))
+ const res = await POST(makeRequest(), context)
+ expect(mockVerifySignature).toHaveBeenCalledWith(
+ 'sec',
+ expect.anything(),
+ expect.any(String),
+ expect.any(String)
+ )
+ expect(res.status).toBe(401)
+ expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
+ })
+
+ it('fans out by credential id (provider slack) and dispatches each webhook', async () => {
+ const res = await POST(makeRequest(), context)
+ expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith(
+ CREDENTIAL_ID,
+ expect.any(String),
+ 'slack'
+ )
+ expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
+ expect(res.status).toBe(200)
+ })
+
+ it('still returns 200 when the dispatcher filters the event', async () => {
+ mockDispatchResolvedWebhookTarget.mockResolvedValue({
+ outcome: 'ignored',
+ response: new Response(null, { status: 200 }),
+ reason: 'filtered',
+ })
+ const res = await POST(makeRequest(), context)
+ expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
+ expect(res.status).toBe(200)
+ })
+})
diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts
new file mode 100644
index 00000000000..2267179417d
--- /dev/null
+++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts
@@ -0,0 +1,88 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
+import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
+import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch'
+import { getSlackBotCredential } from '@/app/api/auth/oauth/utils'
+
+const logger = createLogger('SlackCustomBotWebhookAPI')
+
+export const dynamic = 'force-dynamic'
+export const runtime = 'nodejs'
+export const maxDuration = 60
+
+/**
+ * Ingest endpoint for a reusable custom Slack bot. The bot's single Event
+ * Subscriptions Request URL embeds its credential id, so every trigger that
+ * references the same bot credential shares one URL — routed here by
+ * `routingKey = credentialId` (mirrors the native `/api/webhooks/slack`
+ * `team_id` fan-out). Requests are HMAC-verified with the credential's OWN
+ * signing secret (not the shared env secret).
+ */
+export const POST = withRouteHandler(
+ async (request: NextRequest, context: { params: Promise<{ credentialId: string }> }) => {
+ const ticket = tryAdmit()
+ if (!ticket) {
+ return admissionRejectedResponse()
+ }
+ try {
+ return await handleSlackCustomBotWebhook(request, context)
+ } finally {
+ ticket.release()
+ }
+ }
+)
+
+async function handleSlackCustomBotWebhook(
+ request: NextRequest,
+ context: { params: Promise<{ credentialId: string }> }
+): Promise {
+ const receivedAt = Date.now()
+ const requestId = generateRequestId()
+ const { credentialId } = await context.params
+
+ const parseResult = await parseWebhookBody(request, requestId)
+ if (parseResult instanceof NextResponse) {
+ return parseResult
+ }
+ const { body, rawBody } = parseResult
+
+ // Echo Slack's url_verification challenge unconditionally — this is how Slack
+ // verifies the Request URL when the app is created from the manifest, before
+ // the bot is installed / the credential is finalized.
+ const challenge = handleSlackChallenge(body)
+ if (challenge) {
+ return challenge
+ }
+
+ const botCredential = await getSlackBotCredential(credentialId)
+ if (!botCredential) {
+ logger.warn(`[${requestId}] Unknown Slack bot credential ${credentialId}`)
+ return new NextResponse(null, { status: 404 })
+ }
+
+ const authError = verifySlackRequestSignature(
+ botCredential.signingSecret,
+ request,
+ rawBody,
+ requestId
+ )
+ if (authError) {
+ return authError
+ }
+
+ const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack')
+ if (webhooks.length === 0) {
+ logger.info(
+ `[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run`
+ )
+ return new NextResponse(null, { status: 200 })
+ }
+
+ await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt })
+
+ return new NextResponse(null, { status: 200 })
+}
diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts
new file mode 100644
index 00000000000..ac20c7927cf
--- /dev/null
+++ b/apps/sim/app/api/webhooks/slack/route.test.ts
@@ -0,0 +1,126 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, mockDispatchResolvedWebhookTarget } =
+ vi.hoisted(() => ({
+ mockParseWebhookBody: vi.fn(),
+ mockFindWebhooksByRoutingKey: vi.fn(),
+ mockDispatchResolvedWebhookTarget: vi.fn(),
+ }))
+
+vi.mock('@/lib/core/admission/gate', () => ({
+ tryAdmit: () => ({ release: vi.fn() }),
+ admissionRejectedResponse: () => new Response(null, { status: 503 }),
+}))
+
+vi.mock('@/lib/core/config/env', () => ({
+ env: { SLACK_SIGNING_SECRET: 'test-secret' },
+}))
+
+vi.mock('@/lib/webhooks/processor', () => ({
+ parseWebhookBody: mockParseWebhookBody,
+ findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey,
+ dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget,
+}))
+
+vi.mock('@/lib/webhooks/providers/slack', () => ({
+ handleSlackChallenge: () => null,
+ verifySlackRequestSignature: () => null,
+ resolveSlackEventKey: () => null,
+}))
+
+import { POST } from '@/app/api/webhooks/slack/route'
+
+function makeRequest() {
+ return new Request('https://sim.test/api/webhooks/slack', {
+ method: 'POST',
+ headers: { 'x-slack-request-timestamp': '1700000000' },
+ }) as unknown as import('next/server').NextRequest
+}
+
+function webhook(id: string) {
+ return { webhook: { id, blockId: `blk-${id}`, providerConfig: {} }, workflow: { id: `wf-${id}` } }
+}
+
+async function run(body: Record) {
+ mockParseWebhookBody.mockResolvedValue({ body, rawBody: JSON.stringify(body) })
+ await POST(makeRequest())
+}
+
+const messageBody = {
+ team_id: 'T1',
+ api_app_id: 'A1',
+ event: { type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.1' },
+}
+
+describe('Slack app webhook route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')])
+ mockDispatchResolvedWebhookTarget.mockResolvedValue({
+ outcome: 'queued',
+ response: new Response(null, { status: 200 }),
+ reason: 'queued',
+ })
+ })
+
+ it('dispatches each webhook resolved for the event team', async () => {
+ await run(messageBody)
+ expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
+ })
+
+ it('continues cleanly when the dispatcher filters the event', async () => {
+ mockDispatchResolvedWebhookTarget.mockResolvedValue({
+ outcome: 'ignored',
+ response: new Response(null, { status: 200 }),
+ reason: 'filtered',
+ })
+ await run(messageBody)
+ expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
+ })
+
+ it('routes via Slack Connect authorizations and dedups overlapping webhooks', async () => {
+ // Two candidate teams (outer + authorization) that resolve to overlapping webhooks.
+ mockFindWebhooksByRoutingKey.mockImplementation(async (teamId: string) =>
+ teamId === 'T1' ? [webhook('wh1')] : [webhook('wh1'), webhook('wh2')]
+ )
+ await run({
+ ...messageBody,
+ authorizations: [{ team_id: 'T2' }],
+ })
+ expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledTimes(2)
+ // wh1 (in both) is dispatched once, wh2 once — dedup by webhook id.
+ expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2)
+ })
+
+ it('returns 200 with no team_id', async () => {
+ await run({ event: { type: 'message' } })
+ expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
+ expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
+ })
+
+ it('routes an interaction payload by payload.team.id', async () => {
+ await run({
+ type: 'block_actions',
+ api_app_id: 'A1',
+ team: { id: 'T1' },
+ user: { id: 'U1' },
+ actions: [{ action_id: 'approve_btn' }],
+ })
+ expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith('T1', expect.anything())
+ expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
+ })
+
+ it('fails closed on an interaction missing payload.team.id (never routes on user.team_id)', async () => {
+ await run({
+ type: 'block_actions',
+ api_app_id: 'A1',
+ user: { id: 'U1', team_id: 'T_OTHER' },
+ actions: [{ action_id: 'approve_btn' }],
+ })
+ expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
+ expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts
new file mode 100644
index 00000000000..6562b5f12a3
--- /dev/null
+++ b/apps/sim/app/api/webhooks/slack/route.ts
@@ -0,0 +1,112 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
+import { env } from '@/lib/core/config/env'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
+import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
+import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch'
+
+const logger = createLogger('SlackAppWebhookAPI')
+
+export const dynamic = 'force-dynamic'
+export const runtime = 'nodejs'
+export const maxDuration = 60
+
+/**
+ * Single ingest endpoint for the official Sim Slack app. Every workspace's
+ * events arrive here and are routed to listening workflows by Slack `team_id`
+ * (and Slack Connect `authorizations[].team_id`) after HMAC verification with
+ * the shared app signing secret. This is the request URL configured in the
+ * app's Event Subscriptions.
+ */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const ticket = tryAdmit()
+ if (!ticket) {
+ return admissionRejectedResponse()
+ }
+
+ try {
+ return await handleSlackAppWebhook(request)
+ } finally {
+ ticket.release()
+ }
+})
+
+async function handleSlackAppWebhook(request: NextRequest): Promise {
+ const receivedAt = Date.now()
+ const requestId = generateRequestId()
+
+ const parseResult = await parseWebhookBody(request, requestId)
+ if (parseResult instanceof NextResponse) {
+ return parseResult
+ }
+ const { body, rawBody } = parseResult
+
+ // Slack's endpoint verification handshake — echo the challenge back.
+ const challenge = handleSlackChallenge(body)
+ if (challenge) {
+ return challenge
+ }
+
+ const signingSecret = env.SLACK_SIGNING_SECRET
+ if (!signingSecret) {
+ logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
+ return new NextResponse('Slack app not configured', { status: 500 })
+ }
+
+ const authError = verifySlackRequestSignature(signingSecret, request, rawBody, requestId)
+ if (authError) {
+ return authError
+ }
+
+ const payload = body as Record
+
+ // Route by the installed workspace(s). For Slack Connect the outer `team_id`
+ // may be the sender's workspace, so every authorized installation is a
+ // routing candidate. Team ids are Slack-attested (post-signature), never user
+ // input.
+ const teamIds = new Set()
+ if (typeof payload.team_id === 'string' && payload.team_id.length > 0) {
+ teamIds.add(payload.team_id)
+ }
+ const authorizations = Array.isArray(payload.authorizations) ? payload.authorizations : []
+ for (const authorization of authorizations) {
+ const teamId = (authorization as Record)?.team_id
+ if (typeof teamId === 'string' && teamId.length > 0) {
+ teamIds.add(teamId)
+ }
+ }
+ // Interactivity payloads (block_actions / view_submission) carry no top-level
+ // `team_id` / `authorizations`; the install/context workspace is at
+ // `payload.team.id`. Route on that ONLY — never `payload.user.team_id`, which
+ // in Slack Connect can be a different (external) tenant. Slack-attested,
+ // post-signature, so not user-forgeable.
+ if (teamIds.size === 0) {
+ const interactionTeamId = (payload.team as Record | undefined)?.id
+ if (typeof interactionTeamId === 'string' && interactionTeamId.length > 0) {
+ teamIds.add(interactionTeamId)
+ }
+ }
+ if (teamIds.size === 0) {
+ logger.warn(`[${requestId}] Slack event missing team_id`)
+ return new NextResponse(null, { status: 200 })
+ }
+
+ const webhooksById = new Map()
+ for (const teamId of teamIds) {
+ const found = await findWebhooksByRoutingKey(teamId, requestId)
+ for (const entry of found) {
+ webhooksById.set(entry.webhook.id, entry)
+ }
+ }
+ const webhooks = [...webhooksById.values()]
+ if (webhooks.length === 0) {
+ return new NextResponse(null, { status: 200 })
+ }
+
+ await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt })
+
+ return new NextResponse(null, { status: 200 })
+}
diff --git a/apps/sim/app/api/webhooks/tiktok/route.test.ts b/apps/sim/app/api/webhooks/tiktok/route.test.ts
new file mode 100644
index 00000000000..71d07947cab
--- /dev/null
+++ b/apps/sim/app/api/webhooks/tiktok/route.test.ts
@@ -0,0 +1,116 @@
+/**
+ * @vitest-environment node
+ */
+
+import crypto from 'node:crypto'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockEnqueue, mockExecuteTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({
+ mockEnqueue: vi.fn(),
+ mockExecuteTikTokWebhookIngress: vi.fn(),
+ mockRelease: vi.fn(),
+}))
+
+vi.mock('@/background/tiktok-webhook-ingress', () => ({
+ executeTikTokWebhookIngress: mockExecuteTikTokWebhookIngress,
+ TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT: 50,
+ TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS: 3,
+}))
+
+vi.mock('@/lib/core/admission/gate', () => ({
+ admissionRejectedResponse: vi.fn(() => new Response(null, { status: 503 })),
+ tryAdmit: vi.fn(() => ({ release: mockRelease })),
+}))
+
+vi.mock('@/lib/core/async-jobs', () => ({
+ getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })),
+}))
+
+vi.mock('@/lib/core/config/env', () => ({
+ env: {
+ TIKTOK_CLIENT_ID: 'client-key',
+ TIKTOK_CLIENT_SECRET: 'client-secret',
+ },
+}))
+
+vi.mock('@/lib/core/utils/request', () => ({
+ generateRequestId: vi.fn(() => 'request-1'),
+}))
+
+vi.mock('@/lib/core/utils/with-route-handler', () => ({
+ withRouteHandler:
+ (handler: (request: NextRequest) => Promise) => (request: NextRequest) =>
+ handler(request),
+}))
+
+import { POST } from '@/app/api/webhooks/tiktok/route'
+
+function signedRequest(overrides?: { clientKey?: string }): NextRequest {
+ const body = JSON.stringify({
+ client_key: overrides?.clientKey ?? 'client-key',
+ event: 'post.publish.complete',
+ create_time: 1_725_000_000,
+ user_openid: 'act.user',
+ content: '{"publish_id":"publish-1"}',
+ })
+ const timestamp = String(Math.floor(Date.now() / 1000))
+ const signature = crypto
+ .createHmac('sha256', 'client-secret')
+ .update(`${timestamp}.${body}`, 'utf8')
+ .digest('hex')
+
+ return new NextRequest('http://localhost/api/webhooks/tiktok', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'TikTok-Signature': `t=${timestamp},s=${signature}`,
+ },
+ body,
+ })
+}
+
+describe('TikTok webhook ingress route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockEnqueue.mockResolvedValue('ingress-job-1')
+ })
+
+ it('returns 200 only after the verified delivery is accepted by the job queue', async () => {
+ const response = await POST(signedRequest())
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toEqual({ ok: true })
+ expect(mockEnqueue).toHaveBeenCalledWith(
+ 'tiktok-webhook-ingress',
+ expect.objectContaining({
+ envelope: expect.objectContaining({
+ client_key: 'client-key',
+ user_openid: 'act.user',
+ }),
+ requestId: 'request-1',
+ }),
+ expect.objectContaining({
+ maxAttempts: 3,
+ runner: expect.any(Function),
+ })
+ )
+ expect(mockRelease).toHaveBeenCalledOnce()
+ })
+
+ it('returns 503 when durable acceptance fails so TikTok retries', async () => {
+ mockEnqueue.mockRejectedValue(new Error('queue unavailable'))
+
+ const response = await POST(signedRequest())
+
+ expect(response.status).toBe(503)
+ expect(mockRelease).toHaveBeenCalledOnce()
+ })
+
+ it('rejects a signed delivery for a different TikTok app', async () => {
+ const response = await POST(signedRequest({ clientKey: 'other-client-key' }))
+
+ expect(response.status).toBe(401)
+ expect(mockEnqueue).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/webhooks/tiktok/route.ts b/apps/sim/app/api/webhooks/tiktok/route.ts
new file mode 100644
index 00000000000..5fecb9134d0
--- /dev/null
+++ b/apps/sim/app/api/webhooks/tiktok/route.ts
@@ -0,0 +1,135 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { tiktokWebhookEnvelopeSchema } from '@/lib/api/contracts/webhooks'
+import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
+import { getJobQueue } from '@/lib/core/async-jobs'
+import { env } from '@/lib/core/config/env'
+import { generateRequestId } from '@/lib/core/utils/request'
+import {
+ assertContentLengthWithinLimit,
+ isPayloadSizeLimitError,
+ readStreamToBufferWithLimit,
+} from '@/lib/core/utils/stream-limits'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
+import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok'
+import {
+ executeTikTokWebhookIngress,
+ TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,
+ TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS,
+ type TikTokWebhookIngressPayload,
+} from '@/background/tiktok-webhook-ingress'
+
+const logger = createLogger('TikTokWebhookIngress')
+
+const TIKTOK_BODY_LABEL = 'TikTok webhook body'
+
+export const dynamic = 'force-dynamic'
+export const runtime = 'nodejs'
+export const maxDuration = 60
+
+async function readTikTokBody(req: Request): Promise {
+ assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, TIKTOK_BODY_LABEL)
+ const buffer = await readStreamToBufferWithLimit(req.body, {
+ maxBytes: WEBHOOK_MAX_BODY_BYTES,
+ label: TIKTOK_BODY_LABEL,
+ })
+ return new TextDecoder().decode(buffer)
+}
+
+/**
+ * App-level TikTok webhook Callback URL.
+ * Portal: `{APP_URL}/api/webhooks/tiktok` (e.g. https://www.sim.ai/api/webhooks/tiktok).
+ * Verifies TikTok-Signature and durably accepts the delivery before background target fanout.
+ */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const ticket = tryAdmit()
+ if (!ticket) {
+ return admissionRejectedResponse()
+ }
+
+ const requestId = generateRequestId()
+ const receivedAt = Date.now()
+
+ try {
+ let rawBody: string
+ try {
+ rawBody = await readTikTokBody(request)
+ } catch (bodyError) {
+ if (isPayloadSizeLimitError(bodyError)) {
+ logger.warn(`[${requestId}] Rejected oversized TikTok webhook body`, {
+ maxBytes: WEBHOOK_MAX_BODY_BYTES,
+ observedBytes: bodyError.observedBytes,
+ })
+ return NextResponse.json({ error: 'Request body too large' }, { status: 413 })
+ }
+ throw bodyError
+ }
+
+ const authError = verifyTikTokSignature(
+ rawBody,
+ request.headers.get('TikTok-Signature'),
+ requestId
+ )
+ if (authError) {
+ return authError
+ }
+
+ let parsedJson: unknown
+ try {
+ parsedJson = rawBody ? JSON.parse(rawBody) : {}
+ } catch {
+ logger.warn(`[${requestId}] TikTok webhook body is not valid JSON`)
+ // Ack to avoid retry storms on malformed payloads after a valid signature.
+ return NextResponse.json({ ok: true })
+ }
+
+ const envelopeResult = tiktokWebhookEnvelopeSchema.safeParse(parsedJson)
+ if (!envelopeResult.success) {
+ logger.warn(`[${requestId}] Invalid TikTok webhook envelope`, {
+ issues: envelopeResult.error.issues,
+ })
+ return NextResponse.json({ ok: true })
+ }
+
+ const envelope = envelopeResult.data
+ if (!env.TIKTOK_CLIENT_ID || envelope.client_key !== env.TIKTOK_CLIENT_ID) {
+ logger.warn(`[${requestId}] TikTok webhook client_key does not match configured app`)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const payload: TikTokWebhookIngressPayload = {
+ envelope,
+ headers: {
+ 'content-type': request.headers.get('content-type') ?? 'application/json',
+ },
+ requestId,
+ receivedAt,
+ }
+ const jobQueue = await getJobQueue()
+ const jobId = await jobQueue.enqueue('tiktok-webhook-ingress', payload, {
+ maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS,
+ concurrencyKey: 'tiktok-webhook-ingress',
+ concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,
+ runner: async () => {
+ await executeTikTokWebhookIngress(payload)
+ },
+ })
+
+ logger.info(`[${requestId}] Accepted TikTok webhook delivery`, {
+ event: envelope.event,
+ jobId,
+ userOpenIdPrefix: envelope.user_openid.slice(0, 12),
+ })
+
+ return NextResponse.json({ ok: true })
+ } catch (error) {
+ logger.error(`[${requestId}] TikTok webhook ingress error`, {
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
+ } finally {
+ ticket.release()
+ }
+})
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
index df0e11e35dd..395882f0c8b 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
@@ -15,63 +15,52 @@ import {
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-/** Mock execution dependencies for webhook tests */
-function mockExecutionDependencies() {
- vi.mock('@/lib/core/security/encryption', () => encryptionMock)
-
- vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
- buildTraceSpans: vi.fn().mockReturnValue({ traceSpans: [], totalDuration: 100 }),
- }))
-
- vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
-
- vi.mock('@/serializer', () => ({
- Serializer: vi.fn().mockImplementation(() => ({
- serializeWorkflow: vi.fn().mockReturnValue({
- version: '1.0',
- blocks: [
- {
- id: 'starter-id',
- metadata: { id: 'starter', name: 'Start' },
- config: {},
- inputs: {},
- outputs: {},
- position: { x: 100, y: 100 },
- enabled: true,
- },
- {
- id: 'agent-id',
- metadata: { id: 'agent', name: 'Agent 1' },
- config: {},
- inputs: {},
- outputs: {},
- position: { x: 634, y: -167 },
- enabled: true,
- },
- ],
- edges: [
- {
- id: 'edge-1',
- source: 'starter-id',
- target: 'agent-id',
- sourceHandle: 'source',
- targetHandle: 'target',
- },
- ],
- loops: {},
- parallels: {},
- }),
- })),
- }))
-}
+vi.mock('@/lib/core/security/encryption', () => encryptionMock)
-/** Mock Trigger.dev SDK */
-function mockTriggerDevSdk() {
- vi.mock('@trigger.dev/sdk', () => ({
- tasks: { trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }) },
- task: vi.fn().mockReturnValue({}),
- }))
-}
+vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
+ buildTraceSpans: vi.fn().mockReturnValue({ traceSpans: [], totalDuration: 100 }),
+}))
+
+vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
+
+vi.mock('@/serializer', () => ({
+ Serializer: vi.fn().mockImplementation(() => ({
+ serializeWorkflow: vi.fn().mockReturnValue({
+ version: '1.0',
+ blocks: [
+ {
+ id: 'starter-id',
+ metadata: { id: 'starter', name: 'Start' },
+ config: {},
+ inputs: {},
+ outputs: {},
+ position: { x: 100, y: 100 },
+ enabled: true,
+ },
+ {
+ id: 'agent-id',
+ metadata: { id: 'agent', name: 'Agent 1' },
+ config: {},
+ inputs: {},
+ outputs: {},
+ position: { x: 634, y: -167 },
+ enabled: true,
+ },
+ ],
+ edges: [
+ {
+ id: 'edge-1',
+ source: 'starter-id',
+ target: 'agent-id',
+ sourceHandle: 'source',
+ targetHandle: 'target',
+ },
+ ],
+ loops: {},
+ parallels: {},
+ }),
+ })),
+}))
/**
* Test data store - isolated per test via beforeEach reset
@@ -106,6 +95,7 @@ const {
executeMock,
getWorkspaceBilledAccountUserIdMock,
queueWebhookExecutionMock,
+ dispatchResolvedWebhookTargetMock,
isWorkspaceApiExecutionEntitledMock,
} = vi.hoisted(() => ({
generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'),
@@ -134,6 +124,7 @@ const {
const { NextResponse } = await import('next/server')
return NextResponse.json({ message: 'Webhook processed' })
}),
+ dispatchResolvedWebhookTargetMock: vi.fn(),
isWorkspaceApiExecutionEntitledMock: vi.fn().mockResolvedValue(true),
}))
@@ -274,6 +265,25 @@ vi.mock('@/lib/webhooks/processor', () => ({
}
),
handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
+ dispatchResolvedWebhookTarget: dispatchResolvedWebhookTargetMock.mockImplementation(
+ async (
+ foundWebhook: unknown,
+ foundWorkflow: unknown,
+ body: unknown,
+ request: unknown,
+ options: unknown
+ ) => ({
+ outcome: 'queued',
+ reason: 'queued',
+ response: await queueWebhookExecutionMock(
+ foundWebhook,
+ foundWorkflow,
+ body,
+ request,
+ options
+ ),
+ })
+ ),
verifyProviderAuth: vi
.fn()
.mockImplementation(
@@ -350,7 +360,6 @@ vi.mock('@/lib/webhooks/processor', () => ({
}),
shouldSkipWebhookEvent: vi.fn().mockReturnValue(false),
handlePreDeploymentVerification: vi.fn().mockReturnValue(null),
- queueWebhookExecution: queueWebhookExecutionMock,
}))
vi.mock('drizzle-orm/postgres-js', () => ({
@@ -401,9 +410,6 @@ describe('Webhook Trigger API Route', () => {
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true)
isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true)
- mockExecutionDependencies()
- mockTriggerDevSdk()
-
// Set up default workflow for tests
testData.workflows.push({
id: 'test-workflow-id',
@@ -495,8 +501,8 @@ describe('Webhook Trigger API Route', () => {
expect(text).toMatch(/not found/i)
})
- describe('Internal trigger providers', () => {
- it.each(['sim', 'table'])(
+ describe('Non-path trigger providers', () => {
+ it.each(['sim', 'table', 'tiktok'])(
'rejects HTTP deliveries to %s trigger paths with 404',
async (provider) => {
testData.webhooks.push({
@@ -539,7 +545,7 @@ describe('Webhook Trigger API Route', () => {
})
describe('Generic Webhook Authentication', () => {
- it('passes correlation-bearing request context into webhook queueing', async () => {
+ it('passes request context into shared webhook dispatch', async () => {
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
@@ -556,7 +562,7 @@ describe('Webhook Trigger API Route', () => {
expect(response.status).toBe(200)
expect(queueWebhookExecutionMock).toHaveBeenCalledOnce()
- const call = queueWebhookExecutionMock.mock.calls[0]
+ const call = dispatchResolvedWebhookTargetMock.mock.calls[0]
expect(call[0]).toEqual(expect.objectContaining({ id: 'generic-webhook-id' }))
expect(call[1]).toEqual(expect.objectContaining({ id: 'test-workflow-id' }))
expect(call[2]).toEqual(expect.objectContaining({ event: 'test', id: 'test-123' }))
@@ -564,18 +570,6 @@ describe('Webhook Trigger API Route', () => {
expect.objectContaining({
requestId: 'mock-request-id',
path: 'test-path',
- actorUserId: 'test-user-id',
- executionId: 'preprocess-execution-id',
- correlation: {
- executionId: 'preprocess-execution-id',
- requestId: 'mock-request-id',
- source: 'webhook',
- workflowId: 'test-workflow-id',
- webhookId: 'generic-webhook-id',
- path: 'test-path',
- provider: 'generic',
- triggerType: 'webhook',
- },
})
)
})
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index 1c103ddad72..af9ebe7c134 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -10,18 +10,15 @@ import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
- checkWebhookPreprocessing,
+ dispatchResolvedWebhookTarget,
findAllWebhooksForPath,
- handlePreDeploymentVerification,
handlePreLookupWebhookVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
- queueWebhookExecution,
- shouldSkipWebhookEvent,
verifyProviderAuth,
} from '@/lib/webhooks/processor'
-import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
+import { acceptsPathWebhookDelivery } from '@/lib/webhooks/providers'
import { isInternalTriggerProvider } from '@/triggers/constants'
const logger = createLogger('WebhookTriggerAPI')
@@ -107,15 +104,15 @@ async function handleWebhookPost(
// Find all webhooks for this path (multiple webhooks in one workflow may share a path)
const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path })
- // Internal trigger providers (sim, table) are fired in-process, never over
- // HTTP. Their rows still register a path, so reject deliveries here to keep
- // forged events out.
+ /** Exclude in-process triggers and providers that own an app-level ingress route. */
const webhooksForPath = allWebhooksForPath.filter(
- ({ webhook: foundWebhook }) => !isInternalTriggerProvider(foundWebhook.provider)
+ ({ webhook: foundWebhook }) =>
+ !isInternalTriggerProvider(foundWebhook.provider) &&
+ acceptsPathWebhookDelivery(foundWebhook.provider)
)
if (allWebhooksForPath.length > 0 && webhooksForPath.length === 0) {
- logger.warn(`[${requestId}] Rejected HTTP delivery to internal trigger path: ${path}`)
+ logger.warn(`[${requestId}] Rejected HTTP delivery to non-path trigger: ${path}`)
return new NextResponse('Not Found', { status: 404 })
}
@@ -172,48 +169,35 @@ async function handleWebhookPost(
return reachabilityResponse
}
- const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
- if (preprocessResult.error) {
- if (webhooksForPath.length > 1) {
- logger.warn(
- `[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
- )
- continue
+ const dispatchResult = await dispatchResolvedWebhookTarget(
+ foundWebhook,
+ foundWorkflow,
+ body,
+ request,
+ {
+ requestId,
+ path,
+ receivedAt,
+ triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined,
}
- return preprocessResult.error
- }
+ )
- if (foundWebhook.blockId) {
- const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
- if (!blockExists) {
- const preDeploymentResponse = handlePreDeploymentVerification(foundWebhook, requestId)
- if (preDeploymentResponse) {
- return preDeploymentResponse
- }
+ if (dispatchResult.reason === 'filtered') {
+ continue
+ }
- logger.info(
- `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
+ if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') {
+ if (webhooksForPath.length > 1) {
+ logger.warn(
+ `[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`,
+ { reason: dispatchResult.reason, status: dispatchResult.response.status }
)
- if (webhooksForPath.length > 1) {
- continue
- }
- return new NextResponse('Trigger block not found in deployment', { status: 404 })
+ continue
}
+ return dispatchResult.response
}
- if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) {
- continue
- }
- const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
- requestId,
- path,
- actorUserId: preprocessResult.actorUserId,
- executionId: preprocessResult.executionId,
- correlation: preprocessResult.correlation,
- receivedAt,
- triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined,
- })
- responses.push(response)
+ responses.push(dispatchResult.response)
}
if (responses.length === 0) {
diff --git a/apps/sim/app/landing-preview/marks-lab/marks-lab.tsx b/apps/sim/app/landing-preview/marks-lab/marks-lab.tsx
deleted file mode 100644
index 9ffca4c824e..00000000000
--- a/apps/sim/app/landing-preview/marks-lab/marks-lab.tsx
+++ /dev/null
@@ -1,966 +0,0 @@
-'use client'
-
-import { useEffect, useMemo, useRef, useState } from 'react'
-import {
- normalizeReach,
- type Pt,
- sampleClosed,
- toPath,
-} from '@/app/(landing)/components/mothership/components/goo-marks/use-goo-hover'
-import {
- type Edge,
- edgesToPaths,
- isoProject,
- rotate2,
-} from '@/app/(landing)/components/mothership/components/iso-marks/use-goo-mark'
-
-/**
- * Internal tuning lab for the Sim brand marks. Renders each mark with live
- * controls — size, plus a full before-hover (rest) and after-hover (hover) value
- * for every geometry parameter AND for stroke width and goo fusion — with a
- * scrub/play for the hover transition and a JSON readout to copy back into the
- * production component constants.
- *
- * Forced light (`light` wrapper): the marks use the dark brand gradient, so they
- * only read on a light surface; this keeps the lab correct regardless of the
- * app's active theme.
- *
- * Not linked from nav — internal route at /landing-preview/marks-lab.
- */
-
-const lerp = (a: number, b: number, t: number) => a + (b - a) * t
-
-interface ParamDef {
- key: string
- label: string
- min: number
- max: number
- step: number
- rest: number
- hover: number
- /** Structural params don't animate — one value drives both rest and hover. */
- structural?: boolean
-}
-
-interface LabMark {
- id: string
- label: string
- defaultStroke: number
- defaultGoo: number
- defaultSize: number
- params: ParamDef[]
- build: (
- p: Record,
- amt: number,
- pairs: Record
- ) => string
-}
-
-type P3 = [number, number, number]
-
-/** Rotate a model-space point about the vertical Z axis (`az`), then about the
- * in-plane X axis (`ax`) for an isometric tumble. */
-function rot3([x, y, z]: P3, az: number, ax: number): P3 {
- const cz = Math.cos(az)
- const sz = Math.sin(az)
- const x1 = x * cz - y * sz
- const y1 = x * sz + y * cz
- const cx = Math.cos(ax)
- const sx = Math.sin(ax)
- return [x1, y1 * cx - z * sx, y1 * sx + z * cx]
-}
-
-/**
- * Per-element transition progress. `wave` (0..1) staggers each element's hover
- * across time: 0 = every element animates together, 1 = fully sequential (each
- * element waits for the ones before it). This is what makes the pieces "spin
- * individually at different times" as the hover scrubs 0 → 1.
- */
-function elementProgress(amt: number, i: number, n: number, wave: number): number {
- if (wave <= 0 || n <= 1) return amt
- const span = Math.max(0.0001, 1 - wave)
- const start = (i / (n - 1)) * wave
- return Math.max(0, Math.min(1, (amt - start) / span))
-}
-
-function cubeEdges(s: number, ky: number, rot: number, zScale: number, zc: number): Edge[] {
- const corner = (sx: number, sy: number, sz: number): Pt => {
- const [rx, ry] = rotate2(sx * s, sy * s, rot)
- return isoProject(rx, ry, sz * s * zScale + zc, ky)
- }
- const c = [
- corner(-1, -1, -1),
- corner(1, -1, -1),
- corner(1, 1, -1),
- corner(-1, 1, -1),
- corner(-1, -1, 1),
- corner(1, -1, 1),
- corner(1, 1, 1),
- corner(-1, 1, 1),
- ]
- const ed: [number, number][] = [
- [0, 1],
- [1, 2],
- [2, 3],
- [3, 0],
- [4, 5],
- [5, 6],
- [6, 7],
- [7, 4],
- [0, 4],
- [1, 5],
- [2, 6],
- [3, 7],
- ]
- return ed.map(([a, b]) => [c[a], c[b]] as Edge)
-}
-
-const MARKS: LabMark[] = [
- {
- id: 'stacked',
- label: 'Stacked planes — Integrate',
- defaultStroke: 1.5,
- defaultGoo: 0.8,
- defaultSize: 160,
- params: [
- {
- key: 'planes',
- label: 'Planes',
- min: 1,
- max: 5,
- step: 1,
- rest: 3,
- hover: 3,
- structural: true,
- },
- {
- key: 'divisions',
- label: 'Divisions',
- min: 1,
- max: 6,
- step: 1,
- rest: 2,
- hover: 2,
- structural: true,
- },
- { key: 'gap', label: 'Gap', min: 0, max: 40, step: 0.5, rest: 6, hover: 20 },
- { key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.36, hover: 0.25 },
- { key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
- ],
- build: (p) => {
- const half = 40
- const planes = Math.round(p.planes)
- const div = Math.round(p.divisions)
- const totalH = (planes - 1) * p.gap
- const proj = (u: number, v: number, z: number): Pt => {
- const [ru, rv] = rotate2(u, v, p.spin)
- const pp = isoProject(ru * half, rv * half, 0, p.tilt)
- return [pp[0], pp[1] + (z - totalH / 2)]
- }
- const E: Edge[] = []
- for (let pl = 0; pl < planes; pl++) {
- const z = pl * p.gap
- for (let i = 0; i <= div; i++) {
- const v = -1 + (2 * i) / div
- E.push([proj(-1, v, z), proj(1, v, z)])
- }
- for (let i = 0; i <= div; i++) {
- const u = -1 + (2 * i) / div
- E.push([proj(u, -1, z), proj(u, 1, z)])
- }
- }
- return edgesToPaths(E)
- },
- },
- {
- id: 'fourbox',
- label: 'Four-box twist — Ingest context',
- defaultStroke: 1.5,
- defaultGoo: 0.8,
- defaultSize: 160,
- params: [
- {
- key: 'boxes',
- label: 'Boxes',
- min: 2,
- max: 6,
- step: 1,
- rest: 4,
- hover: 4,
- structural: true,
- },
- { key: 'gap', label: 'Gap', min: 0, max: 20, step: 0.5, rest: 2.5, hover: 9 },
- { key: 'twist', label: 'Twist (deg)', min: 0, max: 90, step: 1, rest: 14, hover: 34 },
- { key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
- { key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.55, hover: 0.5 },
- ],
- build: (p) => {
- const boxes = Math.round(p.boxes)
- const twRad = (p.twist * Math.PI) / 180
- const totalH = (boxes - 1) * p.gap
- const E: Edge[] = []
- for (let i = 0; i < boxes; i++) {
- const zc = i * p.gap - totalH / 2
- const rot = p.spin * i * 0.5 + i * twRad
- E.push(...cubeEdges(1.0, p.tilt, rot, 0.4, zc))
- }
- return edgesToPaths(E)
- },
- },
- {
- id: 'nestedcube',
- label: 'Nested cube — Monitor',
- defaultStroke: 1.5,
- defaultGoo: 0.8,
- defaultSize: 160,
- params: [
- {
- key: 'tilt',
- label: 'Tilt',
- min: 0,
- max: 1,
- step: 0.01,
- rest: 0.5,
- hover: 0.5,
- structural: true,
- },
- { key: 'inner', label: 'Inner scale', min: 0.1, max: 1, step: 0.01, rest: 0.42, hover: 0.9 },
- { key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
- ],
- build: (p) => {
- const E: Edge[] = [
- ...cubeEdges(1.0, p.tilt, 0, 1, 0),
- ...cubeEdges(p.inner, p.tilt, p.spin, 1, 0),
- ]
- return edgesToPaths(E)
- },
- },
- {
- id: 'lissajous',
- label: 'Lissajous — Build',
- defaultStroke: 3,
- defaultGoo: 1.5,
- defaultSize: 160,
- params: [
- { key: 'a', label: 'Freq A', min: 1, max: 7, step: 1, rest: 3, hover: 3, structural: true },
- { key: 'b', label: 'Freq B', min: 1, max: 7, step: 1, rest: 2, hover: 2, structural: true },
- {
- key: 'amp',
- label: 'Amplitude',
- min: 10,
- max: 48,
- step: 1,
- rest: 40,
- hover: 40,
- structural: true,
- },
- { key: 'phase', label: 'Phase', min: 0, max: 6.28, step: 0.01, rest: 1.5708, hover: 1.9708 },
- ],
- build: (p) => {
- const fn = (t: number): Pt => [
- 50 + p.amp * Math.sin(p.a * t + p.phase),
- 50 + p.amp * Math.sin(p.b * t),
- ]
- return toPath(normalizeReach(sampleClosed(fn)))
- },
- },
- {
- id: 'gridfloors',
- label: 'Grid floors',
- defaultStroke: 1.5,
- defaultGoo: 0.8,
- defaultSize: 160,
- params: [
- { key: 'grid', label: 'Grid', min: 1, max: 5, step: 1, rest: 3, hover: 3, structural: true },
- {
- key: 'floors',
- label: 'Floors',
- min: 1,
- max: 5,
- step: 1,
- rest: 3,
- hover: 3,
- structural: true,
- },
- { key: 'gap', label: 'Gap', min: 0, max: 40, step: 0.5, rest: 14, hover: 26 },
- { key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.5, hover: 0.42 },
- { key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 1.2 },
- {
- key: 'twist',
- label: 'Twist / floor',
- min: -1.2,
- max: 1.2,
- step: 0.01,
- rest: 0,
- hover: 0.5,
- },
- { key: 'tumble', label: 'Iso tumble', min: -1.57, max: 1.57, step: 0.01, rest: 0, hover: 0 },
- {
- key: 'wave',
- label: 'Wave (stagger)',
- min: 0,
- max: 1,
- step: 0.01,
- rest: 0,
- hover: 0.6,
- structural: true,
- },
- ],
- build: (p, amt, pairs) => {
- const half = 40
- const div = Math.round(p.grid)
- const floors = Math.round(p.floors)
- const totalH = (floors - 1) * p.gap
- const proj = (x: number, y: number, z: number): Pt => isoProject(x, y, z, p.tilt)
- const E: Edge[] = []
- const nodes: Pt[][] = []
- for (let fl = 0; fl < floors; fl++) {
- const ai = elementProgress(amt, fl, floors, p.wave)
- const az = lerp(pairs.spin.rest, pairs.spin.hover, ai) + fl * p.twist
- const ax = lerp(pairs.tumble.rest, pairs.tumble.hover, ai)
- const zc = fl * p.gap - totalH / 2
- const tp = (x: number, y: number): Pt => {
- const r = rot3([x, y, 0], az, ax)
- return proj(r[0], r[1], r[2] + zc)
- }
- for (let i = 0; i <= div; i++) {
- const v = -1 + (2 * i) / div
- E.push([tp(-half, v * half), tp(half, v * half)])
- }
- for (let i = 0; i <= div; i++) {
- const u = -1 + (2 * i) / div
- E.push([tp(u * half, -half), tp(u * half, half)])
- }
- const row: Pt[] = []
- for (let i = 0; i <= div; i++) {
- for (let j = 0; j <= div; j++) {
- const u = -1 + (2 * i) / div
- const v = -1 + (2 * j) / div
- row.push(tp(u * half, v * half))
- }
- }
- nodes.push(row)
- }
- for (let fl = 0; fl < floors - 1; fl++) {
- for (let k = 0; k < nodes[fl].length; k++) {
- E.push([nodes[fl][k], nodes[fl + 1][k]])
- }
- }
- return edgesToPaths(E)
- },
- },
- {
- id: 'tunnel',
- label: 'Square tunnel',
- defaultStroke: 1.5,
- defaultGoo: 0.8,
- defaultSize: 160,
- params: [
- {
- key: 'count',
- label: 'Count',
- min: 2,
- max: 20,
- step: 1,
- rest: 12,
- hover: 12,
- structural: true,
- },
- {
- key: 'sq',
- label: 'Square',
- min: 6,
- max: 30,
- step: 0.5,
- rest: 18,
- hover: 18,
- structural: true,
- },
- { key: 'step', label: 'Step', min: 2, max: 16, step: 0.5, rest: 6, hover: 10 },
- { key: 'tilt', label: 'Tilt', min: 0, max: 1, step: 0.01, rest: 0.5, hover: 0.42 },
- { key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 0.6 },
- {
- key: 'twist',
- label: 'Twist / square',
- min: -1.2,
- max: 1.2,
- step: 0.01,
- rest: 0,
- hover: 0.4,
- },
- { key: 'tumble', label: 'Iso tumble', min: -1.57, max: 1.57, step: 0.01, rest: 0, hover: 0 },
- {
- key: 'wave',
- label: 'Wave (stagger)',
- min: 0,
- max: 1,
- step: 0.01,
- rest: 0,
- hover: 0.6,
- structural: true,
- },
- ],
- build: (p, amt, pairs) => {
- const count = Math.round(p.count)
- const s = p.sq
- const totalU = (count - 1) * p.step
- const proj = (x: number, y: number, z: number): Pt => isoProject(x, y, z, p.tilt)
- const E: Edge[] = []
- for (let k = 0; k < count; k++) {
- const ai = elementProgress(amt, k, count, p.wave)
- const az = lerp(pairs.spin.rest, pairs.spin.hover, ai) + k * p.twist
- const ax = lerp(pairs.tumble.rest, pairs.tumble.hover, ai)
- const u = k * p.step - totalU / 2
- const tp = (y: number, z: number): Pt => {
- const r = rot3([0, y, z], az, ax)
- return proj(r[0] + u, r[1], r[2])
- }
- E.push(
- [tp(-s, -s), tp(s, -s)],
- [tp(s, -s), tp(s, s)],
- [tp(s, s), tp(-s, s)],
- [tp(-s, s), tp(-s, -s)]
- )
- }
- return edgesToPaths(E)
- },
- },
- {
- id: 'tilegrid',
- label: 'Tile grid',
- defaultStroke: 1.5,
- defaultGoo: 0.8,
- defaultSize: 160,
- params: [
- { key: 'grid', label: 'Grid', min: 2, max: 6, step: 1, rest: 3, hover: 3, structural: true },
- { key: 'inset', label: 'Tile gap', min: 0, max: 0.4, step: 0.01, rest: 0.12, hover: 0.22 },
- {
- key: 'tilt',
- label: 'Tilt',
- min: 0,
- max: 1,
- step: 0.01,
- rest: 0.5,
- hover: 0.5,
- structural: true,
- },
- { key: 'spin', label: 'Spin', min: -3.14, max: 3.14, step: 0.01, rest: 0, hover: 0 },
- { key: 'twist', label: 'Twist / tile', min: -1.5, max: 1.5, step: 0.01, rest: 0, hover: 0.6 },
- {
- key: 'tumble',
- label: 'Iso tumble',
- min: -1.57,
- max: 1.57,
- step: 0.01,
- rest: 0,
- hover: 0.8,
- },
- {
- key: 'wave',
- label: 'Wave (stagger)',
- min: 0,
- max: 1,
- step: 0.01,
- rest: 0,
- hover: 0.7,
- structural: true,
- },
- ],
- build: (p, amt, pairs) => {
- const half = 40
- const div = Math.round(p.grid)
- const m = p.inset
- const proj = (x: number, y: number, z: number): Pt => isoProject(x, y, z, p.tilt)
- const E: Edge[] = []
- const n = div * div
- const hs = (1 - 2 * m) * (half / div)
- let idx = 0
- for (let i = 0; i < div; i++) {
- for (let j = 0; j < div; j++) {
- const ai = elementProgress(amt, idx, n, p.wave)
- idx++
- const az = lerp(pairs.spin.rest, pairs.spin.hover, ai) + (i + j) * p.twist * 0.5
- const ax = lerp(pairs.tumble.rest, pairs.tumble.hover, ai)
- const cu = (-1 + (2 * i + 1) / div) * half
- const cv = (-1 + (2 * j + 1) / div) * half
- const tp = (lx: number, ly: number): Pt => {
- const r = rot3([lx, ly, 0], az, ax)
- return proj(r[0] + cu, r[1] + cv, r[2])
- }
- E.push(
- [tp(-hs, -hs), tp(hs, -hs)],
- [tp(hs, -hs), tp(hs, hs)],
- [tp(hs, hs), tp(-hs, hs)],
- [tp(-hs, hs), tp(-hs, -hs)]
- )
- }
- }
- return edgesToPaths(E)
- },
- },
-]
-
-interface Pair {
- rest: number
- hover: number
-}
-
-interface GradientConfig {
- from: string
- to: string
- cx: Pair
- cy: Pair
- r: Pair
-}
-
-interface MarkConfig {
- params: Record
- stroke: Pair
- goo: Pair
- gradient: GradientConfig
- size: number
-}
-
-function makeConfig(m: LabMark): MarkConfig {
- const params: Record = {}
- for (const p of m.params) params[p.key] = { rest: p.rest, hover: p.hover }
- return {
- params,
- stroke: { rest: m.defaultStroke, hover: m.defaultStroke },
- goo: { rest: m.defaultGoo, hover: m.defaultGoo },
- gradient: {
- from: '#2C2C2C',
- to: '#5F5F5F',
- cx: { rest: 50, hover: 50 },
- cy: { rest: 50, hover: 50 },
- r: { rest: 44, hover: 44 },
- },
- size: m.defaultSize,
- }
-}
-
-/** A config that always has an entry (and every param key) for `mark`. Guards
- * against stale lab state after a mark or param is added during development. */
-function ensureConfig(base: MarkConfig | undefined, mark: LabMark): MarkConfig {
- const safe = base ?? makeConfig(mark)
- let params = safe.params
- let cloned = false
- for (const def of mark.params) {
- if (!params[def.key]) {
- if (!cloned) {
- params = { ...params }
- cloned = true
- }
- params[def.key] = { rest: def.rest, hover: def.hover }
- }
- }
- return cloned ? { ...safe, params } : safe
-}
-
-function initConfigs(): Record {
- const out: Record = {}
- for (const m of MARKS) out[m.id] = makeConfig(m)
- return out
-}
-
-const panel = 'rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-4'
-const sectionLabel = 'text-[12px] text-[var(--text-muted)]'
-
-function Slider({
- label,
- value,
- min,
- max,
- step,
- onChange,
-}: {
- label: string
- value: number
- min: number
- max: number
- step: number
- onChange: (v: number) => void
-}) {
- return (
-
- {label}
- onChange(Number(e.target.value))}
- className='h-1 flex-1 cursor-pointer accent-[var(--text-primary)]'
- />
-
- {value.toFixed(step < 1 ? 2 : 0)}
-
-
- )
-}
-
-function PairRow({
- label,
- def,
- pair,
- onChange,
-}: {
- label: string
- def: { min: number; max: number; step: number }
- pair: Pair
- onChange: (side: 'rest' | 'hover', v: number) => void
-}) {
- return (
-
- onChange('rest', v)}
- />
- onChange('hover', v)}
- />
-
- )
-}
-
-function ColorRow({
- label,
- value,
- onChange,
-}: {
- label: string
- value: string
- onChange: (v: string) => void
-}) {
- return (
-
- {label}
- onChange(e.target.value)}
- className='h-7 w-10 flex-shrink-0 cursor-pointer rounded border border-[var(--border)] bg-transparent'
- />
- {value}
-
- )
-}
-
-export function MarksLab() {
- const [configs, setConfigs] = useState>(initConfigs)
- const [markId, setMarkId] = useState(MARKS[0].id)
- const [amt, setAmt] = useState(0)
- const [playing, setPlaying] = useState(false)
- const rafRef = useRef(null)
-
- const mark = MARKS.find((m) => m.id === markId) as LabMark
- const cfg = ensureConfig(configs[markId], mark)
-
- // Persist a complete config for the active mark so edits always have a target
- // — covers marks/params added during development without a full reload.
- useEffect(() => {
- setConfigs((prev) => {
- const ensured = ensureConfig(prev[markId], mark)
- return prev[markId] === ensured ? prev : { ...prev, [markId]: ensured }
- })
- }, [markId, mark])
-
- useEffect(() => {
- if (!playing) return
- const start = performance.now()
- const loop = () => {
- const t = (performance.now() - start) / 1000
- setAmt((1 - Math.cos(t * 1.4)) / 2)
- rafRef.current = requestAnimationFrame(loop)
- }
- rafRef.current = requestAnimationFrame(loop)
- return () => {
- if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
- }
- }, [playing])
-
- const resolved = useMemo(() => {
- const p: Record = {}
- for (const def of mark.params) {
- const c = cfg.params[def.key]
- p[def.key] = lerp(c.rest, c.hover, amt)
- }
- return p
- }, [mark, cfg, amt])
-
- const strokeNow = lerp(cfg.stroke.rest, cfg.stroke.hover, amt)
- const gooNow = lerp(cfg.goo.rest, cfg.goo.hover, amt)
- const gradCx = lerp(cfg.gradient.cx.rest, cfg.gradient.cx.hover, amt)
- const gradCy = lerp(cfg.gradient.cy.rest, cfg.gradient.cy.hover, amt)
- const gradR = lerp(cfg.gradient.r.rest, cfg.gradient.r.hover, amt)
- const d = mark.build(resolved, amt, cfg.params)
-
- const setPair = (
- group: 'params' | 'stroke' | 'goo',
- key: string,
- side: 'rest' | 'hover',
- v: number
- ) =>
- setConfigs((prev) => {
- const next = structuredClone(prev)
- const target = group === 'params' ? next[markId].params[key] : next[markId][group]
- target[side] = v
- return next
- })
-
- const setSize = (v: number) =>
- setConfigs((prev) => {
- const next = structuredClone(prev)
- next[markId].size = v
- return next
- })
-
- const setGradColor = (which: 'from' | 'to', v: string) =>
- setConfigs((prev) => {
- const next = structuredClone(prev)
- next[markId].gradient[which] = v
- return next
- })
-
- const setGradPair = (key: 'cx' | 'cy' | 'r', side: 'rest' | 'hover', v: number) =>
- setConfigs((prev) => {
- const next = structuredClone(prev)
- next[markId].gradient[key][side] = v
- return next
- })
-
- const readout = useMemo(() => {
- const restGeo: Record = {}
- const hoverGeo: Record = {}
- for (const def of mark.params) {
- const c = cfg.params[def.key]
- restGeo[def.key] = c.rest
- hoverGeo[def.key] = c.hover
- }
- const g = cfg.gradient
- return JSON.stringify(
- {
- size: cfg.size,
- gradient: { from: g.from, to: g.to },
- REST: {
- ...restGeo,
- stroke: cfg.stroke.rest,
- gooFusion: cfg.goo.rest,
- gradCx: g.cx.rest,
- gradCy: g.cy.rest,
- gradR: g.r.rest,
- },
- HOVER: {
- ...hoverGeo,
- stroke: cfg.stroke.hover,
- gooFusion: cfg.goo.hover,
- gradCx: g.cx.hover,
- gradCy: g.cy.hover,
- gradR: g.r.hover,
- },
- },
- null,
- 2
- )
- }, [mark, cfg])
-
- return (
-
-
-
-
Brand mark lab
-
- Tune each mark's before-hover and after-hover state — geometry, stroke, and goo fusion.
- Scrub or play the transition, then copy the readout into the component constants.
-
-
-
-
- {MARKS.map((m) => (
- {
- setMarkId(m.id)
- setAmt(0)
- setPlaying(false)
- }}
- className={`rounded-md px-3 py-1.5 text-[13px] transition-colors ${
- m.id === markId
- ? 'bg-[var(--text-primary)] text-[var(--bg)]'
- : 'border border-[var(--border)] bg-[var(--surface-2)] text-[var(--text-body)] hover:bg-[var(--surface-hover)]'
- }`}
- >
- {m.label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setPlaying((v) => !v)}
- className='rounded-md bg-[var(--text-primary)] px-3 py-1.5 text-[13px] text-[var(--bg)]'
- >
- {playing ? 'Pause' : 'Play hover'}
-
- {
- setPlaying(false)
- setAmt(v)
- }}
- />
-
-
-
-
-
-
-
-
Geometry (before → after hover)
- {mark.params.map((def) => (
-
setPair('params', def.key, side, v)}
- />
- ))}
-
-
-
-
Style (before → after hover)
-
setPair('stroke', 'stroke', side, v)}
- />
- setPair('goo', 'goo', side, v)}
- />
-
-
-
-
- Gradient — radial stops + position (moves on hover)
-
-
setGradColor('from', v)}
- />
- setGradColor('to', v)}
- />
- setGradPair('cx', side, v)}
- />
- setGradPair('cy', side, v)}
- />
- setGradPair('r', side, v)}
- />
-
-
-
-
Readout
-
- {readout}
-
-
navigator.clipboard?.writeText(readout)}
- className='self-start rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-1.5 text-[12px] text-[var(--text-body)]'
- >
- Copy JSON
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/landing-preview/marks-lab/page.tsx b/apps/sim/app/landing-preview/marks-lab/page.tsx
deleted file mode 100644
index 4a881193d05..00000000000
--- a/apps/sim/app/landing-preview/marks-lab/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { MarksLab } from '@/app/landing-preview/marks-lab/marks-lab'
-
-/**
- * Internal brand-mark tuning lab. Not linked from nav — reachable at
- * /landing-preview/marks-lab for dialing in mark parameters before porting the
- * values into the production component constants.
- */
-export default function MarksLabPage() {
- return
-}
diff --git a/apps/sim/app/landing-preview/page.tsx b/apps/sim/app/landing-preview/page.tsx
deleted file mode 100644
index ea812a51b02..00000000000
--- a/apps/sim/app/landing-preview/page.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { notFound } from 'next/navigation'
-import { LandingShell } from '@/app/(landing)/components'
-import Landing from '@/app/(landing)/landing'
-
-/**
- * TEMPORARY preview route — renders the new `(landing)` page at a path that
- * bypasses the self-hosted `/` -> `/login` redirect (proxy only redirects `/`).
- * Wrapped in {@link LandingShell} so the preview carries the exact prod chrome
- * (light tokens, navbar with GitHub stars, footer, JSON-LD).
- * Local/preview-only scaffold for visual iteration — 404s in production.
- */
-export const dynamic = 'force-dynamic'
-
-export default function LandingPreviewPage() {
- if (process.env.NODE_ENV === 'production') notFound()
-
- return (
-
-
-
- )
-}
diff --git a/apps/sim/app/landing-preview/readme-tour-capture/[workspaceId]/page.tsx b/apps/sim/app/landing-preview/readme-tour-capture/[workspaceId]/page.tsx
deleted file mode 100644
index 80278ed0bc3..00000000000
--- a/apps/sim/app/landing-preview/readme-tour-capture/[workspaceId]/page.tsx
+++ /dev/null
@@ -1,670 +0,0 @@
-'use client'
-
-import { Suspense, use, useEffect, useRef, useState } from 'react'
-import { ToastProvider } from '@sim/emcn'
-import { type QueryClient, useQueryClient } from '@tanstack/react-query'
-import { notFound } from 'next/navigation'
-import { useTheme } from 'next-themes'
-import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
-import { Files } from '@/app/workspace/[workspaceId]/files/files'
-import { Home } from '@/app/workspace/[workspaceId]/home/home'
-import { Integrations } from '@/app/workspace/[workspaceId]/integrations/integrations'
-import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
-import Logs from '@/app/workspace/[workspaceId]/logs/logs'
-import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
-import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import { Tables } from '@/app/workspace/[workspaceId]/tables/tables'
-import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
-import { SocketProvider } from '@/app/workspace/providers/socket-provider'
-import { deploymentKeys } from '@/hooks/queries/deployments'
-import { connectorKeys } from '@/hooks/queries/kb/connectors'
-import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
-import { type LogFilters, logKeys } from '@/hooks/queries/logs'
-import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
-import { sessionKeys } from '@/hooks/queries/session'
-import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
-import { folderKeys } from '@/hooks/queries/utils/folder-keys'
-import { tableKeys } from '@/hooks/queries/utils/table-keys'
-import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
-import { workspaceKeys } from '@/hooks/queries/workspace'
-import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
-import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
-import { SIDEBAR_WIDTH } from '@/stores/constants'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import { useWorkflowStore } from '@/stores/workflows/workflow/store'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
-
-/**
- * TEMPORARY README tour-capture route — the REAL Sim workspace (genuine
- * WorkspaceChrome sidebar) with the main view switched IN PLACE as the capture
- * cursor clicks sidebar nav items, so one continuous screencast shows a user
- * navigating chat → integrations → knowledge → tables → files → workflow → logs.
- * No auth/network (everything seeded), light mode. 404s in production
- * (see the `NODE_ENV` guard below) — only reachable in dev/preview builds
- * for the capture script.
- */
-export const dynamic = 'force-dynamic'
-
-const FRAME_BG = '#F8F8F8'
-const CARD_BG = '#FFFFFF'
-const CARD_W = 1180
-const CARD_H = 720
-const WS_ID = 'demo'
-const WS_NAME = 'Brightwave'
-const USER = { id: 'demo-user', name: 'Sam Rivera', email: 'sam@brightwave.com', image: null }
-const CHAT_ID = 'demo-chat-1'
-const WF_ID = 'wf1'
-const KB_ID = 'kb-pricing'
-
-type View = 'chat' | 'integrations' | 'knowledge' | 'tables' | 'files' | 'logs' | 'workflow'
-
-const PDF = 'application/pdf'
-const DOC_TOKENS = [12400, 23100, 4300, 15800, 9200]
-
-const DEMO_WORKFLOW: WorkflowState = {
- currentWorkflowId: WF_ID,
- blocks: {
- start: {
- id: 'start',
- type: 'start_trigger',
- name: 'Start',
- position: { x: 170, y: 60 },
- subBlocks: { inputFormat: { id: 'inputFormat', type: 'input-format', value: null } },
- outputs: {},
- enabled: true,
- horizontalHandles: false,
- height: 0,
- },
- agent: {
- id: 'agent',
- type: 'agent',
- name: 'Enrich lead',
- position: { x: 140, y: 260 },
- subBlocks: {
- model: { id: 'model', type: 'dropdown', value: 'claude-opus-4-1' },
- systemPrompt: {
- id: 'systemPrompt',
- type: 'long-input',
- value: 'Enrich the lead with firmographics and a fit score.',
- },
- },
- outputs: {},
- enabled: true,
- horizontalHandles: false,
- height: 0,
- },
- slack: {
- id: 'slack',
- type: 'slack',
- name: 'Post to #sales',
- position: { x: 160, y: 540 },
- subBlocks: {
- operation: { id: 'operation', type: 'dropdown', value: 'send' },
- channel: { id: 'channel', type: 'short-input', value: '#sales' },
- },
- outputs: {},
- enabled: true,
- horizontalHandles: false,
- height: 0,
- },
- },
- edges: [
- {
- id: 'e1',
- source: 'start',
- target: 'agent',
- sourceHandle: 'source',
- targetHandle: 'target',
- type: 'workflowEdge',
- data: {},
- },
- {
- id: 'e2',
- source: 'agent',
- target: 'slack',
- sourceHandle: 'source',
- targetHandle: 'target',
- type: 'workflowEdge',
- data: {},
- },
- ],
- loops: {},
- parallels: {},
- lastSaved: Date.now(),
-}
-
-function makeKb(
- id: string,
- name: string,
- description: string,
- docCount: number,
- tokenCount: number,
- connectorTypes: string[],
- iso: string
-) {
- return {
- id,
- userId: USER.id,
- name,
- description,
- tokenCount,
- embeddingModel: 'text-embedding-3-small',
- embeddingDimension: 1536,
- chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 },
- createdAt: iso,
- updatedAt: iso,
- deletedAt: null,
- workspaceId: WS_ID,
- docCount,
- connectorTypes,
- }
-}
-
-function seed(qc: QueryClient) {
- if (typeof document !== 'undefined')
- document.documentElement.style.setProperty('--sidebar-width', `${SIDEBAR_WIDTH.DEFAULT}px`)
- qc.setQueryDefaults(sessionKeys.detail(), { staleTime: Number.POSITIVE_INFINITY })
- qc.setQueryData(sessionKeys.detail(), { user: USER, session: { activeOrganizationId: null } })
- qc.setQueryData(workspaceKeys.list('active'), {
- workspaces: [
- {
- id: WS_ID,
- name: WS_NAME,
- color: '#525252',
- ownerId: USER.id,
- organizationId: null,
- workspaceMode: 'personal',
- permissions: 'admin',
- logoUrl: '/landing/rivian-logo.svg',
- },
- ],
- lastActiveWorkspaceId: WS_ID,
- creationPolicy: null,
- })
- qc.setQueryData(workspaceKeys.members(WS_ID), [
- { userId: USER.id, name: USER.name, email: USER.email, image: null, role: 'admin' },
- ])
-
- const now = new Date()
- const iso = (d: number) => new Date(now.getTime() - d * 86_400_000).toISOString()
- const day = 86_400_000
- qc.setQueryData(workflowKeys.list(WS_ID, 'active'), [
- {
- id: 'wf1',
- name: 'Lead enrichment',
- description: undefined,
- workspaceId: WS_ID,
- folderId: null,
- sortOrder: 0,
- createdAt: now,
- lastModified: now,
- archivedAt: null,
- locked: false,
- },
- {
- id: 'wf2',
- name: 'Inbound lead routing',
- description: undefined,
- workspaceId: WS_ID,
- folderId: null,
- sortOrder: 1,
- createdAt: now,
- lastModified: now,
- archivedAt: null,
- locked: false,
- },
- {
- id: 'wf3',
- name: 'Weekly pipeline report',
- description: undefined,
- workspaceId: WS_ID,
- folderId: null,
- sortOrder: 2,
- createdAt: now,
- lastModified: now,
- archivedAt: null,
- locked: false,
- },
- ])
- qc.setQueryData(folderKeys.list(WS_ID, 'active'), [])
- qc.setQueryData(workspaceCredentialKeys.list(WS_ID), [])
- qc.setQueryData(mothershipChatKeys.list(WS_ID), [
- {
- id: CHAT_ID,
- name: 'Enrich new signups',
- updatedAt: now,
- isActive: false,
- isUnread: false,
- isPinned: false,
- },
- {
- id: 'c2',
- name: 'Post deal alerts to #sales',
- updatedAt: now,
- isActive: false,
- isUnread: false,
- isPinned: false,
- },
- ])
-
- qc.setQueryData(deploymentKeys.info(WF_ID), {
- isDeployed: false,
- needsRedeployment: false,
- deployedAt: null,
- })
- qc.setQueryData(deploymentKeys.deployedState(WF_ID), DEMO_WORKFLOW)
- useWorkflowStore.getState().setCurrentWorkflowId(WF_ID)
- useWorkflowStore.getState().replaceWorkflowState(DEMO_WORKFLOW)
- useWorkflowRegistry.setState({
- activeWorkflowId: WF_ID,
- hydration: {
- phase: 'ready',
- workspaceId: WS_ID,
- workflowId: WF_ID,
- requestId: null,
- error: null,
- },
- })
-
- // Tables
- qc.setQueryData(tableKeys.list(WS_ID, 'active'), [
- {
- id: 'leads',
- name: 'Leads',
- description: 'Sales leads with enrichment',
- schema: {
- columns: [
- { id: 'c_name', name: 'Name', type: 'string' },
- { id: 'c_email', name: 'Email', type: 'email' },
- { id: 'c_company', name: 'Company', type: 'string' },
- { id: 'c_score', name: 'Fit score', type: 'number' },
- { id: 'c_status', name: 'Status', type: 'string' },
- ],
- workflowGroups: [],
- },
- metadata: null,
- rowCount: 128,
- maxRows: 5000,
- workspaceId: WS_ID,
- createdBy: USER.id,
- archivedAt: null,
- createdAt: new Date(now.getTime() - 9 * day),
- updatedAt: now,
- },
- {
- id: 'enriched',
- name: 'Enriched signups',
- description: null,
- schema: {
- columns: [
- { id: 'c_email', name: 'Email', type: 'email' },
- { id: 'c_domain', name: 'Domain', type: 'string' },
- { id: 'c_rev', name: 'Est. revenue', type: 'string' },
- ],
- workflowGroups: [],
- },
- metadata: null,
- rowCount: 342,
- maxRows: 5000,
- workspaceId: WS_ID,
- createdBy: USER.id,
- archivedAt: null,
- createdAt: new Date(now.getTime() - 7 * day),
- updatedAt: new Date(now.getTime() - 2 * day),
- },
- {
- id: 'accounts',
- name: 'Target accounts',
- description: null,
- schema: {
- columns: [
- { id: 'c_acct', name: 'Account', type: 'string' },
- { id: 'c_tier', name: 'Tier', type: 'string' },
- { id: 'c_owner', name: 'Owner', type: 'string' },
- ],
- workflowGroups: [],
- },
- metadata: null,
- rowCount: 64,
- maxRows: 5000,
- workspaceId: WS_ID,
- createdBy: USER.id,
- archivedAt: null,
- createdAt: new Date(now.getTime() - 20 * day),
- updatedAt: new Date(now.getTime() - 5 * day),
- },
- ])
-
- // Files
- const mkFile = (id: string, name: string, type: string, size: number, days: number) => ({
- id,
- workspaceId: WS_ID,
- name,
- key: `workspace/${WS_ID}/${id}`,
- path: `/serve/workspace/${WS_ID}/${id}`,
- size,
- type,
- uploadedBy: USER.id,
- folderId: null,
- folderPath: null,
- uploadedAt: new Date(now.getTime() - days * day),
- updatedAt: new Date(now.getTime() - days * day),
- storageContext: 'workspace',
- share: null,
- })
- qc.setQueryData(workspaceFilesKeys.list(WS_ID, 'active'), [
- mkFile('file1', 'Pricing & Sales Playbook.pdf', PDF, 2_400_000, 3),
- mkFile('file2', 'Competitor Battlecards.pdf', PDF, 1_800_000, 6),
- mkFile(
- 'file3',
- 'Q4 Pipeline.xlsx',
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 1_200_000,
- 9
- ),
- mkFile(
- 'file4',
- 'ICP & Account Research.docx',
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- 640_000,
- 14
- ),
- ])
- qc.setQueryData(workspaceFileFolderKeys.list(WS_ID, 'active'), [])
-
- // Logs
- const logFilters: LogFilters = {
- timeRange: 'All time',
- startDate: undefined,
- endDate: undefined,
- level: 'all',
- workflowIds: [],
- folderIds: [],
- triggers: [],
- searchQuery: '',
- limit: 50,
- sortBy: 'date',
- sortOrder: 'desc',
- }
- const mkLog = (
- id: string,
- status: string,
- level: string,
- duration: string,
- trigger: string,
- mins: number,
- wfName: string,
- wfId: string
- ) => ({
- id,
- workflowId: wfId,
- executionId: `exec-${id}`,
- deploymentVersionId: null,
- deploymentVersion: null,
- deploymentVersionName: null,
- level,
- status,
- duration,
- trigger,
- createdAt: new Date(now.getTime() - mins * 60_000).toISOString(),
- workflow: { id: wfId, name: wfName, description: undefined },
- jobTitle: null,
- cost: { total: 0.0231 },
- pauseSummary: { status: null, total: 0, resumed: 0 },
- hasPendingPause: false,
- })
- qc.setQueryData(logKeys.list(WS_ID, logFilters), {
- pages: [
- {
- logs: [
- mkLog('l1', 'success', 'info', 'PT2.3S', 'webhook', 4, 'Lead enrichment', 'wf1'),
- mkLog('l2', 'success', 'info', 'PT1.9S', 'webhook', 26, 'Lead enrichment', 'wf1'),
- mkLog('l3', 'success', 'info', 'PT3.1S', 'schedule', 92, 'Weekly pipeline report', 'wf3'),
- mkLog('l4', 'error', 'error', 'PT0.8S', 'webhook', 140, 'Inbound lead routing', 'wf2'),
- mkLog('l5', 'success', 'info', 'PT2.6S', 'manual', 180, 'Lead enrichment', 'wf1'),
- ],
- nextCursor: null,
- },
- ],
- pageParams: [null],
- })
-
- // Knowledge base
- qc.setQueryDefaults(knowledgeKeys.all, {
- staleTime: Number.POSITIVE_INFINITY,
- gcTime: Number.POSITIVE_INFINITY,
- })
- const pricingTokens = DOC_TOKENS.reduce((a, b) => a + b, 0)
- qc.setQueryData(knowledgeKeys.list(WS_ID, 'active'), [
- makeKb(
- KB_ID,
- 'Pricing & Sales Playbooks',
- 'Pricing, packaging, and deal-desk references',
- 7,
- pricingTokens,
- [],
- iso(6)
- ),
- makeKb(
- 'kb-battlecards',
- 'Competitor Battlecards',
- 'Win/loss intel and objection handling',
- 5,
- 188000,
- ['google_drive'],
- iso(12)
- ),
- makeKb(
- 'kb-icp',
- 'ICP & Account Research',
- 'Ideal customer profiles and territory notes',
- 9,
- 142000,
- [],
- iso(20)
- ),
- ])
- qc.setQueryData(knowledgeKeys.tagDefinitions(KB_ID), [])
- qc.setQueryData(connectorKeys.list(KB_ID), [])
-
- qc.setQueryData(mothershipChatKeys.detail(CHAT_ID), {
- id: CHAT_ID,
- title: 'Enrich new signups',
- messages: [
- {
- id: 'm1',
- role: 'user',
- content: 'When a new lead signs up, enrich it with company data and post it to #sales.',
- timestamp: now.toISOString(),
- },
- {
- id: 'm2',
- role: 'assistant',
- content:
- "On it. I'll build a workflow that enriches each new signup with firmographics, scores it, and posts a summary to your #sales channel in Slack.",
- timestamp: new Date(now.getTime() + 1200).toISOString(),
- },
- ],
- activeStreamId: null,
- resources: [{ type: 'workflow', id: WF_ID, title: 'Lead enrichment' }],
- })
-}
-
-interface CapturePageProps {
- searchParams: Promise<{
- w?: string
- h?: string
- view?: string
- cardW?: string
- cardH?: string
- bare?: string
- }>
-}
-
-export default function ReadmeTourCapturePage({ searchParams }: CapturePageProps) {
- if (process.env.NODE_ENV === 'production') notFound()
-
- const params = use(searchParams)
- const camW = Number(params.w ?? 1280)
- const camH = Number(params.h ?? 800)
- const cardW = Number(params.cardW ?? CARD_W)
- const cardH = Number(params.cardH ?? CARD_H)
- const bare = params.bare === '1'
- const queryClient = useQueryClient()
- const { setTheme } = useTheme()
- const [view, setView] = useState((params.view as View) || 'chat')
- const cameraRef = useRef(null)
- const cardRef = useRef(null)
- const seededRef = useRef(false)
-
- // Seed the query cache once, synchronously, before first paint - a useEffect
- // would flash an empty workspace first. A useState lazy initializer would
- // do the same but re-runs the store mutation on every Strict Mode
- // double-invoke; this ref guard makes the seed idempotent instead.
- if (!seededRef.current) {
- seededRef.current = true
- seed(queryClient)
- }
-
- useEffect(() => {
- setTheme('light')
- const prevBody = document.body.style.background
- document.body.style.background = FRAME_BG
- document.documentElement.style.background = FRAME_BG
-
- // Intercept sidebar nav clicks → switch the in-place view (no real routing).
- const hrefToView = (href: string): View | null => {
- if (/\/integrations(\b|\/|\?)/.test(href)) return 'integrations'
- if (/\/tables(\b|\/|\?)/.test(href)) return 'tables'
- if (/\/files(\b|\/|\?)/.test(href)) return 'files'
- if (/\/knowledge(\b|\/|\?)/.test(href)) return 'knowledge'
- if (/\/logs(\b|\/|\?)/.test(href)) return 'logs'
- if (/\/w\//.test(href)) return 'workflow'
- if (/\/workspace\/[^/]+\/?($|\?)/.test(href) || /\/home(\b|\/|\?)/.test(href)) return 'chat'
- return null
- }
- const onClick = (e: MouseEvent) => {
- const a = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null
- if (!a) return
- const v = hrefToView(a.getAttribute('href') || '')
- if (v) {
- e.preventDefault()
- e.stopPropagation()
- setView(v)
- }
- }
- document.addEventListener('click', onClick, true)
-
- // double-cast-allowed: dev-only capture harness exposes imperative hooks on window for Playwright
- const w = window as unknown as {
- __setCamera?: (s: number, tx: number, ty: number) => void
- __cardSize?: () => { w: number; h: number }
- __deploy?: () => void
- __setView?: (v: View) => void
- }
- w.__setCamera = (s, tx, ty) => {
- const cam = cameraRef.current
- if (cam) cam.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`
- }
- w.__cardSize = () => ({
- w: cardRef.current?.offsetWidth ?? CARD_W,
- h: cardRef.current?.offsetHeight ?? CARD_H,
- })
- w.__setView = (v) => setView(v)
- w.__deploy = () =>
- queryClient.setQueryData(deploymentKeys.info(WF_ID), {
- isDeployed: true,
- needsRedeployment: false,
- deployedAt: new Date().toISOString(),
- })
-
- return () => {
- document.body.style.background = prevBody
- document.removeEventListener('click', onClick, true)
- w.__setCamera = undefined
- w.__cardSize = undefined
- w.__deploy = undefined
- w.__setView = undefined
- }
- }, [setTheme, queryClient])
-
- return (
-
-
-
-
-
-
-
-
- {view === 'workflow' ? (
-
- ) : view === 'integrations' ? (
-
-
-
- ) : view === 'knowledge' ? (
-
-
-
- ) : view === 'tables' ? (
-
-
-
- ) : view === 'files' ? (
-
-
-
- ) : view === 'logs' ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
index 766e4c77ef6..d782141e81c 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
@@ -1,8 +1,36 @@
/**
* @vitest-environment jsdom
*/
-import { describe, expect, it } from 'vitest'
-import { extractImageFiles } from './image-paste'
+import { Editor } from '@tiptap/core'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
+import {
+ createPublicFileContentSource,
+ createWorkspaceFileContentSource,
+} from '@/hooks/use-file-content-source'
+import { createMarkdownEditorExtensions } from './editor-extensions'
+import {
+ extractImageFiles,
+ extractImgSrcs,
+ findHostedImageAttrs,
+ hasHostedImageHtml,
+ isInlineRouteSrc,
+ shouldSkipFileUpload,
+} from './image-paste'
+
+// jsdom lacks `elementFromPoint`; the Placeholder extension's viewport tracking calls it on mount.
+beforeEach(() => {
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ Element.prototype.scrollIntoView = vi.fn()
+ document.elementFromPoint = vi.fn(() => null)
+})
function imageFile(name = 'shot.png'): File {
return new File([''], name, { type: 'image/png' })
@@ -54,3 +82,209 @@ describe('extractImageFiles', () => {
expect(result).toEqual([])
})
})
+
+describe('hasHostedImageHtml', () => {
+ const isHosted = (src: string) => src.startsWith('/api/files/view/')
+
+ it('detects an whose src is recognized as one of our own hosted files', () => {
+ expect(hasHostedImageHtml(' ', isHosted)).toBe(true)
+ })
+
+ it('is false when the html has no img, or the img src is not one of ours', () => {
+ expect(hasHostedImageHtml('hello
', isHosted)).toBe(false)
+ expect(hasHostedImageHtml(' ', isHosted)).toBe(false)
+ expect(hasHostedImageHtml('', isHosted)).toBe(false)
+ })
+
+ it('matches a hosted img among multiple candidates', () => {
+ expect(
+ hasHostedImageHtml(
+ ' ',
+ isHosted
+ )
+ ).toBe(true)
+ })
+
+ // Regression: the browser doesn't put the node's persisted `attrs.src` (`/api/files/view/...`)
+ // onto the clipboard when a rendered is copied — it puts the actual DOM `src`, which is
+ // `resolveImageSrc`'s REWRITTEN display URL (`/…/files/inline?key=…`/`?fileId=…`). A predicate
+ // that only recognizes the persisted shape (as `extractEmbeddedFileRef` alone does) never matches
+ // a real same-page copy, silently falling through to the re-upload path it exists to avoid.
+ it('recognizes the real rendered end-to-end, not just the persisted reference shape', () => {
+ const ws = createWorkspaceFileContentSource('ws-1')
+ const renderedFromKey = ws.resolveImageSrc(
+ '/api/files/serve/workspace/ws-1/1700000000000-deadbeefdeadbeef-photo.png'
+ )
+ const renderedFromFileId = ws.resolveImageSrc('/api/files/view/wf_abc')
+ expect(renderedFromKey).toMatch(/^\/api\/workspaces\/ws-1\/files\/inline\?key=/)
+ expect(renderedFromFileId).toBe('/api/workspaces/ws-1/files/inline?fileId=wf_abc')
+
+ // extractEmbeddedFileRef alone (the persisted-content recognizer) does NOT match either
+ // rendered form — that's the exact gap isInlineRouteSrc closes.
+ expect(extractEmbeddedFileRef(renderedFromKey as string)).toBeNull()
+ expect(extractEmbeddedFileRef(renderedFromFileId as string)).toBeNull()
+
+ const isHostedReal = (src: string) => extractEmbeddedFileRef(src) !== null
+ expect(hasHostedImageHtml(` `, isHostedReal)).toBe(true)
+ expect(hasHostedImageHtml(` `, isHostedReal)).toBe(true)
+ })
+
+ it('recognizes the public-share inline route too', () => {
+ const pub = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content')
+ const rendered = pub.resolveImageSrc('/api/files/view/wf_abc')
+ expect(rendered).toBe('/api/files/public/tok_1/inline?fileId=wf_abc')
+ expect(hasHostedImageHtml(` `, () => false)).toBe(true)
+ })
+
+ it('matches a valid unquoted src attribute (unquoted attribute values are valid HTML)', () => {
+ expect(hasHostedImageHtml(' ', isHosted)).toBe(true)
+ expect(hasHostedImageHtml(" ", isHosted)).toBe(
+ true
+ )
+ expect(hasHostedImageHtml(' ', isHosted)).toBe(false)
+ })
+
+ it('matches single-quoted src attributes too', () => {
+ expect(hasHostedImageHtml(" ", isHosted)).toBe(true)
+ })
+})
+
+describe('extractImgSrcs', () => {
+ it('extracts every img src in document order, including duplicates', () => {
+ expect(
+ extractImgSrcs('text
')
+ ).toEqual(['/a.png', '/b.png', '/a.png'])
+ })
+
+ it('returns an empty array for html with no img', () => {
+ expect(extractImgSrcs('hello
')).toEqual([])
+ expect(extractImgSrcs('')).toEqual([])
+ })
+})
+
+describe('shouldSkipFileUpload (shared by paste and drop)', () => {
+ const isHosted = (src: string) => src.startsWith('/api/files/view/')
+ const hostedHtml = ' '
+
+ it('skips upload for a single already-hosted image', () => {
+ expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
+ })
+
+ it('does not skip when there is no html, or the html is not one of ours', () => {
+ expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
+ expect(shouldSkipFileUpload([imageFile()], ' ', isHosted)).toBe(
+ false
+ )
+ })
+
+ it('does not skip when there are no files to upload in the first place', () => {
+ expect(shouldSkipFileUpload([], hostedHtml, isHosted)).toBe(false)
+ })
+
+ // Regression: a genuinely mixed paste/drop (the hosted image plus a separate new one) must
+ // still upload the new file — bailing out entirely here would silently drop it.
+ it('does not skip a mixed paste/drop carrying more than one image file', () => {
+ expect(
+ shouldSkipFileUpload([imageFile('a.png'), imageFile('b.png')], hostedHtml, isHosted)
+ ).toBe(false)
+ })
+
+ // Regression: this must be content-based (the accompanying html), not keyed off any mutable
+ // per-drag flag like ProseMirror's `view.dragging` — that flag can go briefly stale (cleared up
+ // to ~50ms late via `dragend` when a prior internal drag was dropped outside the view), and a
+ // flag-based check would incorrectly suppress upload of an unrelated new file dropped in that
+ // window. This function only reacts to what THIS specific event's `html`/`images` contain.
+ it('is a pure function of the images/html actually offered, independent of any drag-session flag', () => {
+ expect(shouldSkipFileUpload([imageFile()], '', isHosted)).toBe(false)
+ expect(shouldSkipFileUpload([imageFile()], hostedHtml, isHosted)).toBe(true)
+ })
+})
+
+describe('isInlineRouteSrc', () => {
+ it('recognizes the workspace- and public-scoped inline route with key or fileId', () => {
+ expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?key=workspace%2Fws-1%2Fa.png')).toBe(
+ true
+ )
+ expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?fileId=wf_abc')).toBe(true)
+ expect(isInlineRouteSrc('/api/files/public/tok_1/inline?fileId=wf_abc')).toBe(true)
+ })
+
+ it('rejects non-inline paths, unrecognized query params, and external/absolute origins', () => {
+ expect(isInlineRouteSrc('/api/files/serve/workspace/ws-1/a.png')).toBe(false)
+ expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline')).toBe(false)
+ expect(isInlineRouteSrc('/api/workspaces/ws-1/files/inline?other=1')).toBe(false)
+ expect(isInlineRouteSrc('https://other-site.com/files/inline?key=x')).toBe(false)
+ expect(isInlineRouteSrc('data:image/png;base64,aaaa')).toBe(false)
+ })
+})
+
+describe('findHostedImageAttrs', () => {
+ const ws = createWorkspaceFileContentSource('ws-1')
+
+ function docWithImages(...attrs: Array>): Editor {
+ return new Editor({
+ extensions: createMarkdownEditorExtensions({ placeholder: '' }),
+ content: {
+ type: 'doc',
+ content: attrs.map((a) => ({ type: 'image', attrs: a })),
+ },
+ })
+ }
+
+ // Regression: this is the exact mechanism that avoids persisting the display-layer inline URL
+ // (Cursor's "Paste persists display image URLs" finding) — cloning the REAL node's attrs rather
+ // than re-deriving a node from the clipboard html's rewritten src.
+ it('finds the existing node whose RESOLVED (display) src matches, and returns its REAL persisted attrs', () => {
+ const persistedSrc = '/api/files/view/wf_abc'
+ const editor = docWithImages({ src: persistedSrc, alt: 'photo', width: '300' })
+ const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
+ expect(renderedSrc).not.toBe(persistedSrc) // sanity: the rendered form really differs
+
+ const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
+ expect(match).not.toBeNull()
+ expect(match?.src).toBe(persistedSrc) // the REAL persisted src, not the rendered one
+ expect(match?.alt).toBe('photo')
+ expect(match?.width).toBe('300')
+ })
+
+ it('returns null when no node in the doc resolves to any target src', () => {
+ const editor = docWithImages({ src: '/api/files/view/wf_other' })
+ const match = findHostedImageAttrs(
+ editor.state.doc,
+ ['/api/workspaces/ws-1/files/inline?fileId=wf_abc'],
+ ws.resolveImageSrc
+ )
+ expect(match).toBeNull()
+ })
+
+ it('returns null for an empty doc or an empty target list', () => {
+ const editor = docWithImages()
+ expect(findHostedImageAttrs(editor.state.doc, ['/anything'], ws.resolveImageSrc)).toBeNull()
+ const editorWithImage = docWithImages({ src: '/api/files/view/wf_abc' })
+ expect(findHostedImageAttrs(editorWithImage.state.doc, [], ws.resolveImageSrc)).toBeNull()
+ })
+
+ it('matches the first of several images, not just the last', () => {
+ const editor = docWithImages(
+ { src: '/api/files/view/wf_one', alt: 'one' },
+ { src: '/api/files/view/wf_two', alt: 'two' }
+ )
+ const renderedTwo = ws.resolveImageSrc('/api/files/view/wf_two') as string
+ const match = findHostedImageAttrs(editor.state.doc, [renderedTwo], ws.resolveImageSrc)
+ expect(match?.alt).toBe('two')
+ })
+
+ it('returns a defensive copy, not a live reference to the node attrs object', () => {
+ const persistedSrc = '/api/files/view/wf_abc'
+ const editor = docWithImages({ src: persistedSrc, alt: 'photo' })
+ const renderedSrc = ws.resolveImageSrc(persistedSrc) as string
+ const match = findHostedImageAttrs(editor.state.doc, [renderedSrc], ws.resolveImageSrc)
+ expect(match).not.toBeNull()
+ if (match) match.alt = 'mutated'
+ let originalAlt: unknown
+ editor.state.doc.descendants((node) => {
+ if (node.type.name === 'image') originalAlt = node.attrs.alt
+ })
+ expect(originalAlt).toBe('photo')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts
index ff72fededf9..d3bc170f317 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts
@@ -12,3 +12,121 @@ export function extractImageFiles(transfer: DataTransfer | null): File[] {
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
}
+
+// `src` may be double-quoted, single-quoted, or (validly) unquoted per the HTML spec — the browser's
+// own clipboard serialization always quotes it, but other producers of `text/html` are not obligated
+// to.
+const IMG_SRC_RE = / ]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
+const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])
+
+/**
+ * True for the *display-layer* inline route `resolveImageSrc` (see `use-file-content-source.tsx`)
+ * rewrites an embed to — workspace-scoped `/api/workspaces/{workspaceId}/files/inline?key=…`/`?fileId=…`
+ * or public-share-scoped `/api/files/public/{token}/inline?key=…`/`?fileId=…`. This is the shape
+ * actually rendered into ` `, and so what a same-page copy's `text/html` clipboard payload
+ * actually contains — NOT the raw stored reference `extractEmbeddedFileRef` (in
+ * `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only matches the persisted `src` before
+ * that rewrite. Checked separately from (rather than folded into) `extractEmbeddedFileRef` since that
+ * helper is shared with server-side authorization/export code operating on persisted content, where
+ * this display-only shape should never legitimately appear.
+ */
+export function isInlineRouteSrc(src: string): boolean {
+ try {
+ const parsed = new URL(src, 'http://placeholder')
+ if (parsed.origin !== 'http://placeholder') return false
+ if (!parsed.pathname.endsWith('/inline')) return false
+ for (const key of parsed.searchParams.keys()) {
+ if (INLINE_ROUTE_QUERY_KEYS.has(key)) return true
+ }
+ return false
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Extracts every ` ` `src` value found in `html`, in document order (may contain duplicates).
+ */
+export function extractImgSrcs(html: string): string[] {
+ const srcs: string[] = []
+ for (const match of html.matchAll(IMG_SRC_RE)) {
+ const src = match[1] ?? match[2] ?? match[3]
+ if (src) srcs.push(src)
+ }
+ return srcs
+}
+
+/**
+ * True when `html` contains an ` ` whose `src` is already one of our own hosted workspace file
+ * references. Copying a rendered ` ` that's already on the page (e.g. Cmd+C after clicking it to
+ * select it) makes the browser put BOTH `text/html` (the real serialized node, with its real hosted
+ * `src`) AND a synthesized image `File` onto the clipboard — the same "drag a web image out" behavior
+ * that {@link extractImageFiles} alone can't tell apart from a genuinely new external image paste.
+ */
+export function hasHostedImageHtml(html: string, isHostedRef: (src: string) => boolean): boolean {
+ return extractImgSrcs(html).some((src) => isHostedRef(src) || isInlineRouteSrc(src))
+}
+
+/**
+ * True when a paste or drop should be diverted away from the upload-from-file path — it carries
+ * exactly one image file, and the accompanying `text/html` shows it's a same-page copy of an
+ * already-hosted image (see {@link hasHostedImageHtml}) rather than a genuinely new external image.
+ * Content-based (not `view.dragging`-based, for the drop case): `view.dragging` can go briefly stale
+ * (cleared up to ~50ms late by ProseMirror's own `dragend` handler when a prior internal drag was
+ * dropped outside this view) and must never suppress upload of an unrelated, genuinely new file that
+ * happens to land in that window — this check only reacts to what THIS specific event's `html`
+ * actually contains. Gated on exactly one file — a genuinely mixed paste/drop (the hosted image plus a
+ * separate new one) must still upload the new file, not have the whole paste/drop diverted.
+ */
+export function shouldSkipFileUpload(
+ images: File[],
+ html: string,
+ isHostedRef: (src: string) => boolean
+): boolean {
+ return images.length === 1 && Boolean(html) && hasHostedImageHtml(html, isHostedRef)
+}
+
+/** Minimal shape of a ProseMirror image node — just enough to read its type name and attrs. */
+interface ImageLikeNode {
+ type: { name: string }
+ attrs: Record
+}
+
+/** Minimal shape of a ProseMirror doc — just enough to walk its nodes. */
+interface DescendantsDoc {
+ descendants: (callback: (node: ImageLikeNode) => boolean | undefined) => void
+}
+
+/**
+ * Finds the first `image` node already in `doc` whose *rendered* src (`resolveImageSrc(node.attrs.src)`)
+ * matches one of `targetSrcs`, and returns its attrs — a defensive copy, safe to hand straight to
+ * `insertContentAt`. Returns `null` if no match is found (e.g. the source node was deleted, or this is
+ * genuinely a different document than the one the html was copied from).
+ *
+ * Used to clone a same-page copy/drag of an already-hosted image faithfully — the exact persisted
+ * `src` (and every other attribute: width, height, href, title…) — rather than re-deriving a node from
+ * the clipboard/dataTransfer `html`, whose `src` is `resolveImageSrc`'s rewritten *display* URL, not the
+ * real persisted one. Inserting a node built from that display URL would bake it into the document,
+ * which public share/export/referenced-by-doc tracking don't recognize (they only match the persisted
+ * shape) — this lookup avoids ever constructing such a node in the first place.
+ */
+export function findHostedImageAttrs(
+ doc: DescendantsDoc,
+ targetSrcs: string[],
+ resolveImageSrc: (src: string | undefined) => string | undefined
+): Record | null {
+ const targets = new Set(targetSrcs)
+ let found: Record | null = null
+ doc.descendants((node) => {
+ if (found) return false
+ if (node.type.name === 'image') {
+ const resolved = resolveImageSrc(node.attrs.src as string | undefined)
+ if (resolved && targets.has(resolved)) {
+ found = { ...node.attrs }
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-mark-precedence.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-mark-precedence.test.ts
new file mode 100644
index 00000000000..a2edbbae3e5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-mark-precedence.test.ts
@@ -0,0 +1,217 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * A mark stacked on top of another color-setting context must not steal that context's color: an
+ * element's own explicit `color` rule always wins over an inherited value, regardless of how specific
+ * the ancestor's selector is. Two contexts hit this in practice:
+ *
+ * - A link's blue, stacked with bold/italic/strikethrough/inline-code. `strong`/`em`/`code` no longer
+ * set their own `color` at all (it was redundant with the prose default anyway, and — like the
+ * `mark`/highlight rule's own `color: inherit` — the absence of a rule is what lets inheritance
+ * carry through correctly from ANY ambient context, not just links). `del`/`s` genuinely need their
+ * own dimmer default, so they keep an explicit `color` plus an override for the link case.
+ * - `h6`'s intentionally dimmer `--text-secondary` (vs. every other heading and the prose default,
+ * both `--text-primary`), stacked with bold/italic — before strong/em's color was removed, a bold
+ * run inside an `h6` incorrectly showed the brighter `--text-primary` instead of `h6`'s own tone.
+ *
+ * These load the real, shipped `rich-markdown-editor.css` (not a copy) and assert against
+ * `getComputedStyle` in jsdom. jsdom's CSS engine does not resolve `var(...)` references to actual
+ * color values, but it does correctly resolve the cascade/specificity/inheritance winner and returns
+ * that declaration's authored value verbatim — so asserting the winning value is `var(--brand-secondary)`
+ * (vs. e.g. `var(--text-primary)`) is a precise, real assertion about which CSS rule wins, not a proxy.
+ */
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { afterEach, beforeAll, describe, expect, it } from 'vitest'
+
+const CSS_PATH = path.join(__dirname, 'rich-markdown-editor.css')
+const LINK_COLOR = 'var(--brand-secondary)'
+
+beforeAll(() => {
+ const style = document.createElement('style')
+ style.textContent = readFileSync(CSS_PATH, 'utf-8')
+ document.head.appendChild(style)
+})
+
+let container: HTMLDivElement | null = null
+
+afterEach(() => {
+ container?.remove()
+ container = null
+})
+
+/** Mounts `html` inside a `.rich-markdown-prose` container (the real editor's root class) and returns it. */
+function mount(html: string): HTMLDivElement {
+ container = document.createElement('div')
+ container.className = 'rich-markdown-prose'
+ container.innerHTML = html
+ document.body.appendChild(container)
+ return container
+}
+
+function colorOf(el: Element | null): string {
+ if (!el) throw new Error('element not found')
+ return getComputedStyle(el).color
+}
+
+describe('link color baseline (no stacked mark)', () => {
+ it('a plain link is brand-secondary colored', () => {
+ const root = mount('link ')
+ expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
+ })
+})
+
+describe('a mark with no link keeps its own color (regression guard: the fix must not force every mark blue)', () => {
+ it.each([
+ { tag: 'strong', html: 'bold ', color: 'var(--text-primary)' },
+ { tag: 'em', html: 'italic ', color: 'var(--text-primary)' },
+ { tag: 'del', html: 'struck', color: 'var(--text-tertiary)' },
+ { tag: 's', html: 'struck ', color: 'var(--text-tertiary)' },
+ { tag: 'code', html: 'code', color: 'var(--text-primary)' },
+ ])('$tag alone renders $color, not link color', ({ tag, html, color }) => {
+ const root = mount(html)
+ expect(colorOf(root.querySelector(tag))).toBe(color)
+ expect(colorOf(root.querySelector(tag))).not.toBe(LINK_COLOR)
+ })
+})
+
+describe('mark nested INSIDE a link (`text `) — link color wins', () => {
+ it.each([
+ { tag: 'strong', html: 'bold link ' },
+ { tag: 'em', html: 'italic link ' },
+ { tag: 'del', html: 'struck link ' },
+ { tag: 's', html: 'struck link ' },
+ { tag: 'code', html: 'code link ' },
+ ])('$tag inside a link is link-colored, not its own default color', ({ tag, html }) => {
+ const root = mount(html)
+ expect(colorOf(root.querySelector(tag))).toBe(LINK_COLOR)
+ })
+})
+
+describe('mark WRAPPING a link (`text `) — link color still wins', () => {
+ it.each([
+ { tag: 'strong', html: 'bold link ' },
+ { tag: 'em', html: 'italic link ' },
+ { tag: 'del', html: 'struck link ' },
+ { tag: 's', html: 'struck link ' },
+ { tag: 'code', html: 'code link ' },
+ ])('a link inside $tag is link-colored regardless of nesting direction', ({ tag, html }) => {
+ const root = mount(html)
+ expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
+ })
+})
+
+describe('each mark keeps its own non-color styling even when link-colored', () => {
+ it('bold link keeps font-weight 600', () => {
+ const root = mount('bold link ')
+ const strong = root.querySelector('strong') as HTMLElement
+ expect(colorOf(strong)).toBe(LINK_COLOR)
+ expect(getComputedStyle(strong).fontWeight).toBe('600')
+ })
+
+ it('italic link keeps font-style italic', () => {
+ const root = mount('italic link ')
+ const em = root.querySelector('em') as HTMLElement
+ expect(colorOf(em)).toBe(LINK_COLOR)
+ expect(getComputedStyle(em).fontStyle).toBe('italic')
+ })
+
+ it('strikethrough link keeps text-decoration line-through (del and s)', () => {
+ for (const tag of ['del', 's']) {
+ const root = mount(`<${tag}>struck link${tag}> `)
+ const el = root.querySelector(tag) as HTMLElement
+ expect(colorOf(el)).toBe(LINK_COLOR)
+ expect(getComputedStyle(el).textDecoration).toContain('line-through')
+ container?.remove()
+ }
+ })
+
+ it('inline-code link keeps its monospace font and background', () => {
+ const root = mount('code link ')
+ const code = root.querySelector('code') as HTMLElement
+ const computed = getComputedStyle(code)
+ expect(colorOf(code)).toBe(LINK_COLOR)
+ expect(computed.fontFamily).toContain('mono')
+ expect(computed.background).toContain('var(--surface-5)')
+ })
+})
+
+describe('multiple marks stacked together with a link', () => {
+ it('bold + italic + link: link color wins, both font-weight and font-style are preserved', () => {
+ const root = mount('bold italic link ')
+ const em = root.querySelector('em') as HTMLElement
+ const strong = root.querySelector('strong') as HTMLElement
+ expect(colorOf(em)).toBe(LINK_COLOR)
+ expect(colorOf(strong)).toBe(LINK_COLOR)
+ expect(getComputedStyle(em).fontStyle).toBe('italic')
+ expect(getComputedStyle(strong).fontWeight).toBe('600')
+ })
+
+ it('bold + italic + strikethrough + link: link color wins at every nesting level', () => {
+ const root = mount(
+ 'bold italic struck link '
+ )
+ for (const tag of ['strong', 'em', 'del']) {
+ expect(colorOf(root.querySelector(tag))).toBe(LINK_COLOR)
+ }
+ })
+
+ it('a mark stack with NO link present is unaffected by the link-precedence rule', () => {
+ const root = mount('bold italic, no link ')
+ expect(colorOf(root.querySelector('em'))).toBe('var(--text-primary)')
+ expect(colorOf(root.querySelector('strong'))).toBe('var(--text-primary)')
+ })
+})
+
+describe('a link elsewhere in the document does not bleed color into unrelated marks', () => {
+ it('a sibling bold run outside any link keeps its own color', () => {
+ const root = mount('a link and bold text
')
+ expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
+ expect(colorOf(root.querySelector('strong'))).toBe('var(--text-primary)')
+ })
+})
+
+describe('headings stack correctly with marks (same bug class, a different ambient color)', () => {
+ it.each(['h1', 'h2', 'h3', 'h4', 'h5'])(
+ '%s carries --text-primary, same as bold/italic inside it — no visible regression either way',
+ (tag) => {
+ const root = mount(`<${tag}>bold italic ${tag}>`)
+ expect(colorOf(root.querySelector(tag))).toBe('var(--text-primary)')
+ expect(colorOf(root.querySelector('strong'))).toBe('var(--text-primary)')
+ expect(colorOf(root.querySelector('em'))).toBe('var(--text-primary)')
+ }
+ )
+
+ // h6 is the one heading with its own dimmer tone (--text-secondary, not --text-primary like every
+ // other heading and the prose default) — the exact shape of ambient color strong/em must inherit
+ // rather than reset, the same failure mode as the link case above just triggered by a heading
+ // instead of an .
+ it('h6 keeps its dimmer --text-secondary, and bold/italic inside it inherit that tone (not --text-primary)', () => {
+ const root = mount('bold italic plain ')
+ const h6 = root.querySelector('h6') as HTMLElement
+ expect(colorOf(h6)).toBe('var(--text-secondary)')
+ expect(colorOf(root.querySelector('strong'))).toBe('var(--text-secondary)')
+ expect(colorOf(root.querySelector('em'))).toBe('var(--text-secondary)')
+ })
+
+ it('inline code inside h6 also inherits --text-secondary, not its own hardcoded default', () => {
+ const root = mount('code ')
+ expect(colorOf(root.querySelector('code'))).toBe('var(--text-secondary)')
+ })
+
+ it('a struck-through run inside h6 keeps its own dimmer tertiary tone (mark-own-color still wins over a non-link ambient context)', () => {
+ const root = mount('struck ')
+ expect(colorOf(root.querySelector('del'))).toBe('var(--text-tertiary)')
+ })
+
+ it('an anchor heading (link wrapping the whole heading) still shows link color, unaffected by h6', () => {
+ const root = mount('')
+ expect(colorOf(root.querySelector('a'))).toBe(LINK_COLOR)
+ })
+
+ it('bold + italic + link inside h6: link color wins over both the mark defaults and h6 itself', () => {
+ const root = mount('')
+ expect(colorOf(root.querySelector('em'))).toBe(LINK_COLOR)
+ expect(colorOf(root.querySelector('strong'))).toBe(LINK_COLOR)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
index c8b56385b56..5c698d25835 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
@@ -94,16 +94,23 @@
color: var(--text-secondary);
}
+/* No explicit `color` on strong/em — an inherited value never beats an element's own explicit rule
+ * regardless of the ancestor selector's specificity, so a hardcoded color here would silently win
+ * over ANY ambient color a ancestor sets that differs from the prose default (a link's blue, `h6`'s
+ * dimmer `--text-secondary`, or any future colored container) — the same failure mode `color:
+ * inherit` on the `mark` (highlight) rule below already avoids. Bold/italic text is meant to carry
+ * the surrounding color, not reset it; omitting `color` here lets normal CSS inheritance do that
+ * correctly in every context, including ones this file doesn't know about yet. */
.rich-markdown-prose strong {
font-weight: 600;
- color: var(--text-primary);
}
.rich-markdown-prose em {
font-style: italic;
- color: var(--text-primary);
}
+/* del/s DO need their own explicit dimmer color (distinct from the surrounding text) as their
+ * default — but see the link-color override below for why that must still yield inside a link. */
.rich-markdown-prose del,
.rich-markdown-prose s {
color: var(--text-tertiary);
@@ -223,15 +230,31 @@
font-style: italic;
}
+/* No explicit `color` — see the strong/em comment above; inline code composites its own font/
+ * background/radius over whatever color the surrounding context (link, heading, prose default)
+ * already supplies. */
.rich-markdown-prose code {
font-family: var(--font-martian-mono, ui-monospace, monospace);
font-size: 0.875em;
background: var(--surface-5);
- color: var(--text-primary);
border-radius: 4px;
padding: 0.125rem 0.375rem;
}
+/* del/s are the one mark that both needs its own explicit default color (dimmer than the prose
+ * default, unlike strong/em/code above) AND must still yield it to a link's blue — an inherited
+ * value never beats an element's own explicit rule regardless of the ancestor selector's
+ * specificity, so without this override a struck-through link would show the dimmer tertiary color
+ * instead of the link's blue. Covers both DOM nesting orders since ProseMirror's mark array — and so
+ * the render order marks nest in — depends on which was toggled first, not a fixed schema order.
+ * Declared after the del/s rule above so it wins on source order too, not just specificity. */
+.rich-markdown-prose a del,
+.rich-markdown-prose a s,
+.rich-markdown-prose del a,
+.rich-markdown-prose s a {
+ color: var(--brand-secondary);
+}
+
.rich-markdown-prose pre,
.rich-markdown-prose .mermaid-diagram-frame {
background: var(--surface-5);
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
index 7114210f8ca..5057c835af9 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
@@ -7,13 +7,20 @@ import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import { useRouter } from 'next/navigation'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
import type { SaveStatus } from '@/hooks/use-autosave'
+import { useFileContentSource } from '@/hooks/use-file-content-source'
import { PreviewLoadingFrame } from '../preview-shared'
import { useEditableFileContent } from '../use-editable-file-content'
import { createMarkdownEditorExtensions } from './editor-extensions'
import { findHeadingPos } from './heading-anchors'
-import { extractImageFiles } from './image-paste'
+import {
+ extractImageFiles,
+ extractImgSrcs,
+ findHostedImageAttrs,
+ shouldSkipFileUpload,
+} from './image-paste'
import {
applyFrontmatter,
normalizeLinkHref,
@@ -208,6 +215,7 @@ export function LoadedRichMarkdownEditor({
const containerRef = useRef(null)
const uploadFile = useUploadWorkspaceFile()
const editorInstanceRef = useRef(null)
+ const source = useFileContentSource()
/**
* The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position
@@ -250,6 +258,31 @@ export function LoadedRichMarkdownEditor({
}
}
+ /**
+ * A same-page copy/drag of an already-hosted ` ` carries the clipboard/dataTransfer `html`'s
+ * *display* src (`source.resolveImageSrc`'s rewrite), not the real persisted one — inserting a node
+ * built straight from that html would bake the display-only URL into the document, breaking public
+ * share/export/referenced-by-doc tracking for it (they only recognize the persisted shape).
+ * `findHostedImageAttrs` finds the real, already-present node with a matching resolved src instead,
+ * so the clone gets the exact real `src` (and every other attribute — width, href, title…) rather
+ * than a re-derived guess. Returns `false` (falls through to a normal upload) if no match is found,
+ * which is always correct, just occasionally a redundant upload — unlike blindly trusting the html.
+ */
+ const cloneHostedImageRef = useRef<(imgSrcs: string[], at: number) => boolean>(() => false)
+ cloneHostedImageRef.current = (imgSrcs, at) => {
+ const editor = editorInstanceRef.current
+ if (!editor) return false
+ const matchedAttrs = findHostedImageAttrs(editor.state.doc, imgSrcs, source.resolveImageSrc)
+ if (!matchedAttrs) return false
+ const safePosition = Math.min(at, editor.state.doc.content.size)
+ try {
+ editor.chain().insertContentAt(safePosition, { type: 'image', attrs: matchedAttrs }).run()
+ return true
+ } catch {
+ return false
+ }
+ }
+
const editor = useEditor({
extensions: EXTENSIONS,
editable: isEditable,
@@ -298,9 +331,32 @@ export function LoadedRichMarkdownEditor({
window.open(normalized, '_blank', 'noopener,noreferrer')
return true
},
+ /**
+ * Inserts pasted image files at the caret. A same-page copy of an already-hosted ` ` (e.g.
+ * Cmd+C after clicking it to select it) makes the browser add BOTH `text/html` (the real node,
+ * with its real hosted `src`) AND a synthesized image `File` to the clipboard — indistinguishable
+ * from a genuine external image paste by `clipboardData` files/items alone. When the HTML sibling
+ * already names one of our own hosted files, look up the matching node already in this doc and
+ * clone ITS real attrs (see `cloneHostedImageRef`) instead of re-uploading the pasted bytes as a
+ * brand-new, distinct file — letting the editor's DEFAULT html-based paste do that clone instead
+ * would persist the html's display-layer src rather than the real one. Only applied when exactly
+ * one image file is offered: a genuinely mixed paste (the hosted image plus a separate new one)
+ * must still upload the new file rather than have the whole paste diverted by this bypass.
+ */
handlePaste: (view, event) => {
if (!view.editable) return false
const images = extractImageFiles(event.clipboardData)
+ const html = event.clipboardData?.getData('text/html') ?? ''
+ if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) {
+ const cloned = cloneHostedImageRef.current(
+ extractImgSrcs(html),
+ view.state.selection.from
+ )
+ if (cloned) {
+ event.preventDefault()
+ return true
+ }
+ }
if (images.length === 0) return false
event.preventDefault()
void insertImagesRef.current(images, view.state.selection.from)
@@ -310,10 +366,26 @@ export function LoadedRichMarkdownEditor({
* Inserts dropped image files at the drop point. Any other file drop (e.g. a PDF) is swallowed so
* the browser doesn't navigate away from the editor; internal text drags carry no files and fall
* through to the default behavior.
+ *
+ * Dragging an existing image node to reorder it is also an internal drag, but the browser's
+ * native drag-and-drop synthesizes an image `File` into `event.dataTransfer` for a dragged ` `
+ * (the same mechanism that lets a user drag a web image out to their desktop) — indistinguishable
+ * from a real external drop by `dataTransfer` files/items alone. When the accompanying `text/html`
+ * shows it's a same-page drag of an already-hosted image, bail out and let ProseMirror's own
+ * default move logic run — it relocates the actual existing node object (`dragging.node`), never
+ * re-parsing html, so (unlike the paste case above) there's no display-vs-persisted src risk here.
+ * Checked from `html`, not `view.dragging`, so a stale `dragging` flag (ProseMirror clears it up
+ * to ~50ms late via `dragend` when a prior internal drag was dropped outside this view) can never
+ * suppress the plain-file swallow guard below for an unrelated drop that happens to land in that
+ * window — this only reacts to what THIS specific drop's `html` actually contains.
*/
handleDrop: (view, event) => {
if (!view.editable) return false
const images = extractImageFiles(event.dataTransfer)
+ const html = event.dataTransfer?.getData('text/html') ?? ''
+ if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) {
+ return false
+ }
if (images.length > 0) {
event.preventDefault()
const dropPos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
index 108e28f85b4..6ca5964d8c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
@@ -12,6 +12,7 @@ import {
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
+import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
@@ -20,6 +21,8 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
+import { getBlock } from '@/blocks'
+import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
@@ -27,6 +30,7 @@ import {
getTemplatesForBlock,
type ScopedBlockTemplate,
} from '@/blocks/registry'
+import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
@@ -72,7 +76,17 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
)
}, [credentials, oauthService])
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
- const hasServiceAccount = Boolean(oauthService?.serviceAccountProviderId)
+ const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
+ const blockOverlayVersion = useCustomBlockOverlayVersion()
+ // Custom Slack bots ride the slack_v2 preview flag: the setup surface stays
+ // hidden until that block is revealed for this viewer.
+ const slackBotPreviewHidden = useMemo(() => {
+ if (!isSlackBot) return false
+ const v2 = getBlock('slack_v2')
+ return !v2 || isHiddenUnder(overlayVisibility(), v2)
+ }, [isSlackBot, blockOverlayVersion])
+ const hasServiceAccount =
+ Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
const hasHandledConnectQueryRef = useRef(false)
useEffect(() => {
@@ -83,10 +97,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
if (connectMode === CONNECT_MODE.oauth && oauthService) {
setOAuthOpen(true)
handled = true
- } else if (
- connectMode === CONNECT_MODE.serviceAccount &&
- oauthService?.serviceAccountProviderId
- ) {
+ } else if (connectMode === CONNECT_MODE.serviceAccount && hasServiceAccount) {
setServiceAccountOpen(true)
handled = true
}
@@ -94,7 +105,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
hasHandledConnectQueryRef.current = true
void setConnectMode(null, { history: 'replace', scroll: false })
- }, [connectMode, oauthService, setConnectMode])
+ }, [connectMode, oauthService, hasServiceAccount, setConnectMode])
const connectOptions = oauthService
? [
@@ -105,7 +116,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
},
{
value: CONNECT_MODE.serviceAccount,
- label: 'Add service account',
+ label: isSlackBot ? 'Set up a custom bot' : 'Add service account',
icon: oauthService.serviceIcon,
},
]
@@ -164,7 +175,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
serviceIcon={oauthService.serviceIcon}
/>
)}
- {oauthService?.serviceAccountProviderId && (
+ {hasServiceAccount && oauthService?.serviceAccountProviderId && (
+ /**
+ * When set, the modal reconnects (rotates secrets on) this existing credential
+ * in place instead of creating a new one. The id is preserved, so shares and
+ * (for Slack) the ingest URL stay valid.
+ */
+ credentialId?: string
+ /** Existing display name, used to seed reconnect-capable modals. */
+ credentialDisplayName?: string
+ /** Existing description, used to seed reconnect-capable modals. */
+ credentialDescription?: string
}
/**
@@ -99,7 +117,22 @@ export function ConnectServiceAccountModal({
serviceAccountProviderId,
serviceName,
serviceIcon,
+ credentialId,
+ credentialDisplayName,
+ credentialDescription,
}: ConnectServiceAccountModalProps) {
+ if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
+ return (
+
+ )
+ }
if (serviceAccountProviderId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
return (
)
}
@@ -118,6 +154,9 @@ export function ConnectServiceAccountModal({
workspaceId={workspaceId}
serviceName={serviceName}
serviceIcon={serviceIcon}
+ credentialId={credentialId}
+ initialDisplayName={credentialDisplayName}
+ initialDescription={credentialDescription}
/>
)
}
@@ -128,6 +167,11 @@ interface ProviderModalProps {
workspaceId: string
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
+ /** When set, reconnect (rotate secrets on) this credential in place. */
+ credentialId?: string
+ /** Existing name/description, seeded into the fields on reconnect. */
+ initialDisplayName?: string
+ initialDescription?: string
}
/**
@@ -141,23 +185,27 @@ function GoogleServiceAccountModal({
workspaceId,
serviceName,
serviceIcon: ServiceIcon,
+ credentialId,
+ initialDisplayName,
+ initialDescription,
}: ProviderModalProps) {
const [jsonInput, setJsonInput] = useState('')
const [uploadedFileName, setUploadedFileName] = useState(null)
- const [displayName, setDisplayName] = useState('')
- const [description, setDescription] = useState('')
+ const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
+ const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState(null)
const createCredential = useCreateWorkspaceCredential()
+ const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setJsonInput('')
setUploadedFileName(null)
- setDisplayName('')
- setDescription('')
+ setDisplayName(initialDisplayName ?? '')
+ setDescription(initialDescription ?? '')
setError(null)
- }, [open])
+ }, [open, initialDisplayName, initialDescription])
/**
* Try to auto-populate display name from the JSON `client_email`. Silent on
@@ -209,13 +257,22 @@ function GoogleServiceAccountModal({
return
}
try {
- await createCredential.mutateAsync({
- workspaceId,
- type: 'service_account',
- displayName: displayName.trim() || undefined,
- description: description.trim() || undefined,
- serviceAccountJson: trimmed,
- })
+ if (credentialId) {
+ await updateCredential.mutateAsync({
+ credentialId,
+ serviceAccountJson: trimmed,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ } else {
+ await createCredential.mutateAsync({
+ workspaceId,
+ type: 'service_account',
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ serviceAccountJson: trimmed,
+ })
+ }
onOpenChange(false)
} catch (err: unknown) {
const message = getErrorMessage(err, 'Failed to add service account')
@@ -224,7 +281,7 @@ function GoogleServiceAccountModal({
}
}
- const isPending = createCredential.isPending
+ const isPending = createCredential.isPending || updateCredential.isPending
const isDisabled = !jsonInput.trim() || isPending
return (
@@ -315,45 +372,59 @@ function AtlassianServiceAccountModal({
workspaceId,
serviceName,
serviceIcon: ServiceIcon,
+ credentialId,
+ initialDisplayName,
+ initialDescription,
}: ProviderModalProps) {
const [apiToken, setApiToken] = useState('')
const [domain, setDomain] = useState('')
- const [displayName, setDisplayName] = useState('')
- const [description, setDescription] = useState('')
+ const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
+ const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState(null)
const createCredential = useCreateWorkspaceCredential()
+ const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setApiToken('')
setDomain('')
- setDisplayName('')
- setDescription('')
+ setDisplayName(initialDisplayName ?? '')
+ setDescription(initialDescription ?? '')
setError(null)
- }, [open])
+ }, [open, initialDisplayName, initialDescription])
const trimmedToken = apiToken.trim()
const normalizedDomain = normalizeAtlassianDomain(domain)
const showDomainHint =
normalizedDomain.length > 0 && !ATLASSIAN_DOMAIN_HINT_REGEX.test(normalizedDomain)
- const isPending = createCredential.isPending
+ const isPending = createCredential.isPending || updateCredential.isPending
const isDisabled = !trimmedToken || !normalizedDomain || isPending
const handleSubmit = async () => {
setError(null)
if (isDisabled) return
try {
- await createCredential.mutateAsync({
- workspaceId,
- type: 'service_account',
- providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
- apiToken: trimmedToken,
- domain: normalizedDomain,
- displayName: displayName.trim() || undefined,
- description: description.trim() || undefined,
- })
+ if (credentialId) {
+ await updateCredential.mutateAsync({
+ credentialId,
+ apiToken: trimmedToken,
+ domain: normalizedDomain,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ } else {
+ await createCredential.mutateAsync({
+ workspaceId,
+ type: 'service_account',
+ providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ apiToken: trimmedToken,
+ domain: normalizedDomain,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ }
onOpenChange(false)
} catch (err: unknown) {
setError(messageForAtlassianError(err))
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
new file mode 100644
index 00000000000..7526064411e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
@@ -0,0 +1,463 @@
+'use client'
+
+import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ Button,
+ ChipDropdown,
+ type ChipDropdownOption,
+ ChipInput,
+ Code,
+ CopyCodeButton,
+ Label,
+ SecretInput,
+ Wizard,
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { Loader2 } from 'lucide-react'
+import { SlackIcon } from '@/components/icons'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
+import {
+ useCreateWorkspaceCredential,
+ useUpdateWorkspaceCredential,
+} from '@/hooks/queries/credentials'
+import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities'
+
+const logger = createLogger('ConnectSlackBotModal')
+
+const DEFAULT_APP_NAME = 'Sim Bot'
+const DONE_STEP = 4
+
+/** Every capability is granted by default; trimming is an opt-in dropdown. */
+const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id))
+
+const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({
+ value: c.id,
+ label: c.label,
+}))
+
+interface ConnectSlackBotModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceId: string
+ /**
+ * When set, the modal reconnects (rotates secrets on) this existing credential
+ * instead of creating a new one — the id is reused so the Slack ingest URL
+ * `/api/webhooks/slack/custom/{id}` stays valid, and saving updates the
+ * credential in place.
+ */
+ credentialId?: string
+ /** Existing display name, seeded into the bot-name field on reconnect. */
+ initialDisplayName?: string
+ /** Existing description, seeded into the description field on reconnect. */
+ initialDescription?: string
+ /** Called with the credential id after a successful create or reconnect. */
+ onCreated?: (credentialId: string) => void
+}
+
+/**
+ * One-time setup for a reusable custom Slack bot credential — the same guided
+ * wizard as the legacy in-block setup, but it persists a workspace credential
+ * instead of writing sub-block values. The credential id is pre-generated so the
+ * ingest URL `/api/webhooks/slack/custom/{id}` (and the manifest that embeds it)
+ * can be shown up front; the credential is created on the final step once the
+ * signing secret + bot token are pasted.
+ */
+export function ConnectSlackBotModal({
+ open,
+ onOpenChange,
+ workspaceId,
+ credentialId: reconnectCredentialId,
+ initialDisplayName,
+ initialDescription,
+ onCreated,
+}: ConnectSlackBotModalProps) {
+ const isReconnect = Boolean(reconnectCredentialId)
+ const [step, setStep] = useState(0)
+ const [credentialId, setCredentialId] = useState(() => reconnectCredentialId ?? generateId())
+ const [appName, setAppName] = useState(initialDisplayName ?? '')
+ const [appDescription, setAppDescription] = useState(initialDescription ?? '')
+ const [selected, setSelected] = useState>(() => new Set(ALL_CAPABILITIES))
+ const [signingSecret, setSigningSecret] = useState('')
+ const [botToken, setBotToken] = useState('')
+ const [createError, setCreateError] = useState(null)
+ const [created, setCreated] = useState(false)
+
+ const createCredential = useCreateWorkspaceCredential()
+ const updateCredential = useUpdateWorkspaceCredential()
+
+ useEffect(() => {
+ if (open) return
+ setStep(0)
+ setAppName(initialDisplayName ?? '')
+ setAppDescription(initialDescription ?? '')
+ setSelected(new Set(ALL_CAPABILITIES))
+ setSigningSecret('')
+ setBotToken('')
+ setCreateError(null)
+ // Mint a fresh ingest id only after a bot was actually saved, and never when
+ // reconnecting (that id belongs to an existing credential + Slack app).
+ // Otherwise keep it stable so a user who already pasted this Request URL into
+ // their Slack app can reopen and finish creating the credential under the
+ // same id — a regenerated id would leave Slack posting to a URL no credential
+ // resolves.
+ if (created) {
+ if (!isReconnect) setCredentialId(generateId())
+ setCreated(false)
+ }
+ }, [open, created, isReconnect, initialDisplayName, initialDescription])
+
+ // NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be
+ // able to reach this URL, so it has to be the app's public base (e.g. the
+ // tunnel host in dev), not whatever host the browser happens to be on.
+ const requestUrl = useMemo(
+ () => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`,
+ [credentialId]
+ )
+
+ const manifestJson = useMemo(() => {
+ const manifest = buildSlackManifest(selected, {
+ appName: appName.trim() || DEFAULT_APP_NAME,
+ webhookUrl: requestUrl,
+ description: appDescription,
+ })
+ return JSON.stringify(manifest, null, 2)
+ }, [selected, appName, appDescription, requestUrl])
+
+ const capabilityIds = useMemo(() => [...selected], [selected])
+ const setCapabilityIds = useCallback((next: string[]) => setSelected(new Set(next)), [])
+
+ const isPending = createCredential.isPending || updateCredential.isPending
+
+ const runCreate = useCallback(async () => {
+ setCreateError(null)
+ try {
+ if (isReconnect) {
+ // Rotate secrets on the existing credential in place — same id, so the
+ // Slack app's Request URL and any shares stay intact.
+ await updateCredential.mutateAsync({
+ credentialId,
+ signingSecret: signingSecret.trim(),
+ botToken: botToken.trim(),
+ displayName: appName.trim() || undefined,
+ description: appDescription.trim() || undefined,
+ })
+ } else {
+ await createCredential.mutateAsync({
+ workspaceId,
+ type: 'service_account',
+ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
+ id: credentialId,
+ signingSecret: signingSecret.trim(),
+ botToken: botToken.trim(),
+ displayName: appName.trim() || undefined,
+ description: appDescription.trim() || undefined,
+ })
+ }
+ setCreated(true)
+ onCreated?.(credentialId)
+ } catch (err: unknown) {
+ setCreateError(getErrorMessage(err, 'Could not connect the Slack bot.'))
+ logger.error('Failed to add custom Slack bot credential', err)
+ }
+ }, [
+ isReconnect,
+ updateCredential,
+ createCredential,
+ workspaceId,
+ credentialId,
+ signingSecret,
+ botToken,
+ appName,
+ appDescription,
+ onCreated,
+ ])
+
+ // Create the credential once when the final step is first reached (reachable
+ // only after both secrets are entered). A ref guards against re-firing on
+ // failure — retry is manual via the "Try again" button.
+ const attemptedRef = useRef(false)
+ useEffect(() => {
+ if (step !== DONE_STEP) {
+ attemptedRef.current = false
+ return
+ }
+ if (attemptedRef.current) return
+ attemptedRef.current = true
+ void runCreate()
+ }, [step, runCreate])
+
+ return (
+
+ {/* Bot name is required so the credential name, the manifest app name, and
+ uniqueness all use the user's choice — never the shared Slack team name
+ fallback, which collides for a second bot in the same workspace. */}
+ 0}>
+
+
+
+
+
+ 0}>
+
+
+ 0}>
+
+
+
+
+
+
+ )
+}
+
+interface SubStepListProps {
+ children: ReactNode
+}
+function SubStepList({ children }: SubStepListProps) {
+ return {children}
+}
+
+interface SubStepProps {
+ n: number
+ children: ReactNode
+}
+function SubStep({ n, children }: SubStepProps) {
+ return (
+
+
+ {n}
+
+
+ {children}
+
+
+ )
+}
+
+interface StepConfigureProps {
+ appName: string
+ onAppNameChange: (next: string) => void
+ appDescription: string
+ onAppDescriptionChange: (next: string) => void
+ capabilityIds: string[]
+ onCapabilityIdsChange: (next: string[]) => void
+}
+function StepConfigure({
+ appName,
+ onAppNameChange,
+ appDescription,
+ onAppDescriptionChange,
+ capabilityIds,
+ onCapabilityIdsChange,
+}: StepConfigureProps) {
+ const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length
+
+ return (
+
+
+
+ Bot name
+
+ onAppNameChange(e.target.value)}
+ placeholder={DEFAULT_APP_NAME}
+ />
+
+
+
+ Description
+
+ onAppDescriptionChange(e.target.value)}
+ placeholder="Optional — shown on the bot's Slack profile"
+ maxLength={140}
+ />
+
+
+
Permissions
+
+ {allSelected && (
+
+ Full access — the bot can read and send messages, react, upload files, and chat as an AI
+ assistant.
+
+ )}
+
+
+ )
+}
+
+interface StepCreateProps {
+ manifestJson: string
+}
+function StepCreate({ manifestJson }: StepCreateProps) {
+ return (
+
+
+
+ Copy your manifest:
+
+
+ manifest.json
+
+
+
+
+
+
+ Open the{' '}
+
+ Slack Apps page
+
+ .
+
+
+ Click Create New App → From a manifest and pick your
+ workspace.
+
+
+ Paste your manifest, then click Next → Create .
+
+
+
+ )
+}
+
+interface SecretStepProps {
+ value: string
+ onChange: (next: string) => void
+}
+function StepSecret({ value, onChange }: SecretStepProps) {
+ return (
+
+
+
+ In your new Slack app, open Basic Information .
+
+
+ Find Signing Secret and click Show , then copy it.
+
+ Paste it into the field below.
+
+
+
+ )
+}
+
+function StepToken({ value, onChange }: SecretStepProps) {
+ return (
+
+
+
+ In Slack, open Install App → Install to Workspace and
+ authorize.
+
+
+ Copy the Bot User OAuth Token (starts with xoxb-).
+
+ Paste it into the field below, then click Next.
+
+
+
+ )
+}
+
+interface SecretFieldProps {
+ label: string
+ value: string
+ onChange: (next: string) => void
+ placeholder?: string
+}
+function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) {
+ return (
+
+ {label}
+
+
+ )
+}
+
+interface StepDoneProps {
+ pending: boolean
+ created: boolean
+ error: string | null
+ onRetry: () => void
+}
+function StepDone({ pending, created, error, onRetry }: StepDoneProps) {
+ if (pending) {
+ return (
+
+
+
Verifying your bot and connecting…
+
+ )
+ }
+ if (error) {
+ return (
+
+
{error}
+
+ Try again
+
+
+ )
+ }
+ if (created) {
+ return (
+
+
+
Bot connected
+
+ It's now selectable in Slack triggers and actions across this workspace. Click Done to
+ finish.
+
+
+
+ )
+ }
+ return null
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
index 0082e066f16..11735375f5f 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
@@ -27,6 +27,10 @@ import {
UnsavedChangesModal,
useCredentialDetailForm,
} from '@/app/workspace/[workspaceId]/components/credential-detail'
+import {
+ ConnectServiceAccountModal,
+ type ServiceAccountProviderId,
+} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import {
useCreateCredentialDraft,
@@ -77,6 +81,7 @@ export function ConnectedCredentialDetail({
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
+ const [reconnectOpen, setReconnectOpen] = useState(false)
const form = useCredentialDetailForm({ credential, isAdmin, backHref: integrationsHref })
@@ -193,9 +198,13 @@ export function ConnectedCredentialDetail({
const actions =
credential && isAdmin ? (
<>
- {serviceConfig?.authType !== 'service_account' && (
+ {(credential.type === 'oauth' || credential.type === 'service_account') && (
setReconnectOpen(true)
+ : handleReconnectOAuth
+ }
disabled={connectOAuthService.isPending}
leftIcon={serviceConfig?.icon}
>
@@ -319,6 +328,20 @@ export function ConnectedCredentialDetail({
onOpenChange={form.setShowUnsavedAlert}
onDiscard={form.confirmDiscard}
/>
+
+ {credential.type === 'service_account' && credential.providerId && (
+ }
+ credentialId={credential.id}
+ credentialDisplayName={credential.displayName}
+ credentialDescription={credential.description ?? undefined}
+ />
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
index 07e4fe5e0c5..92100372f02 100644
--- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
@@ -245,36 +245,3 @@ export function useUserPermissionsContext(): WorkspaceUserPermissions & {
const { userPermissions } = useWorkspacePermissionsContext()
return userPermissions
}
-
-/**
- * Lightweight permissions provider for sandbox/capture contexts (the
- * landing-preview capture harness). Grants full edit access without any API
- * calls or workspace dependencies.
- */
-export function SandboxWorkspacePermissionsProvider({ children }: { children: React.ReactNode }) {
- const sandboxPermissions = useMemo(
- (): WorkspacePermissionsContextType => ({
- workspacePermissions: null,
- permissionsLoading: false,
- permissionsError: null,
- updatePermissions: () => {},
- refetchPermissions: async () => {},
- userPermissions: {
- canRead: true,
- canEdit: true,
- canAdmin: false,
- userPermissions: 'write',
- isLoading: false,
- error: null,
- isOfflineMode: false,
- },
- }),
- []
- )
-
- return (
-
- {children}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
index c365196abcd..41740182975 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
@@ -6,6 +6,7 @@ import {
AnthropicIcon,
BasetenIcon,
BrandfetchIcon,
+ ContextDevIcon,
DatagmaIcon,
DropcontactIcon,
EnrowIcon,
@@ -124,6 +125,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [
description: 'AI-powered search and research',
placeholder: 'Enter your Exa API key',
},
+ {
+ id: 'context_dev',
+ name: 'Context.dev',
+ icon: ContextDevIcon,
+ description: 'Web scraping, crawling, search, and brand intelligence',
+ placeholder: 'Enter your Context.dev API key',
+ },
{
id: 'serper',
name: 'Serper',
@@ -291,6 +299,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [
ids: [
'firecrawl',
'exa',
+ 'context_dev',
'serper',
'linkup',
'parallel_ai',
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
index 9c93dd8fa01..fe6d59e0967 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
@@ -157,7 +157,7 @@ function FormattedInput({
onChange={onChange}
onScroll={handleScroll}
onInput={handleScroll}
- inputClassName='text-transparent caret-[var(--text-primary)]'
+ inputClassName='font-medium font-sans text-transparent caret-[var(--text-primary)]'
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
index 4b16f2bceac..81613b3d5db 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
@@ -85,6 +85,12 @@ export function resolveCellRender({
return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'value', text }
}
+ // Enrichment outputs share an empty blockId, so `runningBlockIds` can never
+ // match them — the group-level `running` status is the only "worker picked
+ // this up" signal an enrichment cell has. Checked after the value so a
+ // rerun keeps showing the previous output until the new result lands.
+ if (isEnrichmentOutput && exec?.status === 'running') return { kind: 'running' }
+
if (inFlight && !(groupHasBlockErrors && !blockRunning)) {
// A `pending` cell whose jobId starts with `paused-` is mid-pause
// (workflow yielded for human-in-the-loop). Render as Pending rather
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 72fa3638a55..c8c0ddd9829 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
@@ -14,6 +14,7 @@ import {
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
+import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
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 { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
@@ -48,6 +49,7 @@ export function CredentialSelector({
const workspaceId = (params?.workspaceId as string) || ''
const [showConnectModal, setShowConnectModal] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
+ const [showSlackBotModal, setShowSlackBotModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -96,13 +98,18 @@ export function CredentialSelector({
const credentialsLoading = isAllCredentials ? allCredentialsLoading : oauthCredentialsLoading
- const credentials = useMemo(
- () =>
- isTriggerMode
- ? rawCredentials.filter((cred) => cred.type !== 'service_account')
- : rawCredentials,
- [rawCredentials, isTriggerMode]
- )
+ const credentialKind = subBlock.credentialKind
+
+ const credentials = useMemo(() => {
+ // A custom-bot picker lists only the reusable Slack bot credentials
+ // (service-account type), including in trigger mode.
+ if (credentialKind === 'custom-bot') {
+ return rawCredentials.filter((cred) => cred.type === 'service_account')
+ }
+ return isTriggerMode
+ ? rawCredentials.filter((cred) => cred.type !== 'service_account')
+ : rawCredentials
+ }, [rawCredentials, isTriggerMode, credentialKind])
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
@@ -178,8 +185,12 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
+ if (credentialKind === 'custom-bot') {
+ setShowSlackBotModal(true)
+ return
+ }
setShowConnectModal(true)
- }, [])
+ }, [credentialKind])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -220,9 +231,13 @@ export function CredentialSelector({
options.push({
label:
- credentials.length > 0
- ? `Connect another ${getProviderName(provider)} account`
- : `Connect ${getProviderName(provider)} account`,
+ credentialKind === 'custom-bot'
+ ? credentials.length > 0
+ ? 'Connect another custom bot'
+ : 'Set up a custom bot'
+ : credentials.length > 0
+ ? `Connect another ${getProviderName(provider)} account`
+ : `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: ,
})
@@ -232,6 +247,7 @@ export function CredentialSelector({
isAllCredentials,
allWorkspaceCredentials,
credentials,
+ credentialKind,
provider,
getProviderIcon,
getProviderName,
@@ -379,6 +395,18 @@ export function CredentialSelector({
serviceId={serviceId}
/>
)}
+
+ {showSlackBotModal && (
+ {
+ setStoreValue(newCredentialId)
+ refetchCredentials()
+ }}
+ />
+ )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx
index 5fa807b3f73..08addbf7ab4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx
@@ -1,4 +1,4 @@
-import { useCallback } from 'react'
+import { useCallback, useState } from 'react'
import { Combobox, FieldDivider, Label, Slider, Switch } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
@@ -15,6 +15,54 @@ import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('McpDynamicArgs')
+/**
+ * The dropdown UI renders each enum member as a string label/value, so it can only
+ * represent JSON Schema enums whose members are primitives — a non-primitive member
+ * (object/array) would collapse to "[object Object]" and lose its identity. Callers
+ * route a non-primitive enum to the JSON editor (`long-input`) instead.
+ */
+function isPrimitiveEnum(
+ enumValues: unknown
+): enumValues is Array
{
+ return (
+ Array.isArray(enumValues) &&
+ enumValues.every((value) => value === null || typeof value !== 'object')
+ )
+}
+
+/**
+ * True when the schema's actual value must be a JSON object/array (a plain
+ * object/array type, or a non-primitive enum member) rather than a string.
+ */
+function requiresJsonValue(paramSchema: any): boolean {
+ return (
+ paramSchema.type === 'object' ||
+ paramSchema.type === 'array' ||
+ (Array.isArray(paramSchema.enum) && !isPrimitiveEnum(paramSchema.enum))
+ )
+}
+
+/**
+ * Stable signature of an entire tool schema, for detecting whether the effective
+ * param shape has changed (independent of object identity). Signs the whole schema
+ * rather than cherry-picking fields (e.g. just `properties`) so a refresh that only
+ * changes `required`, or any other schema-level field, isn't silently missed.
+ */
+function schemaSignature(schema: unknown): string {
+ return schema ? JSON.stringify(schema) : ''
+}
+
+/**
+ * True when text looks like an attempted JSON array/object literal (starts with `[`
+ * or `{`), as opposed to plain freeform text. Used to tell an in-progress, incomplete
+ * JSON literal (which must not persist until valid — see `requiresJsonValue`) apart
+ * from the comma-separated/plain-text shorthand array params also accept.
+ */
+function looksLikeJsonLiteral(text: string): boolean {
+ const trimmed = text.trim()
+ return trimmed.startsWith('[') || trimmed.startsWith('{')
+}
+
interface McpDynamicArgsProps {
blockId: string
subBlockId: string
@@ -70,6 +118,58 @@ export function McpDynamicArgs({
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
+ /**
+ * Draft text for JSON-value params (object/array/non-primitive-enum) whose current
+ * edit isn't valid JSON yet, paired with a signature of the persisted value it was
+ * typed against. Keeping this out of toolArgs means the stored argument is always
+ * either the last valid parsed value or untouched — never malformed text that could
+ * reach tool execution. A draft is only displayed while its baseline still matches
+ * the live persisted value, so an external change to that value (undo/redo, a diff
+ * baseline switch, a collaborator's edit) can't be shadowed by stale draft text.
+ * Drafts also reset wholesale on either of two independent triggers:
+ * - the selected tool or the cached `_toolSchema` snapshot changes (this pair
+ * always drives `toolSchema` whenever a cached snapshot exists) — the live
+ * schema tracker is also re-baselined to the new tool's current signature
+ * here (even if still empty), so a tool switch never leaves the *previous*
+ * tool's signature behind to be misread as a "refresh" once the new tool's
+ * schema loads a moment later, or
+ * - for the *same* tool, the live discovered schema's signature changes to a
+ * different non-empty value than the last non-empty value actually observed
+ * — a genuine re-discovery/refresh. Comparing against the last known
+ * non-empty value (rather than merely the previous render's value) means a
+ * schema that transiently disappears and reappears — e.g. `mcpTools`
+ * refetching — still resets drafts if it comes back different, while a
+ * plain empty → non-empty transition (the initial async load) does not,
+ * since there is no prior non-empty value for this tool to compare against.
+ */
+ const [invalidJsonDrafts, setInvalidJsonDrafts] = useState<
+ Record
+ >({})
+ const toolAndCachedSchemaKey = `${selectedTool ?? ''}|${schemaSignature(cachedSchema)}`
+ const liveSchemaSignature = schemaSignature(selectedToolConfig?.inputSchema)
+ const [prevToolAndCachedSchemaKey, setPrevToolAndCachedSchemaKey] =
+ useState(toolAndCachedSchemaKey)
+ const [lastNonEmptyLiveSchemaSignature, setLastNonEmptyLiveSchemaSignature] =
+ useState(liveSchemaSignature)
+ if (prevToolAndCachedSchemaKey !== toolAndCachedSchemaKey) {
+ setInvalidJsonDrafts({})
+ setPrevToolAndCachedSchemaKey(toolAndCachedSchemaKey)
+ setLastNonEmptyLiveSchemaSignature(liveSchemaSignature)
+ } else {
+ const nextLastNonEmptyLiveSchemaSignature =
+ liveSchemaSignature !== '' ? liveSchemaSignature : lastNonEmptyLiveSchemaSignature
+ if (nextLastNonEmptyLiveSchemaSignature !== lastNonEmptyLiveSchemaSignature) {
+ const isGenuineLiveRefresh =
+ liveSchemaSignature !== '' &&
+ lastNonEmptyLiveSchemaSignature !== '' &&
+ liveSchemaSignature !== lastNonEmptyLiveSchemaSignature
+ if (isGenuineLiveRefresh) {
+ setInvalidJsonDrafts({})
+ }
+ setLastNonEmptyLiveSchemaSignature(nextLastNonEmptyLiveSchemaSignature)
+ }
+ }
+
const currentArgs = useCallback(() => {
if (isPreview && previewValue) {
if (typeof previewValue === 'string') {
@@ -116,7 +216,9 @@ export function McpDynamicArgs({
)
const getInputType = (paramSchema: any) => {
- if (paramSchema.enum) return 'dropdown'
+ if (Array.isArray(paramSchema.enum)) {
+ return isPrimitiveEnum(paramSchema.enum) ? 'dropdown' : 'long-input'
+ }
if (paramSchema.type === 'boolean') return 'switch'
if (paramSchema.type === 'number' || paramSchema.type === 'integer') {
if (paramSchema.minimum !== undefined && paramSchema.maximum !== undefined) {
@@ -129,7 +231,7 @@ export function McpDynamicArgs({
if (paramSchema.maxLength && paramSchema.maxLength > 100) return 'long-input'
return 'short-input'
}
- if (paramSchema.type === 'array') return 'long-input'
+ if (paramSchema.type === 'array' || paramSchema.type === 'object') return 'long-input'
return 'short-input'
}
@@ -241,6 +343,17 @@ export function McpDynamicArgs({
case 'long-input': {
const config = createParamConfig(paramName, paramSchema, 'long-input')
+ const needsJsonValue = requiresJsonValue(paramSchema)
+ const valueSignature = JSON.stringify(value ?? null)
+ const draft = invalidJsonDrafts[paramName]
+ const activeDraft =
+ needsJsonValue && draft && draft.baseline === valueSignature ? draft.text : undefined
+ const displayValue =
+ activeDraft !== undefined
+ ? activeDraft
+ : typeof value === 'string' || value == null
+ ? value || ''
+ : JSON.stringify(value)
return (
updateParameter(paramName, newValue)}
+ value={displayValue}
+ onChange={(newValue) => {
+ if (!needsJsonValue) {
+ updateParameter(paramName, newValue)
+ return
+ }
+ const clearDraft = () =>
+ setInvalidJsonDrafts((prev) => {
+ if (!(paramName in prev)) return prev
+ const { [paramName]: _removed, ...rest } = prev
+ return rest
+ })
+ if (newValue === '') {
+ updateParameter(paramName, '')
+ clearDraft()
+ return
+ }
+ try {
+ updateParameter(paramName, JSON.parse(newValue))
+ clearDraft()
+ } catch {
+ if (paramSchema.type === 'array' && !looksLikeJsonLiteral(newValue)) {
+ updateParameter(paramName, newValue)
+ clearDraft()
+ return
+ }
+ setInvalidJsonDrafts((prev) => ({
+ ...prev,
+ [paramName]: { text: newValue, baseline: valueSignature },
+ }))
+ }
+ }}
isPreview={isPreview}
disabled={disabled}
workflowSearchValuePath={[paramName]}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
index 01ea37d8706..7ed1f9806f6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
@@ -58,7 +58,9 @@ const SLACK_OVERRIDES: SelectorOverrides = {
transformContext: (context, deps) => {
const authMethod = deps.authMethod as string
const oauthCredential =
- authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '')
+ authMethod === 'bot_token'
+ ? String(deps.customBotCredential ?? deps.botToken ?? '')
+ : String(deps.credential ?? deps.customBotCredential ?? deps.triggerCredentials ?? '')
return { ...context, oauthCredential }
},
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
index af1adb3a6aa..bd9558e6789 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
@@ -23,6 +23,7 @@ import {
evaluateSubBlockCondition,
hasAdvancedValues,
isCanonicalPair,
+ isStandaloneAdvancedMode,
resolveCanonicalMode,
shouldUseSubBlockForTriggerModeCanonicalIndex,
} from '@/lib/workflows/subblocks/visibility'
@@ -185,7 +186,7 @@ export function Editor() {
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
- if (subBlock.mode !== 'advanced') continue
+ if (!isStandaloneAdvancedMode(subBlock.mode)) continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
if (
@@ -220,7 +221,8 @@ export function Editor() {
for (const subBlock of subBlocks) {
const isStandaloneAdvanced =
- subBlock.mode === 'advanced' && !canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
+ isStandaloneAdvancedMode(subBlock.mode) &&
+ !canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
if (isStandaloneAdvanced) {
advancedOnly.push(subBlock)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
index ac6cd52bac0..13e4dc6ab16 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
@@ -1,5 +1,6 @@
'use client'
+import { useRef } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -34,6 +35,18 @@ interface ContextMenuProps {
onMarkAsUnread?: () => void
onTogglePin?: () => void
onRename?: () => void
+ /**
+ * Ref to the rename input rendered by the "Rename" action, if any. Radix's
+ * FocusScope defers its close-time focus teardown to a `setTimeout(0)`, which
+ * can run after the rename input's own mount-time `focus()`/`select()` and
+ * clobber the selection (the "rename deselects the text" bug). Focusing from
+ * `onCloseAutoFocus` runs synchronously inside that same deferred teardown, so
+ * it always wins the race regardless of scheduler timing. Only applied when
+ * this specific close was caused by selecting "Rename" (see
+ * `justSelectedRenameRef`) — an unrelated action closing the menu while an
+ * earlier rename is still live must not steal focus back into it.
+ */
+ renameInputRef?: React.RefObject
onCreate?: () => void
onCreateFolder?: () => void
onDuplicate?: () => void
@@ -84,6 +97,7 @@ export function ContextMenu({
onMarkAsUnread,
onTogglePin,
onRename,
+ renameInputRef,
onCreate,
onCreateFolder,
onDuplicate,
@@ -132,6 +146,13 @@ export function ContextMenu({
(showUploadLogo && onUploadLogo)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
+ /**
+ * Only the "Rename" item should trigger the `onCloseAutoFocus` refocus below —
+ * an unrelated action (Delete, Duplicate, ...) closing this menu while a rename
+ * from an earlier interaction is still live must not steal focus back into it.
+ */
+ const justSelectedRenameRef = useRef(false)
+
return (
!open && onClose()} modal={false}>
@@ -152,7 +173,16 @@ export function ContextMenu({
side='bottom'
sideOffset={4}
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
- onCloseAutoFocus={(e) => e.preventDefault()}
+ onCloseAutoFocus={(e) => {
+ e.preventDefault()
+ const shouldFocusRenameInput = justSelectedRenameRef.current
+ justSelectedRenameRef.current = false
+ const input = shouldFocusRenameInput ? renameInputRef?.current : null
+ if (input) {
+ input.focus()
+ input.select()
+ }
+ }}
>
{showOpenInNewTab && onOpenInNewTab && (
{
+ justSelectedRenameRef.current = true
onRename()
onClose()
}}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
index 377e562eadf..cc29b050118 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
@@ -575,6 +575,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
menuRef={menuRef}
onClose={closeMenu}
onRename={handleStartEdit}
+ renameInputRef={inputRef}
onCreate={handleCreateWorkflowInFolder}
onCreateFolder={handleCreateFolderInFolder}
onDuplicate={handleDuplicate}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
index d0c4b104b4e..fcc7d8a28b8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
@@ -487,6 +487,7 @@ export const WorkflowItem = memo(function WorkflowItem({
onClose={closeMenu}
onOpenInNewTab={handleOpenInNewTab}
onRename={handleStartEdit}
+ renameInputRef={inputRef}
onDuplicate={handleDuplicate}
onExport={handleExport}
onDelete={handleOpenDeleteModal}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index 711d96af8db..dcdfefbacb4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -113,6 +113,7 @@ function WorkspaceHeaderImpl({
const isContextMenuOpeningRef = useRef(false)
const contextMenuClosedRef = useRef(true)
const hasInputFocusedRef = useRef(false)
+ const renameInputRef = useRef(null)
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
@@ -408,6 +409,7 @@ function WorkspaceHeaderImpl({
)}
{
+ renameInputRef.current = el
if (el && !hasInputFocusedRef.current) {
hasInputFocusedRef.current = true
el.focus()
@@ -643,6 +645,7 @@ function WorkspaceHeaderImpl({
menuRef={contextMenuRef}
onClose={closeContextMenu}
onRename={handleRenameAction}
+ renameInputRef={renameInputRef}
onDelete={handleDeleteAction}
onLeave={handleLeaveAction}
onUploadLogo={handleUploadLogoAction}
diff --git a/apps/sim/background/tiktok-webhook-ingress.test.ts b/apps/sim/background/tiktok-webhook-ingress.test.ts
new file mode 100644
index 00000000000..7673053bb95
--- /dev/null
+++ b/apps/sim/background/tiktok-webhook-ingress.test.ts
@@ -0,0 +1,109 @@
+/**
+ * @vitest-environment node
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockDispatchResolvedWebhookTarget, mockFindTikTokWebhookTargets } = vi.hoisted(() => ({
+ mockDispatchResolvedWebhookTarget: vi.fn(),
+ mockFindTikTokWebhookTargets: vi.fn(),
+}))
+
+vi.mock('@trigger.dev/sdk', () => ({
+ task: vi.fn((config: unknown) => config),
+}))
+
+vi.mock('@/lib/webhooks/processor', () => ({
+ dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget,
+}))
+
+vi.mock('@/lib/webhooks/providers/tiktok-targets', () => ({
+ findTikTokWebhookTargets: mockFindTikTokWebhookTargets,
+}))
+
+import {
+ executeTikTokWebhookIngress,
+ type TikTokWebhookIngressPayload,
+} from '@/background/tiktok-webhook-ingress'
+
+const payload: TikTokWebhookIngressPayload = {
+ envelope: {
+ client_key: 'client-key',
+ event: 'post.publish.complete',
+ create_time: 1_725_000_000,
+ user_openid: 'act.user',
+ content: '{"publish_id":"publish-1"}',
+ },
+ headers: { 'content-type': 'application/json' },
+ requestId: 'request-1',
+ receivedAt: 1_725_000_000_000,
+}
+
+const targets = [
+ {
+ webhook: { id: 'webhook-1', path: 'tiktok', provider: 'tiktok' },
+ workflow: { id: 'workflow-1' },
+ },
+ {
+ webhook: { id: 'webhook-2', path: 'tiktok', provider: 'tiktok' },
+ workflow: { id: 'workflow-2' },
+ },
+]
+
+describe('executeTikTokWebhookIngress', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('acknowledges deliveries without active targets', async () => {
+ mockFindTikTokWebhookTargets.mockResolvedValue([])
+
+ await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({
+ ignored: 0,
+ processed: 0,
+ targetCount: 0,
+ })
+ expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
+ })
+
+ it('dispatches every resolved target and reports typed outcomes', async () => {
+ mockFindTikTokWebhookTargets.mockResolvedValue(targets)
+ mockDispatchResolvedWebhookTarget
+ .mockResolvedValueOnce({
+ outcome: 'queued',
+ reason: 'queued',
+ response: new Response(null, { status: 200 }),
+ })
+ .mockResolvedValueOnce({
+ outcome: 'ignored',
+ reason: 'event-mismatch',
+ response: new Response(null, { status: 200 }),
+ })
+
+ await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({
+ ignored: 1,
+ processed: 1,
+ targetCount: 2,
+ })
+ expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2)
+ })
+
+ it('throws after a target failure so the durable ingress job retries', async () => {
+ mockFindTikTokWebhookTargets.mockResolvedValue(targets)
+ mockDispatchResolvedWebhookTarget
+ .mockResolvedValueOnce({
+ outcome: 'queued',
+ reason: 'queued',
+ response: new Response(null, { status: 200 }),
+ })
+ .mockResolvedValueOnce({
+ outcome: 'failed',
+ reason: 'queue-failed',
+ response: new Response(null, { status: 500 }),
+ })
+
+ await expect(executeTikTokWebhookIngress(payload)).rejects.toThrow(
+ 'Failed to dispatch 1 of 2 TikTok webhook targets'
+ )
+ })
+})
diff --git a/apps/sim/background/tiktok-webhook-ingress.ts b/apps/sim/background/tiktok-webhook-ingress.ts
new file mode 100644
index 00000000000..d5b4bfb9480
--- /dev/null
+++ b/apps/sim/background/tiktok-webhook-ingress.ts
@@ -0,0 +1,105 @@
+import { createLogger } from '@sim/logger'
+import { task } from '@trigger.dev/sdk'
+import { NextRequest } from 'next/server'
+import type { TikTokWebhookEnvelope } from '@/lib/api/contracts/webhooks'
+import { dispatchResolvedWebhookTarget } from '@/lib/webhooks/processor'
+import { findTikTokWebhookTargets } from '@/lib/webhooks/providers/tiktok-targets'
+
+const logger = createLogger('TikTokWebhookIngressTask')
+
+export const TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT = 50
+export const TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS = 3
+
+export interface TikTokWebhookIngressPayload {
+ envelope: TikTokWebhookEnvelope
+ headers: {
+ 'content-type': string
+ }
+ requestId: string
+ receivedAt: number
+}
+
+export interface TikTokWebhookIngressResult {
+ ignored: number
+ processed: number
+ targetCount: number
+}
+
+/**
+ * Resolves and dispatches all active workflow targets for one verified TikTok delivery. Throwing
+ * after any retryable target failure lets Trigger.dev replay the fanout; workflow-level
+ * idempotency prevents already-queued targets from executing twice.
+ */
+export async function executeTikTokWebhookIngress(
+ payload: TikTokWebhookIngressPayload
+): Promise {
+ const targets = await findTikTokWebhookTargets(payload.envelope.user_openid, payload.requestId)
+ if (targets.length === 0) {
+ logger.info(`[${payload.requestId}] No TikTok webhook targets found`, {
+ event: payload.envelope.event,
+ userOpenIdPrefix: payload.envelope.user_openid.slice(0, 12),
+ })
+ return { ignored: 0, processed: 0, targetCount: 0 }
+ }
+
+ const request = new NextRequest('http://internal/api/webhooks/tiktok', {
+ method: 'POST',
+ headers: payload.headers,
+ body: JSON.stringify(payload.envelope),
+ })
+
+ let ignored = 0
+ let processed = 0
+ let failed = 0
+
+ for (const { webhook, workflow } of targets) {
+ const result = await dispatchResolvedWebhookTarget(
+ webhook,
+ workflow,
+ payload.envelope,
+ request,
+ {
+ requestId: payload.requestId,
+ path: webhook.path ?? undefined,
+ receivedAt: payload.receivedAt,
+ triggerTimestampMs: payload.envelope.create_time * 1000,
+ }
+ )
+
+ if (result.outcome === 'queued') {
+ processed += 1
+ } else if (result.outcome === 'ignored') {
+ ignored += 1
+ } else {
+ failed += 1
+ }
+ }
+
+ if (failed > 0) {
+ throw new Error(`Failed to dispatch ${failed} of ${targets.length} TikTok webhook targets`)
+ }
+
+ logger.info(`[${payload.requestId}] TikTok webhook fanout completed`, {
+ event: payload.envelope.event,
+ ignored,
+ processed,
+ targetCount: targets.length,
+ })
+
+ return { ignored, processed, targetCount: targets.length }
+}
+
+export const tiktokWebhookIngressTask = task({
+ id: 'tiktok-webhook-ingress',
+ machine: 'small-1x',
+ retry: {
+ maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS,
+ factor: 2,
+ minTimeoutInMs: 1000,
+ maxTimeoutInMs: 10_000,
+ },
+ queue: {
+ concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,
+ },
+ run: async (payload: TikTokWebhookIngressPayload) => executeTikTokWebhookIngress(payload),
+})
diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts
index ddf4e78acdb..c6230c41d23 100644
--- a/apps/sim/blocks/blocks.test.ts
+++ b/apps/sim/blocks/blocks.test.ts
@@ -867,7 +867,14 @@ describe.concurrent('Blocks Module', () => {
describe('Block Consistency', () => {
it('should have consistent registry keys matching block types', () => {
for (const block of getAllBlocks()) {
- expect(getBlock(block.type)).toBe(block)
+ const canonical = getBlock(block.type)
+ if (canonical?.preview) {
+ // Preview blocks surface in getAllBlocks() as projection clones
+ // (hidden or "(Preview)"-suffixed); getBlock stays the pure config.
+ expect(canonical.type).toBe(block.type)
+ continue
+ }
+ expect(canonical).toBe(block)
}
})
diff --git a/apps/sim/blocks/blocks/context_dev.ts b/apps/sim/blocks/blocks/context_dev.ts
index c96a0690d42..f06ec1bb53c 100644
--- a/apps/sim/blocks/blocks/context_dev.ts
+++ b/apps/sim/blocks/blocks/context_dev.ts
@@ -14,15 +14,7 @@ const URL_OPS = [
'extract_product',
]
/** Operations whose primary input is a bare domain. */
-const DOMAIN_OPS = [
- 'map',
- 'get_brand',
- 'get_brand_simplified',
- 'extract_products',
- 'scrape_fonts',
- 'scrape_styleguide',
- 'prefetch_domain',
-]
+const DOMAIN_OPS = ['map', 'get_brand', 'extract_products', 'scrape_fonts', 'scrape_styleguide']
/** Classification operations keyed on a domain-or-name input. */
const CLASSIFY_OPS = ['classify_naics', 'classify_sic']
/** Brand operations that accept language/speed tuning. */
@@ -49,7 +41,6 @@ const MAX_AGE_OPS = [
'get_brand_by_name',
'get_brand_by_email',
'get_brand_by_ticker',
- 'get_brand_simplified',
]
/** Operations that accept a post-load browser wait. */
const WAIT_FOR_OPS = ['scrape_markdown', 'scrape_html', 'scrape_images', 'screenshot', 'crawl']
@@ -118,10 +109,7 @@ export const ContextDevBlock: BlockConfig = {
{ label: 'Get Brand by Name', id: 'get_brand_by_name' },
{ label: 'Get Brand by Email', id: 'get_brand_by_email' },
{ label: 'Get Brand by Ticker', id: 'get_brand_by_ticker' },
- { label: 'Get Brand (Simplified)', id: 'get_brand_simplified' },
{ label: 'Identify Transaction', id: 'identify_transaction' },
- { label: 'Prefetch Domain', id: 'prefetch_domain' },
- { label: 'Prefetch by Email', id: 'prefetch_by_email' },
],
value: () => 'scrape_markdown',
},
@@ -170,8 +158,8 @@ export const ContextDevBlock: BlockConfig = {
title: 'Work Email',
type: 'short-input',
placeholder: 'name@company.com',
- condition: { field: 'operation', value: ['get_brand_by_email', 'prefetch_by_email'] },
- required: { field: 'operation', value: ['get_brand_by_email', 'prefetch_by_email'] },
+ condition: { field: 'operation', value: 'get_brand_by_email' },
+ required: { field: 'operation', value: 'get_brand_by_email' },
},
{
id: 'ticker',
@@ -540,6 +528,7 @@ Do not include any explanations, markdown formatting, or other text outside the
placeholder: 'Enter your Context.dev API key',
password: true,
required: true,
+ hideWhenHosted: true,
},
],
tools: {
@@ -562,10 +551,7 @@ Do not include any explanations, markdown formatting, or other text outside the
'context_dev_get_brand_by_name',
'context_dev_get_brand_by_email',
'context_dev_get_brand_by_ticker',
- 'context_dev_get_brand_simplified',
'context_dev_identify_transaction',
- 'context_dev_prefetch_domain',
- 'context_dev_prefetch_by_email',
],
config: {
tool: (params) =>
@@ -744,11 +730,6 @@ Do not include any explanations, markdown formatting, or other text outside the
setNumber('maxAgeMs')
setNumber('timeoutMS')
break
- case 'get_brand_simplified':
- setString('domain')
- setNumber('maxAgeMs')
- setNumber('timeoutMS')
- break
case 'identify_transaction':
setString('transactionInfo')
setString('countryGl')
@@ -760,14 +741,6 @@ Do not include any explanations, markdown formatting, or other text outside the
setBool('maxSpeed')
setNumber('timeoutMS')
break
- case 'prefetch_domain':
- setString('domain')
- setNumber('timeoutMS')
- break
- case 'prefetch_by_email':
- setString('email')
- setNumber('timeoutMS')
- break
}
return result
@@ -782,7 +755,7 @@ Do not include any explanations, markdown formatting, or other text outside the
input: { type: 'string', description: 'Domain or company name for classification' },
query: { type: 'string', description: 'Web search query' },
name: { type: 'string', description: 'Company name for brand lookup' },
- email: { type: 'string', description: 'Work email for brand lookup or prefetch' },
+ email: { type: 'string', description: 'Work email for brand lookup' },
ticker: { type: 'string', description: 'Stock ticker for brand lookup' },
transactionInfo: { type: 'string', description: 'Transaction descriptor to identify' },
schema: { type: 'json', description: 'JSON schema for structured extraction' },
@@ -849,7 +822,6 @@ Do not include any explanations, markdown formatting, or other text outside the
meta: { type: 'json', description: 'Sitemap discovery stats' },
query: { type: 'string', description: 'The query that was searched' },
status: { type: 'string', description: 'Operation status' },
- message: { type: 'string', description: 'Prefetch result message' },
urlsAnalyzed: { type: 'json', description: 'URLs analyzed during extraction' },
data: { type: 'json', description: 'Structured data extracted from the site' },
isProductPage: { type: 'boolean', description: 'Whether the URL is a product page' },
diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts
index ae3da995b79..0db701b5066 100644
--- a/apps/sim/blocks/blocks/slack.ts
+++ b/apps/sim/blocks/blocks/slack.ts
@@ -1,7 +1,7 @@
import { BookOpen, ClipboardList, File, Table, Users } from '@sim/emcn/icons'
import { GoogleTranslateIcon, GreptileIcon, LinearIcon, SlackIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
-import type { BlockConfig, BlockMeta } from '@/blocks/types'
+import type { BlockConfig, BlockMeta, SubBlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import type { SlackResponse } from '@/tools/slack/types'
@@ -21,6 +21,9 @@ export const SlackBlock: BlockConfig = {
bgColor: '#611f69',
icon: SlackIcon,
triggerAllowed: true,
+ // Superseded by slack_v2, but stays discoverable until v2 GAs — hiding both
+ // would leave no Slack block in the toolbar while v2 is preview-gated. At v2
+ // GA this becomes `hideFromToolbar: true` (superseded-version paradigm).
subBlocks: [
{
id: 'operation',
@@ -149,7 +152,7 @@ export const SlackBlock: BlockConfig = {
selectorKey: 'slack.channels',
placeholder: 'Select Slack channel',
mode: 'basic',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
condition: (values?: Record) => {
const op = values?.operation as string
if (op === 'ephemeral') {
@@ -192,7 +195,7 @@ export const SlackBlock: BlockConfig = {
type: 'short-input',
canonicalParamId: 'channel',
placeholder: 'Enter Slack channel ID (e.g., C1234567890)',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
mode: 'advanced',
condition: (values?: Record) => {
const op = values?.operation as string
@@ -239,7 +242,7 @@ export const SlackBlock: BlockConfig = {
selectorKey: 'slack.users',
placeholder: 'Select Slack user',
mode: 'basic',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
condition: {
field: 'destinationType',
value: 'dm',
@@ -252,7 +255,7 @@ export const SlackBlock: BlockConfig = {
type: 'short-input',
canonicalParamId: 'dmUserId',
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
mode: 'advanced',
condition: {
field: 'destinationType',
@@ -269,7 +272,7 @@ export const SlackBlock: BlockConfig = {
selectorKey: 'slack.users',
placeholder: 'Select Slack user',
mode: 'basic',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
condition: {
field: 'operation',
value: 'ephemeral',
@@ -282,7 +285,7 @@ export const SlackBlock: BlockConfig = {
type: 'short-input',
canonicalParamId: 'ephemeralUser',
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
mode: 'advanced',
condition: {
field: 'operation',
@@ -532,7 +535,7 @@ Do not include any explanations, markdown formatting, or other text outside the
selectorKey: 'slack.users',
placeholder: 'Select Slack user',
mode: 'basic',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
condition: {
field: 'operation',
value: 'get_user',
@@ -545,7 +548,7 @@ Do not include any explanations, markdown formatting, or other text outside the
type: 'short-input',
canonicalParamId: 'userId',
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
mode: 'advanced',
condition: {
field: 'operation',
@@ -904,7 +907,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
selectorKey: 'slack.users',
placeholder: 'Select Slack user',
mode: 'basic',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
condition: {
field: 'operation',
value: 'get_user_presence',
@@ -917,7 +920,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
type: 'short-input',
canonicalParamId: 'presenceUserId',
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
mode: 'advanced',
condition: {
field: 'operation',
@@ -1265,7 +1268,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
selectorKey: 'slack.users',
placeholder: 'Select user to publish Home tab to',
mode: 'basic',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
condition: {
field: 'operation',
value: 'publish_view',
@@ -1278,7 +1281,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
type: 'short-input',
canonicalParamId: 'publishUserId',
placeholder: 'Enter Slack user ID (e.g., U0BPQUNTA)',
- dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
+ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] },
mode: 'advanced',
condition: {
field: 'operation',
@@ -1593,6 +1596,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
oauthCredential,
authMethod,
botToken,
+ botCredential,
operation,
destinationType,
channel,
@@ -1693,9 +1697,15 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
baseParams.channel = effectiveChannel
}
- // Handle authentication based on method
+ // Handle authentication based on method. Custom Bot resolves to a token
+ // server-side: v2 selects a reusable bot credential; v1 pastes a raw
+ // token (kept for back-compat).
if (authMethod === 'bot_token') {
- baseParams.accessToken = botToken
+ if (botCredential) {
+ baseParams.credential = botCredential
+ } else if (botToken) {
+ baseParams.accessToken = botToken
+ }
} else {
// Default to OAuth
baseParams.credential = oauthCredential
@@ -2067,6 +2077,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
destinationType: { type: 'string', description: 'Destination type (channel or dm)' },
oauthCredential: { type: 'string', description: 'Slack access token' },
botToken: { type: 'string', description: 'Bot token' },
+ botCredential: { type: 'string', description: 'Custom Slack bot credential id' },
channel: { type: 'string', description: 'Channel identifier (canonical param)' },
dmUserId: { type: 'string', description: 'User ID for DM recipient (canonical param)' },
text: { type: 'string', description: 'Message text' },
@@ -2454,7 +2465,9 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
team_id: { type: 'string', description: 'Slack workspace/team ID' },
event_id: { type: 'string', description: 'Unique event identifier for the trigger' },
},
- // New: Trigger capabilities
+ // Trigger capabilities moved to slack_v2 so the trigger surfaces once.
+ // Legacy webhook trigger stays available while slack_v2 (which hosts the
+ // redesigned slack_oauth trigger) is preview-gated; drops at v2 GA.
triggers: {
enabled: true,
available: ['slack_webhook'],
@@ -2613,3 +2626,72 @@ export const SlackBlockMeta = {
},
],
} as const satisfies BlockMeta
+
+/**
+ * Custom Bot picker used by slack_v2 in place of v1's raw bot-token field — a
+ * canonical basic/advanced pair (dropdown + manual credential-ID paste),
+ * mirroring the OAuth `credential`/`manualCredential` pair.
+ */
+const SLACK_CUSTOM_BOT_SUBBLOCKS: SubBlockConfig[] = [
+ {
+ id: 'customBotCredential',
+ title: 'Slack Bot',
+ type: 'oauth-input',
+ canonicalParamId: 'botCredential',
+ mode: 'basic',
+ serviceId: 'slack',
+ credentialKind: 'custom-bot',
+ requiredScopes: getScopesForService('slack'),
+ placeholder: 'Select a connected bot',
+ dependsOn: ['authMethod'],
+ condition: { field: 'authMethod', value: 'bot_token' },
+ required: true,
+ },
+ {
+ id: 'manualCustomBotCredential',
+ title: 'Bot Credential ID',
+ type: 'short-input',
+ canonicalParamId: 'botCredential',
+ mode: 'advanced',
+ placeholder: 'Enter bot credential ID',
+ dependsOn: ['authMethod'],
+ condition: { field: 'authMethod', value: 'bot_token' },
+ required: true,
+ },
+]
+
+const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set(
+ getTrigger('slack_webhook').subBlocks.map((sb) => sb.id)
+)
+
+/**
+ * slack_v2 — the go-forward Slack action block. Identical operations, tools, and
+ * outputs to v1 (shared by reference), but the "Custom Bot" auth method selects
+ * a reusable bot credential set up once, instead of pasting a raw token. Also
+ * hosts the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook).
+ */
+export const SlackV2Block: BlockConfig = {
+ ...SlackBlock,
+ type: 'slack_v2',
+ hideFromToolbar: false,
+ // Preview-gated: hidden from every discovery surface until revealed via the
+ // block-visibility AppConfig (hosted) or PREVIEW_BLOCKS=slack_v2 (dev /
+ // self-host). At GA: drop this flag, add SlackV2BlockMeta + docs, and set
+ // hideFromToolbar on v1.
+ preview: true,
+ subBlocks: [
+ ...SlackBlock.subBlocks.flatMap((sb) => {
+ // Drop the legacy paste-secret trigger config (v1 hosts slack_webhook)
+ // and v1's raw bot-token auth field — the trigger set includes an
+ // id-colliding 'botToken', so the set check covers both.
+ if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return []
+ if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS]
+ return [sb]
+ }),
+ ...getTrigger('slack_oauth').subBlocks,
+ ],
+ triggers: {
+ enabled: true,
+ available: ['slack_oauth'],
+ },
+}
diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts
new file mode 100644
index 00000000000..dcd2dabcb69
--- /dev/null
+++ b/apps/sim/blocks/blocks/tiktok.ts
@@ -0,0 +1,637 @@
+import { TikTokIcon } from '@/components/icons'
+import { getScopesForService } from '@/lib/oauth/utils'
+import type { BlockConfig, BlockMeta } from '@/blocks/types'
+import { AuthMode, IntegrationType } from '@/blocks/types'
+import { normalizeFileInput } from '@/blocks/utils'
+import type { TikTokResponse } from '@/tools/tiktok/types'
+import { getTrigger } from '@/triggers'
+
+const VIDEO_POST_OPERATIONS = ['tiktok_direct_post_video', 'tiktok_upload_video_draft']
+
+export const TikTokBlock: BlockConfig = {
+ type: 'tiktok',
+ name: 'TikTok',
+ description: 'Access TikTok profiles and videos, and publish content',
+ authMode: AuthMode.OAuth,
+ longDescription:
+ 'Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos directly to TikTok (or send them to the inbox as drafts) from an uploaded file or a file produced earlier in the workflow, then track post status.',
+ docsLink: 'https://docs.sim.ai/integrations/tiktok',
+ category: 'tools',
+ integrationType: IntegrationType.Communication,
+ bgColor: '#000000',
+ icon: TikTokIcon,
+ triggerAllowed: true,
+ hideFromToolbar: true,
+
+ subBlocks: [
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [
+ { label: 'Get User Info', id: 'tiktok_get_user' },
+ { label: 'List Videos', id: 'tiktok_list_videos' },
+ { label: 'Query Videos', id: 'tiktok_query_videos' },
+ { label: 'Query Creator Info', id: 'tiktok_query_creator_info' },
+ { label: 'Direct Post Video', id: 'tiktok_direct_post_video' },
+ { label: 'Upload Video Draft', id: 'tiktok_upload_video_draft' },
+ { label: 'Get Post Status', id: 'tiktok_get_post_status' },
+ ],
+ value: () => 'tiktok_get_user',
+ },
+
+ // --- OAuth Credential ---
+ {
+ id: 'credential',
+ title: 'TikTok Account',
+ type: 'oauth-input',
+ serviceId: 'tiktok',
+ canonicalParamId: 'oauthCredential',
+ mode: 'basic',
+ requiredScopes: getScopesForService('tiktok'),
+ placeholder: 'Select TikTok account',
+ required: true,
+ },
+ {
+ id: 'manualCredential',
+ title: 'TikTok Account',
+ type: 'short-input',
+ canonicalParamId: 'oauthCredential',
+ mode: 'advanced',
+ placeholder: 'Enter credential ID',
+ required: true,
+ },
+
+ // --- Get User Info ---
+ {
+ id: 'fields',
+ title: 'Fields',
+ type: 'short-input',
+ placeholder: 'open_id,display_name,avatar_url,follower_count,video_count',
+ description: 'Comma-separated list of user fields to return. Leave empty for all fields.',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of TikTok user info fields based on the user request, choosing only from: open_id, union_id, avatar_url, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count. Return ONLY the comma-separated field names - no explanations, no extra text.',
+ placeholder: 'Describe which profile fields you need',
+ },
+ },
+
+ // --- List Videos ---
+ {
+ id: 'maxCount',
+ title: 'Max Count',
+ type: 'short-input',
+ placeholder: '20',
+ description: 'Maximum number of videos to return (1-20).',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'tiktok_list_videos' },
+ },
+ {
+ id: 'cursor',
+ title: 'Cursor',
+ type: 'short-input',
+ placeholder: 'Pagination cursor from previous response',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'tiktok_list_videos' },
+ },
+
+ // --- Query Videos ---
+ {
+ id: 'videoIds',
+ title: 'Video IDs',
+ type: 'long-input',
+ placeholder: 'One video ID per line or comma-separated (e.g., 7077642457847994444)',
+ condition: { field: 'operation', value: 'tiktok_query_videos' },
+ required: { field: 'operation', value: 'tiktok_query_videos' },
+ },
+
+ // --- Video file (Direct Post Video / Upload Video Draft) — Gmail-style upload ⇄ block ref ---
+ {
+ id: 'videoFile',
+ title: 'Video File',
+ type: 'file-upload',
+ canonicalParamId: 'file',
+ mode: 'basic',
+ placeholder: 'Upload video',
+ acceptedTypes: '.mp4,.mov,.webm',
+ multiple: false,
+ maxSize: 250,
+ description: 'MP4, MOV, or WebM video up to 250 MB.',
+ condition: { field: 'operation', value: VIDEO_POST_OPERATIONS },
+ required: { field: 'operation', value: VIDEO_POST_OPERATIONS },
+ },
+ {
+ id: 'videoFileRef',
+ title: 'Video File',
+ type: 'short-input',
+ canonicalParamId: 'file',
+ mode: 'advanced',
+ placeholder: 'Reference a video from a previous block',
+ condition: { field: 'operation', value: VIDEO_POST_OPERATIONS },
+ required: { field: 'operation', value: VIDEO_POST_OPERATIONS },
+ },
+
+ // --- Caption (Direct Post Video) ---
+ {
+ id: 'title',
+ title: 'Title / Caption',
+ type: 'long-input',
+ placeholder: 'Caption with #hashtags and @mentions',
+ description: 'Video caption. Maximum 2200 characters.',
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+
+ // --- Privacy & interaction settings (Direct Post Video) ---
+ {
+ id: 'privacyLevel',
+ title: 'Privacy Level',
+ type: 'dropdown',
+ options: [
+ { label: 'Public', id: 'PUBLIC_TO_EVERYONE' },
+ { label: 'Friends', id: 'MUTUAL_FOLLOW_FRIENDS' },
+ { label: 'Followers', id: 'FOLLOWER_OF_CREATOR' },
+ { label: 'Only Me', id: 'SELF_ONLY' },
+ ],
+ description:
+ 'Choose manually from the privacyLevelOptions returned by Query Creator Info. TikTok prohibits preselecting a privacy level. Unaudited apps are restricted to Only Me.',
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ required: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'disableComment',
+ title: 'Disable Comments',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ required: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'disableDuet',
+ title: 'Disable Duet',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ required: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'disableStitch',
+ title: 'Disable Stitch',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ required: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'videoCoverTimestampMs',
+ title: 'Cover Timestamp (ms)',
+ type: 'short-input',
+ placeholder: '1000',
+ description: 'Timestamp in milliseconds to use as the video cover image.',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'isAigc',
+ title: 'AI-Generated Content',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ value: () => 'false',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'brandContentToggle',
+ title: 'Paid Partnership',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ value: () => 'false',
+ description:
+ 'Disclose this post as a paid partnership promoting a third-party business. Branded content cannot be posted with Only Me privacy.',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'brandOrganicToggle',
+ title: 'Promotes Own Business',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ value: () => 'false',
+ description: "Disclose this post as promoting the creator's own business.",
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+ {
+ id: 'musicUsageConsent',
+ title: 'TikTok Music Usage Confirmation',
+ type: 'dropdown',
+ options: [
+ {
+ label: "I agree to TikTok's Music Usage Confirmation",
+ id: 'accepted',
+ },
+ ],
+ description:
+ "By posting, you agree to TikTok's Music Usage Confirmation. TikTok requires explicit consent before content is uploaded.",
+ condition: { field: 'operation', value: 'tiktok_direct_post_video' },
+ required: { field: 'operation', value: 'tiktok_direct_post_video' },
+ },
+
+ // --- Get Post Status ---
+ {
+ id: 'publishId',
+ title: 'Publish ID',
+ type: 'short-input',
+ placeholder: 'v_pub_file~v2-1.123456789',
+ condition: { field: 'operation', value: 'tiktok_get_post_status' },
+ required: { field: 'operation', value: 'tiktok_get_post_status' },
+ },
+
+ ...getTrigger('tiktok_post_publish_complete').subBlocks,
+ ...getTrigger('tiktok_post_publish_failed').subBlocks,
+ ...getTrigger('tiktok_post_inbox_delivered').subBlocks,
+ ...getTrigger('tiktok_post_publicly_available').subBlocks,
+ ...getTrigger('tiktok_post_no_longer_public').subBlocks,
+ ...getTrigger('tiktok_video_publish_completed').subBlocks,
+ ...getTrigger('tiktok_video_upload_failed').subBlocks,
+ ...getTrigger('tiktok_authorization_removed').subBlocks,
+ ],
+
+ triggers: {
+ enabled: true,
+ available: [
+ 'tiktok_post_publish_complete',
+ 'tiktok_post_publish_failed',
+ 'tiktok_post_inbox_delivered',
+ 'tiktok_post_publicly_available',
+ 'tiktok_post_no_longer_public',
+ 'tiktok_video_publish_completed',
+ 'tiktok_video_upload_failed',
+ 'tiktok_authorization_removed',
+ ],
+ },
+
+ tools: {
+ access: [
+ 'tiktok_get_user',
+ 'tiktok_list_videos',
+ 'tiktok_query_videos',
+ 'tiktok_query_creator_info',
+ 'tiktok_direct_post_video',
+ 'tiktok_upload_video_draft',
+ 'tiktok_get_post_status',
+ ],
+ config: {
+ tool: (params) => params.operation || 'tiktok_get_user',
+ params: (params) => {
+ const operation = params.operation || 'tiktok_get_user'
+ const toBoolean = (value: unknown): boolean | undefined =>
+ value === undefined || value === '' ? undefined : String(value).toLowerCase() === 'true'
+
+ switch (operation) {
+ case 'tiktok_get_user':
+ return {
+ ...(params.fields && { fields: params.fields }),
+ }
+ case 'tiktok_list_videos':
+ return {
+ ...(params.maxCount && { maxCount: Number(params.maxCount) }),
+ ...(params.cursor !== undefined &&
+ params.cursor !== '' && { cursor: Number(params.cursor) }),
+ }
+ case 'tiktok_query_videos':
+ return {
+ videoIds: (params.videoIds || '')
+ .split(/[,\n]+/)
+ .map((id: string) => id.trim())
+ .filter(Boolean),
+ }
+ case 'tiktok_query_creator_info':
+ return {}
+ case 'tiktok_direct_post_video': {
+ const file = normalizeFileInput(params.file, { single: true })
+ return {
+ file,
+ title: params.title,
+ privacyLevel: params.privacyLevel,
+ disableDuet: toBoolean(params.disableDuet),
+ disableStitch: toBoolean(params.disableStitch),
+ disableComment: toBoolean(params.disableComment),
+ ...(params.videoCoverTimestampMs !== undefined &&
+ params.videoCoverTimestampMs !== '' && {
+ videoCoverTimestampMs: Number(params.videoCoverTimestampMs),
+ }),
+ isAigc: toBoolean(params.isAigc),
+ brandContentToggle: toBoolean(params.brandContentToggle),
+ brandOrganicToggle: toBoolean(params.brandOrganicToggle),
+ musicUsageConsent: params.musicUsageConsent,
+ }
+ }
+ case 'tiktok_upload_video_draft': {
+ const file = normalizeFileInput(params.file, { single: true })
+ return {
+ file,
+ }
+ }
+ case 'tiktok_get_post_status':
+ return {
+ publishId: params.publishId,
+ }
+ default:
+ return {}
+ }
+ },
+ },
+ },
+
+ inputs: {
+ operation: { type: 'string', description: 'Operation to perform' },
+ oauthCredential: { type: 'string', description: 'TikTok account credential' },
+ fields: { type: 'string', description: 'Comma-separated list of user fields to return' },
+ maxCount: { type: 'number', description: 'Maximum number of videos to return (1-20)' },
+ cursor: { type: 'number', description: 'Pagination cursor from previous response' },
+ videoIds: {
+ type: 'string',
+ description: 'List of video IDs to query, one per line or comma-separated',
+ },
+ file: {
+ type: 'json',
+ description: 'Video file to upload (uploaded file or reference from a previous block)',
+ },
+ title: { type: 'string', description: 'Video caption or title' },
+ privacyLevel: { type: 'string', description: 'Privacy level for the post' },
+ disableComment: { type: 'string', description: 'Whether to disable comments' },
+ disableDuet: { type: 'string', description: 'Whether to disable duet' },
+ disableStitch: { type: 'string', description: 'Whether to disable stitch' },
+ videoCoverTimestampMs: { type: 'number', description: 'Video cover timestamp in ms' },
+ isAigc: { type: 'string', description: 'Whether the video is AI-generated content' },
+ brandContentToggle: {
+ type: 'string',
+ description: 'Whether the post is a paid partnership promoting a third-party business',
+ },
+ brandOrganicToggle: {
+ type: 'string',
+ description: "Whether the post promotes the creator's own business",
+ },
+ musicUsageConsent: {
+ type: 'string',
+ description: "Explicit acceptance of TikTok's Music Usage Confirmation",
+ },
+ publishId: { type: 'string', description: 'Publish ID to check status for' },
+ },
+
+ outputs: {
+ // Get User Info
+ openId: {
+ type: 'string',
+ description: 'Unique TikTok user ID for this application',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ unionId: {
+ type: 'string',
+ description: 'Unique TikTok user ID across all apps from the developer',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ displayName: {
+ type: 'string',
+ description: 'User display name',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ bioDescription: {
+ type: 'string',
+ description: 'User bio description',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ profileDeepLink: {
+ type: 'string',
+ description: 'Deep link to the user TikTok profile',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ isVerified: {
+ type: 'boolean',
+ description: 'Whether the account is verified',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ username: {
+ type: 'string',
+ description: 'TikTok username',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ followerCount: {
+ type: 'number',
+ description: 'Number of followers',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ followingCount: {
+ type: 'number',
+ description: 'Number of accounts followed',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ likesCount: {
+ type: 'number',
+ description: 'Total likes received across all videos',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ videoCount: {
+ type: 'number',
+ description: 'Total number of public videos',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+ avatarFile: {
+ type: 'file',
+ description:
+ 'Downloadable copy of the profile avatar image, stored as a workflow file so it can be chained into file-consuming blocks (e.g. attached to an email).',
+ condition: { field: 'operation', value: 'tiktok_get_user' },
+ },
+
+ // List/Query Videos
+ videos: {
+ type: 'json',
+ description:
+ 'Array of video objects (id, title, coverImageUrl, embedLink, embedHtml, duration, createTime, shareUrl, videoDescription, width, height, viewCount, likeCount, commentCount, shareCount)',
+ condition: { field: 'operation', value: ['tiktok_list_videos', 'tiktok_query_videos'] },
+ },
+ cursor: {
+ type: 'number',
+ description: 'Pagination cursor for fetching the next page',
+ condition: { field: 'operation', value: 'tiktok_list_videos' },
+ },
+ hasMore: {
+ type: 'boolean',
+ description: 'Whether more videos are available',
+ condition: { field: 'operation', value: 'tiktok_list_videos' },
+ },
+
+ // Query Creator Info
+ creatorAvatarUrl: {
+ type: 'string',
+ description: 'URL of the creator avatar',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+ creatorUsername: {
+ type: 'string',
+ description: 'TikTok username of the creator',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+ creatorNickname: {
+ type: 'string',
+ description: 'Display name/nickname of the creator',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+ privacyLevelOptions: {
+ type: 'json',
+ description: 'Available privacy levels for posting (array of strings)',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+ commentDisabled: {
+ type: 'boolean',
+ description: 'Whether the creator disabled comments by default',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+ duetDisabled: {
+ type: 'boolean',
+ description: 'Whether the creator disabled duets by default',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+ stitchDisabled: {
+ type: 'boolean',
+ description: 'Whether the creator disabled stitches by default',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+ maxVideoPostDurationSec: {
+ type: 'number',
+ description: 'Maximum allowed video duration in seconds',
+ condition: { field: 'operation', value: 'tiktok_query_creator_info' },
+ },
+
+ // Direct Post / Upload Draft
+ publishId: {
+ type: 'string',
+ description: 'Publish ID for tracking post status',
+ condition: {
+ field: 'operation',
+ value: VIDEO_POST_OPERATIONS,
+ },
+ },
+
+ // Get Post Status
+ status: {
+ type: 'string',
+ description:
+ 'Post status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD, SEND_TO_USER_INBOX, PUBLISH_COMPLETE, or FAILED',
+ condition: { field: 'operation', value: 'tiktok_get_post_status' },
+ },
+ failReason: {
+ type: 'string',
+ description: 'Reason for failure if status is FAILED',
+ condition: { field: 'operation', value: 'tiktok_get_post_status' },
+ },
+ publiclyAvailablePostId: {
+ type: 'json',
+ description: 'Array of public post IDs once the content is published',
+ condition: { field: 'operation', value: 'tiktok_get_post_status' },
+ },
+ uploadedBytes: {
+ type: 'number',
+ description: 'Number of video bytes TikTok has received for a file upload',
+ condition: { field: 'operation', value: 'tiktok_get_post_status' },
+ },
+ downloadedBytes: {
+ type: 'number',
+ description: 'Number of video bytes TikTok reports as downloaded',
+ condition: { field: 'operation', value: 'tiktok_get_post_status' },
+ },
+ },
+}
+
+export const TikTokBlockMeta = {
+ tags: ['marketing', 'content-management'],
+ url: 'https://www.tiktok.com',
+ templates: [
+ {
+ icon: TikTokIcon,
+ title: 'User-reviewed TikTok publisher',
+ prompt:
+ 'Build a workflow that prepares a generated video and editable AI-written caption, asks the user to review the video, choose TikTok privacy and interaction settings, and explicitly accept the Music Usage Confirmation before publishing, then checks post status until it completes.',
+ modules: ['agent', 'workflows'],
+ category: 'marketing',
+ tags: ['marketing', 'automation'],
+ },
+ {
+ icon: TikTokIcon,
+ title: 'TikTok content calendar drafts',
+ prompt:
+ 'Create a scheduled workflow that reads the next due row from a Tables-based content calendar, uploads the matching video to the creator’s TikTok inbox as a draft, and notifies them to review and publish it in TikTok.',
+ modules: ['scheduled', 'tables', 'agent', 'workflows'],
+ category: 'marketing',
+ tags: ['marketing', 'automation'],
+ },
+ {
+ icon: TikTokIcon,
+ title: 'TikTok performance reporter',
+ prompt:
+ 'Build a workflow that lists recent TikTok videos, summarizes view and engagement trends with an agent, and posts the digest to Slack every week.',
+ modules: ['scheduled', 'agent', 'workflows'],
+ category: 'marketing',
+ tags: ['marketing', 'reporting'],
+ alsoIntegrations: ['slack'],
+ },
+ {
+ icon: TikTokIcon,
+ title: 'TikTok draft review pipeline',
+ prompt:
+ 'Create a workflow that uploads a new video to a TikTok inbox draft for review, then notifies the marketing team on Slack to approve and post it from the TikTok app.',
+ modules: ['agent', 'workflows'],
+ category: 'operations',
+ tags: ['marketing', 'automation'],
+ alsoIntegrations: ['slack'],
+ },
+ ],
+ skills: [
+ {
+ name: 'publish-video-to-tiktok',
+ description:
+ 'Guide a user-reviewed direct post to TikTok from an uploaded file or a previous block.',
+ content:
+ '# Publish a Video to TikTok\n\nGuide a user-reviewed post to a connected TikTok account.\n\n## Steps\n1. Run Query Creator Info immediately before posting to confirm the account, posting permissions, allowed privacy levels, interaction restrictions, and maximum duration.\n2. Show the user the video and an editable Title/Caption; never publish an unattended or unreviewed file.\n3. Have the user manually choose Privacy Level and each interaction setting from the currently allowed options.\n4. Require the user to accept the TikTok Music Usage Confirmation, then use Direct Post Video with the reviewed settings.\n5. Use Get Post Status with the returned Publish ID until the post completes or fails.\n\n## Output\nReturn the Publish ID and final status (PUBLISH_COMPLETE or FAILED with a reason).',
+ },
+ {
+ name: 'send-video-draft-to-inbox',
+ description: "Send a video to the user's TikTok inbox for manual review before posting.",
+ content:
+ "# Send a TikTok Video Draft\n\nDeliver a video to the connected account's TikTok inbox so a human can review, edit, and publish it from the app.\n\n## Steps\n1. Use the Upload Video Draft operation with a connected TikTok Account.\n2. Provide a Video File: upload one, or reference a file from a previous block.\n3. Submit the draft — no caption or privacy level is set here, since the user finishes the post manually in the TikTok app.\n4. Use Get Post Status with the returned Publish ID to see when the user has acted on the inbox notification (SEND_TO_USER_INBOX until they do).\n\n## Output\nReturn the Publish ID so the draft's status can be tracked or referenced later.",
+ },
+ {
+ name: 'check-tiktok-post-status',
+ description: 'Poll the status of a TikTok post or draft until it completes or fails.',
+ content:
+ '# Check TikTok Post Status\n\nTrack the outcome of a post or draft submitted with any TikTok publish operation.\n\n## Steps\n1. Capture the Publish ID returned by Direct Post Video or Upload Video Draft.\n2. Call Get Post Status with that Publish ID.\n3. Branch on the returned status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD means still in progress, SEND_TO_USER_INBOX means a draft is waiting on the user, PUBLISH_COMPLETE means it succeeded, and FAILED means it did not (read failReason for why).\n4. Repeat on a delay for in-progress statuses until a terminal state is reached.\n\n## Output\nReturn the final status, failReason (if any), and the publiclyAvailablePostId once published.',
+ },
+ {
+ name: 'summarize-tiktok-video-performance',
+ description: "List a creator's recent TikTok videos and summarize engagement for reporting.",
+ content:
+ "# Summarize TikTok Video Performance\n\nPull a creator's recent videos and turn the metadata into a readable report.\n\n## Steps\n1. Use List Videos with a connected TikTok Account to fetch recent videos (paginate with the Cursor if more than one page is needed).\n2. For specific videos already known by ID, use Query Videos instead to refresh their metadata.\n3. Ask an agent to summarize the results — highlight top performers by duration/engagement signals available in the metadata and note any patterns.\n4. Optionally use Get User Info alongside this to report overall follower and like counts.\n\n## Output\nA structured summary or table of videos with their titles, share URLs, and key metadata, suitable for posting to a report or chat.",
+ },
+ ],
+} as const satisfies BlockMeta
diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts
index d13139aaba7..62cc473e7c9 100644
--- a/apps/sim/blocks/custom/build-config.ts
+++ b/apps/sim/blocks/custom/build-config.ts
@@ -33,6 +33,7 @@ export interface CustomBlockInput {
type: string
placeholder?: string
description?: string
+ required?: boolean
}
/**
@@ -108,6 +109,9 @@ export function buildCustomBlockConfig(
type,
description: field.description,
placeholder: field.placeholder,
+ // Serializer Loop-B (required subBlocks not covered by tool params) and the
+ // editor asterisk both read this — same enforcement path as regular blocks.
+ required: field.required === true,
}
if (field.type === 'object' || field.type === 'array') sub.language = 'json'
if (field.type === 'file[]') sub.multiple = true
diff --git a/apps/sim/blocks/registry-maps.minimal.ts b/apps/sim/blocks/registry-maps.minimal.ts
index 585da915b1c..9c43a7b948b 100644
--- a/apps/sim/blocks/registry-maps.minimal.ts
+++ b/apps/sim/blocks/registry-maps.minimal.ts
@@ -23,6 +23,7 @@ import { RssBlock } from '@/blocks/blocks/rss'
import { ScheduleBlock } from '@/blocks/blocks/schedule'
import { SearchBlock } from '@/blocks/blocks/search'
import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event'
+import { SlackBlock, SlackV2Block } from '@/blocks/blocks/slack'
import { StartTriggerBlock } from '@/blocks/blocks/start_trigger'
import { TableBlock } from '@/blocks/blocks/table'
import { TranslateBlock } from '@/blocks/blocks/translate'
@@ -72,6 +73,8 @@ export const BLOCK_REGISTRY: Record = {
schedule: ScheduleBlock,
search: SearchBlock,
sim_workspace_event: SimWorkspaceEventBlock,
+ slack: SlackBlock,
+ slack_v2: SlackV2Block,
start_trigger: StartTriggerBlock,
table: TableBlock,
translate: TranslateBlock,
diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts
index 89919671fc9..7934e50a8f1 100644
--- a/apps/sim/blocks/registry-maps.ts
+++ b/apps/sim/blocks/registry-maps.ts
@@ -272,7 +272,7 @@ import { ShopifyBlock, ShopifyBlockMeta } from '@/blocks/blocks/shopify'
import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event'
import { SimilarwebBlock, SimilarwebBlockMeta } from '@/blocks/blocks/similarweb'
import { SixtyfourBlock, SixtyfourBlockMeta } from '@/blocks/blocks/sixtyfour'
-import { SlackBlock, SlackBlockMeta } from '@/blocks/blocks/slack'
+import { SlackBlock, SlackBlockMeta, SlackV2Block } from '@/blocks/blocks/slack'
import { SmtpBlock, SmtpBlockMeta } from '@/blocks/blocks/smtp'
import { SportmonksBlock, SportmonksBlockMeta } from '@/blocks/blocks/sportmonks'
import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify'
@@ -294,6 +294,7 @@ import { TemporalBlock, TemporalBlockMeta } from '@/blocks/blocks/temporal'
import { TextractBlock, TextractBlockMeta, TextractV2Block } from '@/blocks/blocks/textract'
import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { ThriveBlock, ThriveBlockMeta } from '@/blocks/blocks/thrive'
+import { TikTokBlock, TikTokBlockMeta } from '@/blocks/blocks/tiktok'
import { TinybirdBlock, TinybirdBlockMeta } from '@/blocks/blocks/tinybird'
import { TranslateBlock } from '@/blocks/blocks/translate'
import { TrelloBlock, TrelloBlockMeta } from '@/blocks/blocks/trello'
@@ -581,6 +582,7 @@ export const BLOCK_REGISTRY: Record = {
similarweb: SimilarwebBlock,
sixtyfour: SixtyfourBlock,
slack: SlackBlock,
+ slack_v2: SlackV2Block,
smtp: SmtpBlock,
sportmonks: SportmonksBlock,
spotify: SpotifyBlock,
@@ -604,6 +606,7 @@ export const BLOCK_REGISTRY: Record = {
textract_v2: TextractV2Block,
thinking: ThinkingBlock,
thrive: ThriveBlock,
+ tiktok: TikTokBlock,
tinybird: TinybirdBlock,
translate: TranslateBlock,
trello: TrelloBlock,
@@ -871,6 +874,7 @@ export const BLOCK_META_REGISTRY: Record = {
temporal: TemporalBlockMeta,
textract: TextractBlockMeta,
thrive: ThriveBlockMeta,
+ tiktok: TikTokBlockMeta,
tinybird: TinybirdBlockMeta,
trello: TrelloBlockMeta,
trigger_dev: TriggerDevBlockMeta,
diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts
index 6d9f63188ab..d063f43d38a 100644
--- a/apps/sim/blocks/types.ts
+++ b/apps/sim/blocks/types.ts
@@ -258,7 +258,7 @@ export interface SubBlockConfig {
id: string
title?: string
type: SubBlockType
- mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is for advanced canonical pair members shown in trigger mode
+ mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is the advanced side of a trigger field — either a canonical pair member or a standalone field shown under the block-level advanced toggle
canonicalParamId?: string
/** Controls parameter visibility in agent/tool-input context */
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
@@ -366,6 +366,12 @@ export interface SubBlockConfig {
// OAuth specific properties - serviceId is the canonical identifier for OAuth services
serviceId?: string
requiredScopes?: string[]
+ /**
+ * Narrows an `oauth-input` selector to a specific credential kind. `'custom-bot'`
+ * lists only reusable custom Slack bot credentials (service-account type) and its
+ * connect row opens the custom-bot setup modal instead of the OAuth flow.
+ */
+ credentialKind?: 'custom-bot'
// Selector properties — declarative mapping to a SelectorKey
selectorKey?: SelectorKey
selectorAllowSearch?: boolean
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx
index fe869a5b77e..f1fb7446eeb 100644
--- a/apps/sim/components/icons.tsx
+++ b/apps/sim/components/icons.tsx
@@ -1274,6 +1274,31 @@ export function xAIIcon(props: SVGProps) {
)
}
+export function TikTokIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+ )
+}
+
export function xIcon(props: SVGProps) {
return (
@@ -3687,6 +3712,38 @@ export const SakanaIcon = (props: SVGProps) => (
)
+export const NvidiaIcon = (props: SVGProps) => (
+
+ NVIDIA
+
+
+)
+
+export const ZaiIcon = (props: SVGProps) => (
+
+ Z.ai
+
+
+
+
+
+)
+
export function MetaIcon(props: SVGProps) {
const id = useId()
const gradient1Id = `meta_gradient_1_${id}`
diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
index d9d48a99eed..7eb676229d2 100644
--- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
@@ -7,6 +7,7 @@ import {
ChipCombobox,
ChipConfirmModal,
ChipInput,
+ ChipModalField,
ChipTextarea,
type ComboboxOptionGroup,
cn,
@@ -14,6 +15,7 @@ import {
ExpandableContent,
Label,
Loader,
+ Switch,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
@@ -34,6 +36,7 @@ import type { CustomBlockInput, CustomBlockOutput } from '@/blocks/custom/build-
import { SettingRow } from '@/ee/components/setting-row'
import {
useCustomBlocks,
+ useCustomBlockUsageCounts,
useDeleteCustomBlock,
usePublishCustomBlock,
useUpdateCustomBlock,
@@ -138,6 +141,19 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
const [error, setError] = useState(null)
const [showDelete, setShowDelete] = useState(false)
+ // Type-to-confirm buffer for the delete modal — reset whenever the modal opens.
+ const [confirmationText, setConfirmationText] = useState('')
+ const [prevShowDelete, setPrevShowDelete] = useState(false)
+ if (showDelete !== prevShowDelete) {
+ setPrevShowDelete(showDelete)
+ if (showDelete) setConfirmationText('')
+ }
+
+ // Fetched with the view so the count is warm before the delete modal opens.
+ // Server-gated on the same manage authz as delete.
+ const usageCountsQuery = useCustomBlockUsageCounts(existing?.id, { enabled: canManageBlock })
+ const usageCount = usageCountsQuery.data?.usageCount ?? 0
+
// Edit mode may mount before `useCustomBlocks` has resolved this row, leaving the
// buffers empty. Reseed them the first time the block's identity loads (or when it
// changes) — keyed on `existing.id` so a later refetch of the SAME block doesn't
@@ -177,15 +193,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
return m
}, [availableFields])
- const placeholderById = useMemo(() => {
- const m = new Map()
- for (const i of inputs) m.set(i.id, i.placeholder)
+ const overrideById = useMemo(() => {
+ const m = new Map()
+ for (const i of inputs) m.set(i.id, i)
return m
}, [inputs])
// Every deployed Start input is exposed (no selection). Name/type/description are
- // inherited from the field itself (the Start block already defines them); the only
- // thing authored here is the placeholder.
+ // inherited from the field itself (the Start block already defines them); only
+ // the placeholder and required flag are authored here.
const visibleInputs = useMemo(
() =>
deployedLoaded
@@ -196,11 +212,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
name: f.name,
type: f.type,
description: f.description,
- placeholder: placeholderById.get(id),
+ placeholder: overrideById.get(id)?.placeholder,
+ required: overrideById.get(id)?.required,
}
})
: inputs,
- [deployedLoaded, availableFields, placeholderById, inputs]
+ [deployedLoaded, availableFields, overrideById, inputs]
)
const [expandedInputs, setExpandedInputs] = useState>(new Set())
@@ -281,15 +298,18 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
deployed.isLoading ||
(deployedLoaded && visibleOutputs.length === 0)
- // Upsert the per-input placeholder (the only authored field). `visibleInputs`
+ // Upsert an authored per-input override (placeholder/required). `visibleInputs`
// shows every deployed field; the first edit of a field adds its override row.
- function setPlaceholder(id: string, placeholder: string) {
+ function setInputOverride(
+ id: string,
+ patch: Partial>
+ ) {
setInputs((prev) => {
if (prev.some((i) => i.id === id)) {
- return prev.map((i) => (i.id === id ? { ...i, placeholder } : i))
+ return prev.map((i) => (i.id === id ? { ...i, ...patch } : i))
}
const f = fieldById.get(id)
- return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', placeholder }]
+ return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', ...patch }]
})
}
@@ -340,11 +360,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setError('Output names must be unique')
return
}
- // Only the placeholder is authored; the field set/name/type are always derived
- // from the deployed Start. Persist just the non-empty placeholder overrides.
+ // Only the placeholder and required flag are authored; the field set/name/type
+ // are always derived from the deployed Start. Persist only non-empty overrides.
const inputPlaceholders = visibleInputs
- .filter((i) => i.placeholder?.trim())
- .map((i) => ({ id: i.id, placeholder: i.placeholder!.trim() }))
+ .filter((i) => i.placeholder?.trim() || i.required)
+ .map((i) => ({
+ id: i.id,
+ ...(i.placeholder?.trim() ? { placeholder: i.placeholder.trim() } : {}),
+ ...(i.required ? { required: true } : {}),
+ }))
try {
if (existing) {
@@ -408,7 +432,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
{
text: remove.isPending ? 'Deleting...' : 'Delete',
variant: 'destructive' as const,
- onSelect: () => setShowDelete(true),
+ onSelect: () => {
+ setShowDelete(true)
+ // The warning must reflect the org's CURRENT usage, not a
+ // ≤30s-stale cache entry.
+ usageCountsQuery.refetch()
+ },
disabled: remove.isPending,
},
]
@@ -604,11 +633,24 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
)}
+
+ Required
+
+ setInputOverride(i.id, { required: checked })
+ }
+ disabled={!canManageBlock}
+ />
+
Placeholder
setPlaceholder(i.id, e.target.value)}
+ onChange={(e) =>
+ setInputOverride(i.id, { placeholder: e.target.value })
+ }
placeholder='Shown in the empty field'
maxLength={200}
disabled={!canManageBlock}
@@ -686,15 +728,41 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
text={[
'Delete ',
{ text: existing?.name ?? 'this block', bold: true },
- '? Workflows already using it will stop resolving it. This cannot be undone.',
+ '? This cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
pending: remove.isPending,
pendingLabel: 'Deleting...',
+ disabled: confirmationText !== (existing?.name ?? ''),
}}
- />
+ >
+ {usageCountsQuery.data &&
+ (usageCount > 0 ? (
+
+ {usageCount} {usageCount === 1 ? 'workflow is' : 'workflows are'} still using this
+ block and will fail at run time.
+
+ ) : (
+
+ No workflows are currently using it.
+
+ ))}
+
+ Type
+ {existing?.name}
+ to confirm
+
+ }
+ value={confirmationText}
+ onChange={setConfirmationText}
+ placeholder={existing?.name}
+ />
+
| undefined
): CustomBlockInput[] {
@@ -723,17 +792,20 @@ function toCustomBlockInputs(
type: f.type,
placeholder: f.placeholder,
description: f.description,
+ required: f.required,
}))
}
/**
- * Compare inputs by only the authored data — the field id and its placeholder.
- * name/type/description are derived live from the deployed Start (not stored), so
- * comparing them would flag the form dirty when only Start metadata drifted.
+ * Compare inputs by only the authored data — the field id, placeholder, and
+ * required flag. name/type/description are derived live from the deployed Start
+ * (not stored), so comparing them would flag the form dirty when only Start
+ * metadata drifted.
*/
function normalizeInputsForCompare(items: ReadonlyArray>) {
return items.map((i) => ({
id: i.id ?? i.name ?? '',
placeholder: i.placeholder ?? '',
+ required: i.required ?? false,
}))
}
diff --git a/apps/sim/enrichments/email-verification/email-verification.test.ts b/apps/sim/enrichments/email-verification/email-verification.test.ts
index 694c8dd0349..ae547dc4e9d 100644
--- a/apps/sim/enrichments/email-verification/email-verification.test.ts
+++ b/apps/sim/enrichments/email-verification/email-verification.test.ts
@@ -19,7 +19,6 @@ describe('email-verification enrichment cascade', () => {
'zerobounce',
'neverbounce',
'millionverifier',
- 'icypeas',
'enrow',
])
})
@@ -39,31 +38,6 @@ describe('email-verification enrichment cascade', () => {
})
})
- describe('icypeas', () => {
- const p = provider('icypeas')
- it('maps FOUND/DEBITED to deliverable and NOT_FOUND to undeliverable', () => {
- expect(p.toolId).toBe('icypeas_verify_email')
- expect(p.buildParams(emailInput)).toEqual({ email: 'john@acme.com' })
- expect(p.buildParams({ email: '' })).toBeNull()
- expect(p.mapOutput({ status: 'FOUND' })).toEqual({ status: 'valid', deliverable: true })
- expect(p.mapOutput({ status: 'DEBITED' })).toEqual({ status: 'valid', deliverable: true })
- expect(p.mapOutput({ status: 'NOT_FOUND' })).toEqual({
- status: 'invalid',
- deliverable: false,
- })
- expect(p.mapOutput({ status: 'DEBITED_NOT_FOUND' })).toEqual({
- status: 'invalid',
- deliverable: false,
- })
- })
- it('falls through on inconclusive statuses', () => {
- expect(p.mapOutput({ status: 'BAD_INPUT' })).toBeNull()
- expect(p.mapOutput({ status: 'INSUFFICIENT_FUNDS' })).toBeNull()
- expect(p.mapOutput({ status: 'ABORTED' })).toBeNull()
- expect(p.mapOutput({})).toBeNull()
- })
- })
-
describe('enrow', () => {
const p = provider('enrow')
it('maps the valid/invalid qualifier and falls through otherwise', () => {
diff --git a/apps/sim/enrichments/email-verification/email-verification.ts b/apps/sim/enrichments/email-verification/email-verification.ts
index bd78306813e..e9c8048689c 100644
--- a/apps/sim/enrichments/email-verification/email-verification.ts
+++ b/apps/sim/enrichments/email-verification/email-verification.ts
@@ -5,7 +5,7 @@ import type { EnrichmentConfig } from '@/enrichments/types'
/**
* Email Verification enrichment. Checks an email address's deliverability via a
* verifier waterfall — ZeroBounce first (highest coverage), then NeverBounce,
- * then MillionVerifier, then Icypeas, then Enrow. A provider that returns a
+ * then MillionVerifier, then Enrow. A provider that returns a
* definitive verdict (valid / invalid / catch_all / disposable / etc.) fills the
* cell; a provider that can only return `unknown` falls through to the next so
* the row gets the most confident answer available. All providers support hosted
@@ -68,26 +68,6 @@ export const emailVerificationEnrichment: EnrichmentConfig = {
return { status, deliverable: output.deliverable === true }
},
}),
- toolProvider({
- id: 'icypeas',
- label: 'Icypeas',
- toolId: 'icypeas_verify_email',
- buildParams: (inputs) => {
- const email = str(inputs.email)
- if (!email) return null
- return { email }
- },
- mapOutput: (output) => {
- // FOUND/DEBITED → deliverable, NOT_FOUND/DEBITED_NOT_FOUND → undeliverable.
- // Bad input / insufficient funds / aborted are inconclusive → fall through.
- const status = str(output.status)
- if (status === 'FOUND' || status === 'DEBITED')
- return { status: 'valid', deliverable: true }
- if (status === 'NOT_FOUND' || status === 'DEBITED_NOT_FOUND')
- return { status: 'invalid', deliverable: false }
- return null
- },
- }),
toolProvider({
id: 'enrow',
label: 'Enrow',
diff --git a/apps/sim/enrichments/work-email/work-email.test.ts b/apps/sim/enrichments/work-email/work-email.test.ts
index 9f3f6801f17..3a398a6bca6 100644
--- a/apps/sim/enrichments/work-email/work-email.test.ts
+++ b/apps/sim/enrichments/work-email/work-email.test.ts
@@ -26,7 +26,6 @@ describe('work-email enrichment cascade', () => {
'datagma',
'leadmagic',
'dropcontact',
- 'icypeas',
'enrow',
])
})
@@ -130,25 +129,6 @@ describe('work-email enrichment cascade', () => {
})
})
- describe('icypeas', () => {
- const p = provider('icypeas')
- it('splits the name when possible and keeps mononym rows', () => {
- expect(p.toolId).toBe('icypeas_find_email')
- expect(p.buildParams(nameDomain)).toEqual({
- firstname: 'John',
- lastname: 'Doe',
- domainOrCompany: 'acme.com',
- })
- // single-token name runs with firstname alone (lastname is optional)
- expect(p.buildParams({ fullName: 'Cher', companyDomain: 'acme.com' })).toEqual({
- firstname: 'Cher',
- domainOrCompany: 'acme.com',
- })
- expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
- expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' })
- })
- })
-
describe('enrow', () => {
const p = provider('enrow')
it('maps full name + company domain', () => {
diff --git a/apps/sim/enrichments/work-email/work-email.ts b/apps/sim/enrichments/work-email/work-email.ts
index f965a8544f5..5fe87917e36 100644
--- a/apps/sim/enrichments/work-email/work-email.ts
+++ b/apps/sim/enrichments/work-email/work-email.ts
@@ -8,7 +8,7 @@ import type { EnrichmentConfig } from '@/enrichments/types'
* available identifiers (company domain, LinkedIn URL) via a provider waterfall:
* deterministic finders first (Hunter, Findymail by name then by LinkedIn), then
* enrichment/reveal providers (Prospeo, Wiza), then People Data Labs as a broad
- * record-match fallback, then Datagma, LeadMagic, Dropcontact, Icypeas, and Enrow
+ * record-match fallback, then Datagma, LeadMagic, Dropcontact, and Enrow
* as additional finders. Each provider opportunistically uses whatever
* identifiers the row provides and self-skips when it has none usable, so adding
* more inputs widens coverage. First email wins; all providers support hosted keys.
@@ -189,26 +189,6 @@ export const workEmailEnrichment: EnrichmentConfig = {
return email ? { email } : null
},
}),
- toolProvider({
- id: 'icypeas',
- label: 'Icypeas',
- toolId: 'icypeas_find_email',
- buildParams: (inputs) => {
- // Icypeas only requires domainOrCompany; firstname/lastname are optional,
- // so a mononym still runs with firstname alone rather than self-skipping.
- const fullName = str(inputs.fullName)
- const domainOrCompany = normalizeDomain(inputs.companyDomain)
- if (!fullName || !domainOrCompany) return null
- const name = splitName(inputs.fullName)
- return name
- ? { firstname: name.firstName, lastname: name.lastName, domainOrCompany }
- : { firstname: fullName, domainOrCompany }
- },
- mapOutput: (output) => {
- const email = str(output.email)
- return email ? { email } : null
- },
- }),
toolProvider({
id: 'enrow',
label: 'Enrow',
diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts
index b5883c61905..affd77355c8 100644
--- a/apps/sim/executor/constants.ts
+++ b/apps/sim/executor/constants.ts
@@ -1,3 +1,7 @@
+import {
+ normalizeWorkflowBlockName,
+ RESERVED_WORKFLOW_BLOCK_NAMES,
+} from '@sim/workflow-types/workflow'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import type { LoopType, ParallelType } from '@/lib/workflows/types'
@@ -151,11 +155,12 @@ export const SPECIAL_REFERENCE_PREFIXES = [
REFERENCE.PREFIX.VARIABLE,
] as const
-export const RESERVED_BLOCK_NAMES = [
- REFERENCE.PREFIX.LOOP,
- REFERENCE.PREFIX.PARALLEL,
- REFERENCE.PREFIX.VARIABLE,
-] as const
+/**
+ * Delegates to the shared implementation in `@sim/workflow-types` so the
+ * client store and the realtime persistence layer agree on the same reserved
+ * names. Values intentionally mirror REFERENCE.PREFIX.{LOOP,PARALLEL,VARIABLE} above.
+ */
+export const RESERVED_BLOCK_NAMES = RESERVED_WORKFLOW_BLOCK_NAMES
export const LOOP_REFERENCE = {
ITERATION: 'iteration',
@@ -478,12 +483,10 @@ export function escapeRegExp(value: string): string {
* spaces and dots. Used for both block names and variable names to ensure
* consistent matching.
*
- * Dots are stripped because `.` is the reference path delimiter — a name like
- * "Trigger.dev 1" must normalize to "triggerdev1" so the reference
- * `` parses unambiguously. Dotted names could never be
- * referenced before (the first path segment cut the name at the dot), so
- * stripping dots cannot break any previously working reference.
+ * Delegates to the shared implementation in `@sim/workflow-types` so the
+ * client store and the realtime persistence layer normalize block names
+ * identically when checking for reserved/duplicate names.
*/
export function normalizeName(name: string): string {
- return name.toLowerCase().replace(/\s+/g, '').replace(/\./g, '')
+ return normalizeWorkflowBlockName(name)
}
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
index ffda0c65a2b..b650cc1059a 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
@@ -2,6 +2,7 @@ import { setupGlobalFetchMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import {
+ findMissingRequiredCustomBlockInputs,
remapCustomBlockInputKeys,
WorkflowBlockHandler,
} from '@/executor/handlers/workflow/workflow-handler'
@@ -432,3 +433,67 @@ describe('remapCustomBlockInputKeys', () => {
).toEqual({ payload: 'not json' })
})
})
+
+describe('findMissingRequiredCustomBlockInputs', () => {
+ const childBlocks = {
+ start: {
+ type: 'start_trigger',
+ subBlocks: {
+ inputFormat: {
+ value: [
+ { id: 'f1', name: 'firstName', type: 'string' },
+ { id: 'f2', name: 'payload', type: 'object' },
+ { name: 'legacyField', type: 'string' },
+ ],
+ },
+ },
+ },
+ } as Record
+
+ it('flags a required field left empty and reports its display name', () => {
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, {})).toEqual(['firstName'])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: '' })).toEqual([
+ 'firstName',
+ ])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: null })).toEqual([
+ 'firstName',
+ ])
+ })
+
+ it('passes when the required field has a value', () => {
+ expect(
+ findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'Theodore' })
+ ).toEqual([])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 0 })).toEqual([])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: false })).toEqual(
+ []
+ )
+ })
+
+ it('ignores a stale required override whose field was removed from the Start', () => {
+ expect(findMissingRequiredCustomBlockInputs(['removed-field'], childBlocks, {})).toEqual([])
+ })
+
+ it('treats fields without an override as optional', () => {
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'x' })).toEqual(
+ []
+ )
+ expect(findMissingRequiredCustomBlockInputs([], childBlocks, {})).toEqual([])
+ })
+
+ it('keys legacy fields without a stable id by name', () => {
+ expect(findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, {})).toEqual([
+ 'legacyField',
+ ])
+ expect(
+ findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, { legacyField: 'v' })
+ ).toEqual([])
+ })
+
+ it('reports every missing required field at once', () => {
+ expect(findMissingRequiredCustomBlockInputs(['f1', 'f2'], childBlocks, {})).toEqual([
+ 'firstName',
+ 'payload',
+ ])
+ })
+})
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts
index 70d1e52e089..edde6925eb5 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts
@@ -46,6 +46,41 @@ function getValueAtPath(source: unknown, path: string): unknown {
* name. Legacy fields without an id are keyed by name and pass through unchanged.
* Keys that match no current field are dropped.
*/
+/**
+ * A consumer left a publisher-required custom block input empty. The message is
+ * consumer-safe (it names only the block's own input labels), so the catch's
+ * custom-block sanitizer rethrows it verbatim instead of the generic failure.
+ */
+export class CustomBlockMissingInputsError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'CustomBlockMissingInputsError'
+ }
+}
+
+/**
+ * Names of publisher-required custom block inputs the consumer left empty, checked
+ * against the child's LIVE deployed Start fields — a required override whose field
+ * was removed is inert, and a field added after publish has no override, so schema
+ * drift can never block a run. `childWorkflowInput` is the post-remap mapping
+ * (keyed by field name). Same empty semantics as the serializer's required check.
+ */
+export function findMissingRequiredCustomBlockInputs(
+ requiredInputIds: string[],
+ childBlocks: Record,
+ childWorkflowInput: Record
+): string[] {
+ if (requiredInputIds.length === 0) return []
+ const requiredIds = new Set(requiredInputIds)
+ return extractInputFieldsFromBlocks(childBlocks)
+ .filter((field) => requiredIds.has(field.id ?? field.name))
+ .filter((field) => {
+ const value = childWorkflowInput[field.name]
+ return value === undefined || value === null || value === ''
+ })
+ .map((field) => field.name)
+}
+
export function remapCustomBlockInputKeys(
mapping: Record,
childBlocks: Record
@@ -162,6 +197,7 @@ export class WorkflowBlockHandler implements BlockHandler {
let workflowId = inputs.workflowId
let loadUserId = ctx.userId
let exposedOutputs: CustomBlockOutput[] = []
+ let requiredInputIds: string[] = []
if (isCustomBlock) {
const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId)
if (!authority) {
@@ -170,6 +206,7 @@ export class WorkflowBlockHandler implements BlockHandler {
workflowId = authority.workflowId
loadUserId = authority.ownerUserId
exposedOutputs = authority.exposedOutputs
+ requiredInputIds = authority.requiredInputIds
}
if (!workflowId) {
@@ -270,6 +307,19 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowInput = inputs.input
}
+ if (isCustomBlock) {
+ const missing = findMissingRequiredCustomBlockInputs(
+ requiredInputIds,
+ childWorkflow.rawBlocks || {},
+ childWorkflowInput
+ )
+ if (missing.length > 0) {
+ throw new CustomBlockMissingInputsError(
+ `${block.metadata?.name || 'Custom block'} is missing required fields: ${missing.join(', ')}`
+ )
+ }
+ }
+
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
@@ -406,6 +456,16 @@ export class WorkflowBlockHandler implements BlockHandler {
// so capture the child's spans server-side, distill to the aggregate cost, and
// carry only that (no internals) so `block-executor` still bills it.
if (isCustomBlock) {
+ // Missing-required-inputs is the consumer's own mistake and its message
+ // names only the block's input labels — surface it instead of the generic
+ // failure so they can actually fix it. The child never ran: no spend.
+ if (error instanceof CustomBlockMissingInputsError) {
+ throw new ChildWorkflowError({
+ message: error.message,
+ childWorkflowName: block.metadata?.name || 'Custom block',
+ childWorkflowInstanceId: instanceId,
+ })
+ }
let failedChildSpans: WorkflowTraceSpan[] = []
if (hasExecutionResult(error) && error.executionResult.logs) {
failedChildSpans = this.captureChildWorkflowLogs(
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index d567a4ec406..db1cd25e6ca 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -136,6 +136,10 @@ export function useUpdateWorkspaceCredential() {
displayName: payload.displayName,
description: payload.description,
serviceAccountJson: payload.serviceAccountJson,
+ signingSecret: payload.signingSecret,
+ botToken: payload.botToken,
+ apiToken: payload.apiToken,
+ domain: payload.domain,
},
})
},
diff --git a/apps/sim/hooks/queries/custom-blocks.ts b/apps/sim/hooks/queries/custom-blocks.ts
index 5ab6de8e3b7..55a127fc68f 100644
--- a/apps/sim/hooks/queries/custom-blocks.ts
+++ b/apps/sim/hooks/queries/custom-blocks.ts
@@ -2,7 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type CustomBlock,
+ type CustomBlockUsageCounts,
deleteCustomBlockContract,
+ getCustomBlockUsageCountsContract,
listCustomBlocksContract,
type PublishCustomBlockBody,
publishCustomBlockContract,
@@ -11,11 +13,14 @@ import {
} from '@/lib/api/contracts/custom-blocks'
export const CUSTOM_BLOCK_LIST_STALE_TIME = 60 * 1000
+/** Short — the usage count is a pre-delete safety check and must stay fresh. */
+export const CUSTOM_BLOCK_USAGES_STALE_TIME = 30 * 1000
export const customBlockKeys = {
all: ['custom-blocks'] as const,
lists: () => [...customBlockKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...customBlockKeys.lists(), workspaceId ?? ''] as const,
+ usages: (id?: string) => [...customBlockKeys.all, 'usages', id ?? ''] as const,
}
interface CustomBlocksResult {
@@ -53,6 +58,23 @@ export function useCanPublishCustomBlock(workspaceId?: string) {
return useCustomBlocksQuery(workspaceId, (r) => r.enabled)
}
+function fetchCustomBlockUsageCounts(
+ id: string,
+ signal?: AbortSignal
+): Promise {
+ return requestJson(getCustomBlockUsageCountsContract, { params: { id }, signal })
+}
+
+/** How many workflows across the org place this block (live editor state and/or active deployment). */
+export function useCustomBlockUsageCounts(blockId?: string, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: customBlockKeys.usages(blockId),
+ queryFn: ({ signal }) => fetchCustomBlockUsageCounts(blockId as string, signal),
+ enabled: Boolean(blockId) && (options?.enabled ?? true),
+ staleTime: CUSTOM_BLOCK_USAGES_STALE_TIME,
+ })
+}
+
export function usePublishCustomBlock(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts
index f24ed617827..ebab233c7dd 100644
--- a/apps/sim/hooks/use-collaborative-workflow.ts
+++ b/apps/sim/hooks/use-collaborative-workflow.ts
@@ -12,6 +12,7 @@ import {
WORKFLOW_OPERATIONS,
} from '@sim/realtime-protocol/constants'
import { generateId } from '@sim/utils/id'
+import { getWorkflowBlockNameConflict } from '@sim/workflow-types/workflow'
import { useQueryClient } from '@tanstack/react-query'
import { isEqual } from 'es-toolkit'
import type { Edge } from 'reactflow'
@@ -27,7 +28,6 @@ import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { getSubBlocksDependingOnChange } from '@/blocks/utils'
-import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { invalidateDeploymentQueries } from '@/hooks/queries/deployments'
import { useUndoRedo } from '@/hooks/use-undo-redo'
import {
@@ -54,7 +54,11 @@ import type {
Position,
WorkflowState,
} from '@/stores/workflows/workflow/types'
-import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
+import {
+ filterAcyclicEdges,
+ findAllDescendantNodes,
+ isBlockProtected,
+} from '@/stores/workflows/workflow/utils'
const logger = createLogger('CollaborativeWorkflow')
@@ -1028,27 +1032,26 @@ export function useCollaborativeWorkflow() {
}
const trimmedName = name.trim()
- const normalizedNewName = normalizeName(trimmedName)
+ const currentBlocks = useWorkflowStore.getState().blocks
+ const siblingNamesById = Object.fromEntries(
+ Object.entries(currentBlocks).map(([blockId, b]) => [blockId, b.name])
+ )
+ const conflict = getWorkflowBlockNameConflict(id, trimmedName, siblingNamesById)
- if (!normalizedNewName) {
+ if (conflict?.reason === 'empty') {
logger.error('Cannot rename block to empty name')
toast.error('Block name cannot be empty')
return { success: false, error: 'Block name cannot be empty' }
}
- if ((RESERVED_BLOCK_NAMES as readonly string[]).includes(normalizedNewName)) {
+ if (conflict?.reason === 'reserved') {
logger.error(`Cannot rename block to reserved name: "${trimmedName}"`)
toast.error(`"${trimmedName}" is a reserved name and cannot be used`)
return { success: false, error: `"${trimmedName}" is a reserved name` }
}
- const currentBlocks = useWorkflowStore.getState().blocks
- const conflictingBlock = Object.entries(currentBlocks).find(
- ([blockId, block]) => blockId !== id && normalizeName(block.name) === normalizedNewName
- )
-
- if (conflictingBlock) {
- const conflictName = conflictingBlock[1].name
+ if (conflict?.reason === 'duplicate') {
+ const conflictName = currentBlocks[conflict.conflictingBlockId as string].name
logger.error(`Cannot rename block to "${trimmedName}" - conflicts with "${conflictName}"`)
toast.error(`Block name "${trimmedName}" already exists`)
return { success: false, error: `Block name "${trimmedName}" already exists` }
@@ -1423,7 +1426,12 @@ export function useCollaborativeWorkflow() {
const currentEdges = useWorkflowStore.getState().edges
const validEdges = filterValidEdges(edges, blocks)
const newEdges = filterNewEdges(validEdges, currentEdges)
- if (newEdges.length === 0) return false
+ // Reject cyclic edges here, before they are queued for realtime/DB
+ // persistence — the local store also runs this check, but only after
+ // an unfiltered payload would already be enqueued. Filtering once
+ // here keeps the queued payload and the local store in agreement.
+ const acyclicEdges = filterAcyclicEdges(newEdges, currentEdges)
+ if (acyclicEdges.length === 0) return false
const operationId = generateId()
@@ -1432,16 +1440,16 @@ export function useCollaborativeWorkflow() {
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
- payload: { edges: newEdges },
+ payload: { edges: acyclicEdges },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
- useWorkflowStore.getState().batchAddEdges(newEdges, { skipValidation: true })
+ useWorkflowStore.getState().batchAddEdges(acyclicEdges, { skipValidation: true })
if (!options?.skipUndoRedo) {
- newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
+ acyclicEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
}
return true
diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts
index 73df4a3a1b0..4a4f26b3271 100644
--- a/apps/sim/lib/api-key/byok.ts
+++ b/apps/sim/lib/api-key/byok.ts
@@ -204,13 +204,14 @@ export async function getApiKeyWithBYOK(
const isClaudeModel = provider === 'anthropic'
const isGeminiModel = provider === 'google'
const isMistralModel = provider === 'mistral'
+ const isZaiModel = provider === 'zai'
const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId)
if (
isHosted &&
workspaceId &&
- (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)
+ (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel)
) {
const hostedModels = getHostedModels()
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts
index 0c4f10f7085..10a1d589123 100644
--- a/apps/sim/lib/api/contracts/byok-keys.ts
+++ b/apps/sim/lib/api/contracts/byok-keys.ts
@@ -6,6 +6,7 @@ export const byokProviderIdSchema = z.enum([
'anthropic',
'google',
'mistral',
+ 'zai',
'fireworks',
'together',
'baseten',
@@ -13,6 +14,7 @@ export const byokProviderIdSchema = z.enum([
'falai',
'firecrawl',
'exa',
+ 'context_dev',
'serper',
'linkup',
'perplexity',
diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts
index cec3b73c7e8..ecd15b68afb 100644
--- a/apps/sim/lib/api/contracts/credentials.ts
+++ b/apps/sim/lib/api/contracts/credentials.ts
@@ -1,6 +1,10 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
-import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthProvider } from '@/lib/oauth/types'
+import {
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ type OAuthProvider,
+ SLACK_CUSTOM_BOT_PROVIDER_ID,
+} from '@/lib/oauth/types'
const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/
@@ -121,6 +125,14 @@ export const createCredentialBodySchema = z
serviceAccountJson: z.string().optional(),
apiToken: z.string().trim().min(1).optional(),
domain: z.string().trim().min(1).optional(),
+ /**
+ * Client-supplied credential id, honored only for `slack-custom-bot` creates:
+ * the setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}`
+ * before secrets exist, so the id must be known up front.
+ */
+ id: z.string().uuid('id must be a valid UUID').optional(),
+ signingSecret: z.string().trim().min(1).optional(),
+ botToken: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
@@ -166,6 +178,23 @@ export const createCredentialBodySchema = z
}
return
}
+ if (data.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
+ if (!data.signingSecret) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'signingSecret is required for a custom Slack bot credential',
+ path: ['signingSecret'],
+ })
+ }
+ if (!data.botToken) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'botToken is required for a custom Slack bot credential',
+ path: ['botToken'],
+ })
+ }
+ return
+ }
if (!data.serviceAccountJson) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -200,13 +229,23 @@ export const updateCredentialByIdBodySchema = z
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
serviceAccountJson: z.string().min(1).optional(),
+ /** Slack custom-bot secret rotation (reconnect). */
+ signingSecret: z.string().trim().min(1).optional(),
+ botToken: z.string().trim().min(1).optional(),
+ /** Atlassian service-account secret rotation (reconnect). */
+ apiToken: z.string().trim().min(1).optional(),
+ domain: z.string().trim().min(1).optional(),
})
.strict()
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
- data.serviceAccountJson !== undefined,
+ data.serviceAccountJson !== undefined ||
+ data.signingSecret !== undefined ||
+ data.botToken !== undefined ||
+ data.apiToken !== undefined ||
+ data.domain !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts
index 95d0915bca2..01adbbddfc0 100644
--- a/apps/sim/lib/api/contracts/custom-blocks.ts
+++ b/apps/sim/lib/api/contracts/custom-blocks.ts
@@ -11,17 +11,20 @@ const inputFieldSchema = z.object({
description: z.string().optional(),
/** Consumer-facing placeholder hint (curated inputs only). */
placeholder: z.string().optional(),
+ /** Consumers must fill this input (curated inputs only). */
+ required: z.boolean().optional(),
})
/**
- * The only authored per-input datum: a placeholder, keyed by the source Start
- * field's stable `id`. The field's name/type/description are NOT stored — they're
- * always derived from the live deployed Start (so they can't go stale), and this
- * map only supplies the consumer-facing placeholder hint.
+ * The authored per-input data: a placeholder and a required flag, keyed by the
+ * source Start field's stable `id`. The field's name/type/description are NOT
+ * stored — they're always derived from the live deployed Start (so they can't go
+ * stale); an override whose field was removed from the Start is silently ignored.
*/
const inputPlaceholderSchema = z.object({
id: z.string().min(1),
placeholder: z.string().max(200).optional(),
+ required: z.boolean().optional(),
})
export type CustomBlockInputPlaceholder = z.input
@@ -93,6 +96,18 @@ export const updateCustomBlockBodySchema = z
export type UpdateCustomBlockBody = z.input
+/**
+ * How many workflows in the org place this block. Live editor state and the
+ * active deployment snapshot can diverge, so a workflow counts when the block
+ * appears in either; `deployedUsageCount` counts active deployments only.
+ */
+export const customBlockUsageCountsSchema = z.object({
+ usageCount: z.number().int().min(0),
+ deployedUsageCount: z.number().int().min(0),
+})
+
+export type CustomBlockUsageCounts = z.output
+
export const listCustomBlocksContract = defineRouteContract({
method: 'GET',
path: '/api/custom-blocks',
@@ -137,3 +152,13 @@ export const deleteCustomBlockContract = defineRouteContract({
schema: z.object({ success: z.literal(true) }),
},
})
+
+export const getCustomBlockUsageCountsContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/custom-blocks/[id]/usages',
+ params: customBlockIdParamsSchema,
+ response: {
+ mode: 'json',
+ schema: customBlockUsageCountsSchema,
+ },
+})
diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts
index dfcddac85ad..1c0159e9ec2 100644
--- a/apps/sim/lib/api/contracts/mcp.ts
+++ b/apps/sim/lib/api/contracts/mcp.ts
@@ -50,10 +50,12 @@ export const mcpToolSchemaPropertySchema: z.ZodType = z.l
.object({
type: z.union([z.string(), z.array(z.string())]).optional(),
description: z.string().optional(),
- items: mcpToolSchemaPropertySchema.optional(),
+ items: z
+ .union([mcpToolSchemaPropertySchema, z.array(mcpToolSchemaPropertySchema)])
+ .optional(),
properties: z.record(z.string(), mcpToolSchemaPropertySchema).optional(),
required: z.array(z.string()).optional(),
- enum: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
+ enum: z.array(z.unknown()).optional(),
default: z.unknown().optional(),
})
.passthrough()
diff --git a/apps/sim/lib/api/contracts/tiktok-tools.test.ts b/apps/sim/lib/api/contracts/tiktok-tools.test.ts
new file mode 100644
index 00000000000..ee24ba93bd3
--- /dev/null
+++ b/apps/sim/lib/api/contracts/tiktok-tools.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest'
+import { tiktokPublishVideoBodySchema } from '@/lib/api/contracts/tiktok-tools'
+
+const file = {
+ key: 'workspace/test/video.mp4',
+ name: 'video.mp4',
+ size: 1024,
+ type: 'video/mp4',
+}
+
+describe('tiktokPublishVideoBodySchema', () => {
+ it('accepts a draft without direct-post metadata', () => {
+ expect(
+ tiktokPublishVideoBodySchema.safeParse({
+ accessToken: 'token',
+ mode: 'draft',
+ file,
+ }).success
+ ).toBe(true)
+ })
+
+ it('requires direct-post privacy and commercial-content disclosure', () => {
+ expect(
+ tiktokPublishVideoBodySchema.safeParse({
+ accessToken: 'token',
+ mode: 'direct',
+ file,
+ postInfo: { privacy_level: 'SELF_ONLY' },
+ }).success
+ ).toBe(false)
+ })
+
+ it('accepts documented direct-post metadata', () => {
+ expect(
+ tiktokPublishVideoBodySchema.safeParse({
+ accessToken: 'token',
+ mode: 'direct',
+ file,
+ musicUsageConsent: 'accepted',
+ postInfo: {
+ title: 'A test video',
+ privacy_level: 'SELF_ONLY',
+ disable_duet: true,
+ disable_stitch: true,
+ disable_comment: true,
+ brand_content_toggle: false,
+ },
+ }).success
+ ).toBe(true)
+ })
+
+ it('rejects direct posting without explicit music usage consent', () => {
+ expect(
+ tiktokPublishVideoBodySchema.safeParse({
+ accessToken: 'token',
+ mode: 'direct',
+ file,
+ postInfo: {
+ privacy_level: 'SELF_ONLY',
+ disable_duet: true,
+ disable_stitch: true,
+ disable_comment: true,
+ brand_content_toggle: false,
+ },
+ }).success
+ ).toBe(false)
+ })
+
+ it('rejects an undocumented privacy value', () => {
+ expect(
+ tiktokPublishVideoBodySchema.safeParse({
+ accessToken: 'token',
+ mode: 'direct',
+ file,
+ musicUsageConsent: 'accepted',
+ postInfo: {
+ privacy_level: 'PRIVATE',
+ disable_duet: true,
+ disable_stitch: true,
+ disable_comment: true,
+ brand_content_toggle: false,
+ },
+ }).success
+ ).toBe(false)
+ })
+})
diff --git a/apps/sim/lib/api/contracts/tiktok-tools.ts b/apps/sim/lib/api/contracts/tiktok-tools.ts
new file mode 100644
index 00000000000..2023b29de6b
--- /dev/null
+++ b/apps/sim/lib/api/contracts/tiktok-tools.ts
@@ -0,0 +1,56 @@
+import { z } from 'zod'
+import {
+ type ContractBodyInput,
+ type ContractJsonResponse,
+ defineRouteContract,
+} from '@/lib/api/contracts/types'
+import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
+
+const tiktokPublishVideoPostInfoSchema = z.object({
+ title: z.string().max(2200).optional(),
+ privacy_level: z.enum([
+ 'PUBLIC_TO_EVERYONE',
+ 'MUTUAL_FOLLOW_FRIENDS',
+ 'FOLLOWER_OF_CREATOR',
+ 'SELF_ONLY',
+ ]),
+ disable_duet: z.boolean(),
+ disable_stitch: z.boolean(),
+ disable_comment: z.boolean(),
+ video_cover_timestamp_ms: z.number().int().nonnegative().optional(),
+ is_aigc: z.boolean().optional(),
+ brand_content_toggle: z.boolean(),
+ brand_organic_toggle: z.boolean().optional(),
+})
+
+const tiktokPublishVideoBaseSchema = z.object({
+ accessToken: z.string().min(1, 'Access token is required'),
+ file: RawFileInputSchema,
+})
+
+export const tiktokPublishVideoBodySchema = z.discriminatedUnion('mode', [
+ tiktokPublishVideoBaseSchema.extend({
+ mode: z.literal('direct'),
+ postInfo: tiktokPublishVideoPostInfoSchema,
+ musicUsageConsent: z.literal('accepted'),
+ }),
+ tiktokPublishVideoBaseSchema.extend({
+ mode: z.literal('draft'),
+ }),
+])
+
+export const tiktokPublishVideoResponseSchema = z.object({
+ success: z.boolean(),
+ output: z.object({ publishId: z.string() }).optional(),
+ error: z.string().optional(),
+})
+
+export const tiktokPublishVideoContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/tools/tiktok/publish-video',
+ body: tiktokPublishVideoBodySchema,
+ response: { mode: 'json', schema: tiktokPublishVideoResponseSchema },
+})
+
+export type TikTokPublishVideoBody = ContractBodyInput
+export type TikTokPublishVideoResponse = ContractJsonResponse
diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts
index 4903d558614..04c2d3ad5ba 100644
--- a/apps/sim/lib/api/contracts/webhooks.ts
+++ b/apps/sim/lib/api/contracts/webhooks.ts
@@ -258,3 +258,38 @@ export const webhookTriggerPostContract = defineRouteContract({
schema: z.unknown(),
},
})
+
+/**
+ * TikTok app-level webhook ingress. Signature is verified from the raw body
+ * before this schema runs; `content` remains a JSON string per TikTok docs.
+ */
+export const tiktokWebhookEnvelopeSchema = z.object({
+ client_key: z.string().min(1).max(255),
+ event: z.string().min(1).max(255),
+ create_time: z.number().int().nonnegative(),
+ user_openid: z.string().min(1).max(255),
+ content: z.string().max(1_000_000),
+})
+
+export type TikTokWebhookEnvelope = z.input
+
+export const tiktokWebhookHeadersSchema = z.object({
+ 'tiktok-signature': z.string().min(1),
+})
+
+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/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 96c86881aa9..d9d6443fdb4 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -1938,6 +1938,77 @@ export const auth = betterAuth({
},
},
+ {
+ providerId: 'tiktok',
+ clientId: env.TIKTOK_CLIENT_ID as string,
+ clientSecret: env.TIKTOK_CLIENT_SECRET as string,
+ authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/',
+ tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/',
+ scopes: getCanonicalScopesForProvider('tiktok'),
+ responseType: 'code',
+ redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`,
+ // TikTok requires the app identifier under `client_key`, not the standard
+ // `client_id`. The library always sends `client_id` too, but TikTok ignores
+ // unrecognized params, so adding `client_key` here (for both the authorize
+ // redirect and the token exchange) is sufficient without patching the library.
+ //
+ // TikTok also requires a comma-separated `scope` list, but the library always
+ // joins scopes with a space (no configurable separator in the generic-oauth
+ // plugin's authorize route). `additionalParams` is applied via
+ // `url.searchParams.set(key, value)` after the default scope is set, so
+ // overriding `scope` here replaces the space-joined value with a comma-joined
+ // one that TikTok can actually parse.
+ authorizationUrlParams: {
+ client_key: env.TIKTOK_CLIENT_ID as string,
+ scope: getCanonicalScopesForProvider('tiktok').join(','),
+ },
+ tokenUrlParams: { client_key: env.TIKTOK_CLIENT_ID as string },
+ getUserInfo: async (tokens) => {
+ try {
+ const response = await fetch(
+ 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url',
+ {
+ headers: {
+ Authorization: `Bearer ${tokens.accessToken}`,
+ },
+ }
+ )
+
+ if (!response.ok) {
+ await response.text().catch(() => {})
+ logger.error('Error fetching TikTok user info:', {
+ status: response.status,
+ statusText: response.statusText,
+ })
+ return null
+ }
+
+ const profile = await response.json()
+ const user = profile.data?.user
+
+ if (!user?.open_id) {
+ logger.error('Invalid TikTok profile response:', profile)
+ return null
+ }
+
+ const now = new Date()
+
+ return {
+ id: `${user.open_id}-${generateId()}`,
+ name: user.display_name || 'TikTok User',
+ email: `${user.open_id}@tiktok.user`,
+ image: user.avatar_url || undefined,
+ emailVerified: false,
+ createdAt: now,
+ updatedAt: now,
+ }
+ } catch (error) {
+ logger.error('Error in TikTok getUserInfo:', { error })
+ return null
+ }
+ },
+ },
+
{
providerId: 'confluence',
clientId: env.CONFLUENCE_CLIENT_ID as string,
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
index 233be772e99..eecf53ff772 100644
--- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
@@ -21,6 +21,7 @@ const JOB_TYPE_TO_TASK_ID: Record = {
'workflow-execution': 'workflow-execution',
'schedule-execution': 'schedule-execution',
'webhook-execution': 'webhook-execution',
+ 'tiktok-webhook-ingress': 'tiktok-webhook-ingress',
'resume-execution': 'resume-execution',
'workflow-group-cell': 'workflow-group-cell',
'cleanup-logs': 'cleanup-logs',
diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts
index 3acb9927c25..95d6c83dbba 100644
--- a/apps/sim/lib/core/async-jobs/types.ts
+++ b/apps/sim/lib/core/async-jobs/types.ts
@@ -24,6 +24,7 @@ export type JobType =
| 'workflow-execution'
| 'schedule-execution'
| 'webhook-execution'
+ | 'tiktok-webhook-ingress'
| 'resume-execution'
| 'workflow-group-cell'
| 'cleanup-logs'
diff --git a/apps/sim/lib/core/config/api-keys.ts b/apps/sim/lib/core/config/api-keys.ts
index ad093a4d987..1a1f04218cd 100644
--- a/apps/sim/lib/core/config/api-keys.ts
+++ b/apps/sim/lib/core/config/api-keys.ts
@@ -11,7 +11,8 @@ export function getRotatingApiKey(provider: string): string {
provider !== 'openai' &&
provider !== 'anthropic' &&
provider !== 'gemini' &&
- provider !== 'cohere'
+ provider !== 'cohere' &&
+ provider !== 'zai'
) {
throw new Error(`No rotation implemented for provider: ${provider}`)
}
@@ -34,6 +35,10 @@ export function getRotatingApiKey(provider: string): string {
if (env.COHERE_API_KEY_1) keys.push(env.COHERE_API_KEY_1)
if (env.COHERE_API_KEY_2) keys.push(env.COHERE_API_KEY_2)
if (env.COHERE_API_KEY_3) keys.push(env.COHERE_API_KEY_3)
+ } else if (provider === 'zai') {
+ if (env.ZAI_API_KEY_1) keys.push(env.ZAI_API_KEY_1)
+ if (env.ZAI_API_KEY_2) keys.push(env.ZAI_API_KEY_2)
+ if (env.ZAI_API_KEY_3) keys.push(env.ZAI_API_KEY_3)
}
if (keys.length === 0) {
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index 990ab7679d3..717a6f5eb2b 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -144,6 +144,9 @@ export const env = createEnv({
GEMINI_API_KEY_1: z.string().min(1).optional(), // Primary Gemini API key
GEMINI_API_KEY_2: z.string().min(1).optional(), // Additional Gemini API key for load balancing
GEMINI_API_KEY_3: z.string().min(1).optional(), // Additional Gemini API key for load balancing
+ ZAI_API_KEY_1: z.string().min(1).optional(), // Primary Z.ai API key for load balancing
+ ZAI_API_KEY_2: z.string().min(1).optional(), // Additional Z.ai API key for load balancing
+ ZAI_API_KEY_3: z.string().min(1).optional(), // Additional Z.ai API key for load balancing
OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL
VLLM_BASE_URL: z.string().url().optional(), // vLLM self-hosted base URL (OpenAI-compatible)
VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM
@@ -344,6 +347,8 @@ export const env = createEnv({
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
+ TIKTOK_CLIENT_ID: z.string().optional(), // TikTok OAuth client key (TikTok calls this "client_key")
+ TIKTOK_CLIENT_SECRET: z.string().optional(), // TikTok OAuth client secret
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID
CONFLUENCE_CLIENT_SECRET: z.string().optional(), // Atlassian Confluence OAuth client secret
JIRA_CLIENT_ID: z.string().optional(), // Atlassian Jira OAuth client ID
@@ -381,6 +386,7 @@ export const env = createEnv({
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
+ SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger)
REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID
diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts
index aaaad624fa3..25e848a0d5f 100644
--- a/apps/sim/lib/core/security/csp.ts
+++ b/apps/sim/lib/core/security/csp.ts
@@ -60,6 +60,12 @@ const STATIC_SCRIPT_SRC = [
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
'https://analytics.ahrefs.com',
+ // HubSpot tracking (landing pages) — loader plus the
+ // analytics/form-tracking/banner scripts it injects as