From 4856ef396c2774cc3db49c6dc19f34cbfc34d2f5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 22 Jul 2026 23:43:03 -0700 Subject: [PATCH 01/14] feat(library): Best AI Agents for Customer Support Automation (#5879) Co-authored-by: Sim Pi Agent --- .../index.mdx | 145 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 37254 bytes 2 files changed, 145 insertions(+) create mode 100644 apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx create mode 100644 apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg diff --git a/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx b/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx new file mode 100644 index 00000000000..90be869f995 --- /dev/null +++ b/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx @@ -0,0 +1,145 @@ +--- +slug: best-ai-agents-for-customer-support-automation +title: 'Best AI Agents for Customer Support Automation' +description: 'Compare the best AI agents for customer support automation across ticket triage, feedback-to-ticket workflows, inbox management, knowledge grounding, integrations, and self-hosting.' +date: 2026-07-23 +updated: 2026-07-23 +authors: + - andrew +readingTime: 14 +tags: [AI Agents, Customer Support, Support Automation, Sim] +ogImage: /library/best-ai-agents-for-customer-support-automation/cover.jpg +canonical: https://www.sim.ai/library/best-ai-agents-for-customer-support-automation +draft: false +faq: + - q: "What is the best AI agent platform for customer support automation?" + a: "Sim is the best fit for teams that want an open-source, self-hostable workspace with native Knowledge Bases, helpdesk integrations, and API, Chat, and MCP deployment options. Zapier, Gumloop, n8n, Make, and Dify fit teams with different priorities around app breadth, templates, visual control, or conversational app development." + - q: "Can AI agents automate ticket triage and routing?" + a: "Yes. An AI agent can classify a ticket by topic and urgency, assign a priority, and route clear cases to the right queue. Ambiguous or low-confidence cases should go to a human review queue." + - q: "Can AI agents convert customer feedback into support tickets?" + a: "Yes. An agent can extract the intent, sentiment, category, priority, and summary from reviews, surveys, or support channels, then create a structured ticket in a connected helpdesk." + - q: "How do AI agents automate support inbox management?" + a: "They read and classify incoming messages, draft grounded replies from support documentation and macros, and either send the response or escalate it for human approval according to the workflow's risk rules." +--- + +## TL;DR + +Sim leads for teams that want an open-source, self-hostable AI workspace for support automation, with runner-ups that fit specific buyer types. + +- **Sim** wins for open-source, Apache 2.0 self-hosting with native Knowledge Bases and multi-surface deployment. +- **Zapier** fits teams that need the largest app catalog and standardized ease of use. +- **Gumloop** suits ops-led teams that want fast setup through templates. +- **n8n** works for technical teams wanting node-based control. +- **Make** fits teams building visual, branching workflow logic. +- **Dify** serves teams building LLM-first conversational apps. + +This article covers three support automation use cases: ticket triage and routing, converting customer feedback into tickets, and support inbox management. + +## What is the best AI agent platform for customer support automation? + +Sim is the best AI agent platform for customer support automation when you want an open-source workspace you can actually control. It ships under the Apache 2.0 license, so you can run it as a hosted cloud product at [sim.ai](https://sim.ai) or self-host the same stack via Docker or Kubernetes without commercial-use restrictions. You build agents by describing what you want in plain language through Mothership, and you ground them in your own docs and macros using native Knowledge Bases. What separates Sim from single-surface tools is the deployment step. You publish one workflow as an API, a hosted chat interface, or an MCP tool, so the same triage agent can answer inside a chat window and serve another system through an endpoint. + +That grounding matters because a support agent is only as accurate as the material it reads. A Knowledge Base of your help center articles, refund policies, and canned macros lets the agent answer from your actual rules instead of guessing. + +Sim also connects to the helpdesk tools your team already runs, which removes the custom API glue that usually stalls these projects. The Zendesk integration handles ticket, user, and organization management, and the Intercom integration covers contacts, companies, conversations, and tickets. The Jira Service Management integration handles internal IT-style ticketing. With more than 1,000 integrations, you wire an agent to your CRM and helpdesk in the builder rather than in code. + +The runner-ups each win a specific buyer. Zapier is the safe pick when you want the largest app catalog and your ops team already lives inside it. Gumloop fits ops-led teams that want fast results from a template library. n8n suits technical teams that want node-based control and a mature self-hosted engine. Make works for teams that need visual, branching workflow logic without writing much code. Dify earns a mention for teams building LLM-first conversational apps rather than broad automation. Each section below argues its case in depth, so read on for the case behind each. + +## Can AI agents automate ticket triage and routing? + +Yes, AI agents automate ticket triage and routing by classifying incoming tickets, scoring their priority, and applying routing logic that pushes each ticket to the right queue or agent. A well-built agent reads the ticket body, identifies the topic and urgency, and decides where it belongs before a human ever opens it. The routing decision runs on the same helpdesk and CRM tools your team already uses, so a billing complaint lands with the billing team and an outage report escalates to on-call. + +The accuracy of that decision depends on what the agent knows. Keyword rules break because they match surface text without understanding intent, so a ticket that says "I can't get in" routes wrong when the underlying issue is a password reset. Sim solves this by grounding the agent in a Knowledge Base of your product docs, past resolutions, and support macros, which lets the agent reason about what the customer actually needs rather than which words they typed. Wire that Knowledge Base to Sim's Zendesk and Intercom integrations, and the agent classifies the ticket against real product knowledge, then writes the priority and routing decision straight back into the helpdesk record. + +Triage still breaks on ambiguous tickets, and honest teams plan for it. A message that mixes two unrelated problems, or one written in a language your Knowledge Base doesn't cover well, produces a low-confidence classification the agent should not act on alone. Route those edge cases to a human review queue instead of forcing a guess, and set a confidence threshold below which the agent flags rather than routes. That threshold keeps automation fast on clear tickets while protecting the customers whose problems don't fit a clean category. + +## Can AI agents convert customer feedback into tickets? + +Yes, AI agents convert raw customer feedback into structured helpdesk tickets, and they do it by extracting intent from unstructured text before writing a clean record a human can act on. An agent ingests feedback from post-support surveys, app store reviews, or a shared support channel, reads the sentiment and the underlying request, then creates a ticket in your helpdesk with a category, priority, and summary already filled in. + +The quality of that auto-created ticket depends on two things: how well the agent understands your product and how deeply it connects to your helpdesk. Knowledge grounding decides whether the agent classifies a vague complaint correctly or files it under the wrong queue. An agent grounded in your docs and macros knows that "the export keeps timing out" belongs to the billing-export bug queue, not general feedback. Without that grounding, you get a ticket a human has to re-triage, which defeats the point. + +Integration depth decides whether the ticket lands usable or half-formed. A shallow connector might create a ticket with a title and nothing else. Sim's Zendesk tools can read and write ticket, user, organization, priority, tag, and custom-field data, so a workflow can carry customer and account context into the record instead of leaving an agent with a blank screen. + +Consider a one-star review that mentions a broken checkout flow. A Sim workflow can read the review through the feedback channel, check it against a Knowledge Base of known issues, match it to an open bug, and create a Zendesk ticket tagged with the affected feature and a priority score, then link it to the existing bug record. A human agent opens that ticket and already knows what happened and where it fits. + +## How do AI agents automate support inbox management? + +An AI agent handles a support inbox by reading each incoming message, classifying it, drafting a reply from your macros and documentation, and either sending it or flagging it for a human. The classification step sorts messages by intent and urgency. The drafting step pulls the right macro or doc passage and writes it into a coherent response. The escalation step decides which conversations a person needs to see before anything goes out. + +Where this automation actually lives depends on the deployment surface you choose. Deploy the agent as a hosted chat interface and it becomes the front door that answers customers directly. Deploy it as an API and it plugs into the inbox tool you already run, drafting inside Zendesk or Intercom rather than replacing them. Deploy it as an MCP tool and another system calls the agent when it needs a support answer. That deployment surface decides whether the agent sits on top of your existing stack or in front of it. + +Sim's Zendesk and Intercom integrations make the read-draft-write loop concrete. The agent reads incoming tickets and conversations through the integration, drafts a reply grounded in a Knowledge Base of macros and support docs, and writes the response or an escalation note back into the same helpdesk record. Grounding matters here because a reply built from your actual macros stays accurate, while a raw model guess drifts. + +Whether you choose draft-and-approve or full autonomy separates most buyers. A draft-and-approve setup writes the reply and leaves it for an agent to send, which suits teams protecting tone and accuracy. Full autonomy sends without review, which fits high-volume, low-risk queries where speed outweighs oversight. + +## n8n for technical teams building custom support workflows + +n8n is the pick for technical teams that want node-based control over every branch of a support workflow. Its execution engine has matured over years of production use, and its node library covers hundreds of services with the granular parameter control that engineers expect. When you need a support automation with custom error handling, conditional retries, and precise data transformations between a helpdesk and a CRM, n8n gives you the primitives to build exactly what you want. + +The template ecosystem shortens the path from blank canvas to working flow. You can pull a community workflow for ticket enrichment or Slack escalation, then rewire it to your stack rather than starting from scratch. For a team comfortable reading and editing node graphs, that flexibility pays off across every automation you build after the first. + +Hosting is not what separates n8n from Sim, since both offer a managed cloud product and a self-hosted path you run in your own infrastructure. The license is the real difference. n8n ships under the Sustainable Use License, a fair-code model that restricts certain commercial uses and hosting-as-a-service arrangements. Sim ships under Apache 2.0, a fully permissive license that lets you run, modify, and commercialize the code without those commercial-use restrictions. If your legal team needs a clean permissive license, that distinction decides the choice before you write a single workflow. + +The main concession is the build curve. n8n provides native nodes for assembling a RAG pipeline, but you still configure the document loading, embeddings, vector store, and retrieval logic that grounds a triage agent in your documentation. Sim ships purpose-built, workspace-level Knowledge Bases for that grounding and lets you describe the agent in plain language through Mothership, so a support engineer reaches a working, doc-grounded agent with less assembly. Pick n8n when you want maximum control and are willing to build the grounding pipeline. Pick Sim when you want that layer ready out of the box. + +## Zapier for teams that want the largest app catalog + +Zapier wins on catalog breadth, and that alone explains why so many support teams default to it. With more than 9,000 app connections, Zapier almost certainly already talks to the tools your support stack runs, whether that means Zendesk, Salesforce, Slack, or a survey tool nobody else supports. When your goal is wiring one event to another action across a sprawling SaaS stack, Zapier's coverage means you rarely hit a dead end where an integration simply doesn't exist. + +That breadth pairs with a build model most ops people can pick up in an afternoon. Zapier's trigger-and-action Zaps read like plain sentences, and a support-ops lead with no coding background can ship a working automation the same day. For teams already standardized on Zapier across marketing and sales ops, adding a few support automations costs almost nothing in learning curve. + +Zapier's limits show up the moment your triage logic gets complicated. Zapier's agent capabilities and knowledge grounding stay shallow compared to a purpose-built AI workspace, so an agent that needs to read a ticket, weigh it against your documented policies, and route it by nuanced intent is harder to express as a linear Zap. You can end up chaining filters and paths that grow brittle as edge cases pile up, because Zapier was built to move data between apps before it added agents. + +Zapier Agents and Chatbots can attach FAQs, documents, tables, and webpages as knowledge sources. Those sources are configured per agent rather than managed as a reusable, workspace-level Knowledge Base, so grounding multiple support workflows can mean copying and maintaining context across agents. Pick Zapier when your priority is connecting the widest possible set of tools with minimal setup, and reach for an AI workspace when reusable grounding and nuanced routing matter more than the length of the connector list. + +## Make for visual, multi-step support automations + +Make earns its place when your support workflows branch in ways a linear tool can't express. Its scenario builder lets you draw conditional paths visually, so a ticket that meets one condition routes one way and a ticket that fails it takes another. You can nest routers, add filters between modules, and build error-handling branches without writing code. For a support team mapping out a triage flow with a dozen possible outcomes, that visual model is easier to reason about than a script or a flat rule list. + +The scenario builder shines on the operations side of support automation. You can pull a new Zendesk ticket, check its priority and tags, split the flow across several routes, and post to different Slack channels or update different CRM records depending on what each branch decides. When your logic depends on many overlapping conditions, seeing the whole tree on one canvas beats debugging it in text. + +Make's weakness surfaces once the agent itself needs to reason across reusable workspace knowledge rather than follow rules you drew. Make's Knowledge feature can ground an AI Agent with uploaded context files backed by RAG, but that context remains attached to the agent rather than becoming a shared Knowledge Base that workflows across the workspace can reuse. The scenario still requires you to arrange the surrounding retrieval, routing, and helpdesk actions as modules. + +That difference defines who Make fits. If your support automation is mostly deterministic routing with occasional AI classification, Make handles it cleanly. If you want a workspace-level knowledge layer that multiple agents use to read a ticket, retrieve the right macro, and draft a grounded reply, Sim's Knowledge Bases and Mothership building target that case directly. + +## Gumloop for ops teams automating support workflows with templates + +Gumloop earns its place for ops-led teams that measure success in weeks, not months. Its template library ships with pre-built support workflows you can clone and adjust, so an operations lead can stand up a triage-to-routing flow without hiring an engineer or learning a node graph from scratch. For support-ops buyers specifically, that head start matters, because most of them own the ticketing process but not the codebase. + +The template approach shapes the whole product. Gumloop's UX assumes you want to configure existing patterns rather than design new ones, and it rewards that assumption with clean workflows and quick wins for common tasks like feedback intake, tagging, and handoffs to a helpdesk. If your team already knows the shape of the automation you need and just wants it running, Gumloop gets you there faster than tools that make you design from a blank canvas. + +That same design choice sets the ceiling. Gumloop does not give you the self-hosting control that regulated or security-conscious teams need, and it does not expose the custom agent design you would build if your support logic outgrows the template it started from. When your routing rules stop fitting a pre-made pattern, you hit the edge of what the platform wants you to do. + +For teams that need to run everything inside their own infrastructure or wire up bespoke agent behavior, Sim offers Apache 2.0 self-hosting and Knowledge Bases that ground agents in your own docs and macros. Gumloop wins on speed for standard ops workflows. Sim wins when the workflow has to be yours, hosted where you choose and built to logic no template anticipated. Match the tool to how far your support automation will eventually stretch. + +## Dify as a secondary option for LLM-native teams + +Dify earns a spot on this list if you're building an LLM-first conversational app rather than automating a broad support ops stack. Its prompt and app management tooling is genuinely strong, and it gives you a clean workspace for versioning prompts, testing model outputs, and shipping a chat-style agent grounded in your own content. For a support team whose main goal is a single conversational assistant, that focus pays off. + +The tradeoff shows up the moment you need to reach into the tools your support team already runs. Dify's integration breadth trails the workflow-automation platforms above it, so wiring an agent into Zendesk, Intercom, or a CRM takes more custom work than it does on Sim or Zapier. If your support automation lives mostly inside chat and rarely touches other systems, Dify handles it well. If a ticket has to flow through triage, routing, and a helpdesk record, you'll feel the gaps, and a platform built around integrations and grounding fits better. + +## How the top AI agent platforms for customer support compare + +The six platforms below split along a clear line. Some optimize for broad integration reach and template speed, and others give you agent depth and self-hosting control. Read the table across these axes to match a platform to how your support team actually works, not to a generic feature count. + +| Platform | Builder model | Agent depth | Knowledge grounding | Integrations | Deployment surfaces | License / hosting | Pricing model | Best-fit ICP | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Sim | Natural-language (Mothership) + visual | Deep, multi-step agents | Native Knowledge Bases | 1,000+ | API, hosted chat interface, MCP tool | Apache 2.0, cloud or self-host | Usage-based tiers | Teams wanting open-source AI workspace | +| n8n | Node-based visual | Moderate, DIY assembly | Native RAG nodes, configurable pipeline | 1,900+ listed | API, webhook | Sustainable Use License, cloud or self-host | Execution-based | Technical teams needing node control | +| Zapier | Linear step builder | Moderate | Per-agent knowledge sources | 9,000+ | Webhook, embed | Proprietary, cloud only | Task-based | Ops teams standardized on Zapier | +| Make | Visual scenario builder | Moderate | Agent Knowledge with RAG | 3,000+ | Webhook, API | Proprietary, cloud only | Operations-based | Teams needing branching visual logic | +| Gumloop | Template-driven visual | Moderate | Basic | 100+ | Webhook, API | Proprietary, cloud only | Credit-based | Ops-led teams wanting templates | +| Dify | LLM-app builder | Deep for chat | Native RAG | Narrower | API, embed widget | Open-source, cloud or self-host | Usage-based | LLM-first app teams | + +## Which AI agent platform fits your team? + +Your best pick depends on what your team controls and where the workflow needs to live. + +Technical teams that need to self-host and own the code should compare Sim and n8n directly. Both offer cloud and self-hosted paths, so the license decides between them. Sim ships under Apache 2.0 with no commercial-use restrictions and gives you native Knowledge Bases plus Mothership natural-language building, which removes much of the RAG assembly n8n's node-based engine requires for grounded support agents. Choose n8n when you want granular node-level control and already have engineers comfortable configuring their own retrieval logic. + +Ops-led teams optimizing existing workflows should start with Zapier or Gumloop. Zapier wins when your stack already spans dozens of tools and you want the broadest catalog to connect them. Gumloop wins when you want support-specific templates that get a triage or feedback-to-ticket flow running quickly. Both prioritize setup speed, so compare them with Sim when your triage logic needs reusable workspace knowledge and deeper agent control. + +Enterprise teams that need governance and scale should weigh Sim's self-hosting against their own compliance requirements. Running the workspace inside your own infrastructure keeps customer conversations and Knowledge Base contents on hardware you control, and deploying the same workflow as an API, hosted chat interface, or MCP tool lets one governed agent serve multiple support surfaces without duplicate builds. + +Start where the friction is lowest. Open a hosted account at [sim.ai](https://sim.ai) to start building a triage agent, or self-host through Docker if your policy requires it. Gumloop's template library is the fastest route if you want a working support flow before you commit to a full build. diff --git a/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg b/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..14234aef1a6789d9dcd1b5a8ccc0f9473939e7f0 GIT binary patch literal 37254 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 Date: Thu, 23 Jul 2026 01:11:45 -0700 Subject: [PATCH 02/14] fix(custom-blocks): stop image icons blowing up on class-sized surfaces (#5878) --- .../menus/suggestion-menu-chrome.ts | 5 +++-- .../components/agent-group/tool-call-item.tsx | 8 +------- .../components/trace-view/trace-view.tsx | 4 ++-- .../workflow-sidebar/workflow-sidebar.tsx | 2 +- .../output-select/output-select.tsx | 2 +- .../connection-blocks/connection-blocks.tsx | 4 ++-- .../components/tag-dropdown/tag-dropdown.tsx | 2 +- .../components/tool-input/tool-input.tsx | 2 +- .../panel/components/editor/editor.tsx | 2 +- .../panel/components/toolbar/toolbar.tsx | 2 +- .../components/terminal/terminal.tsx | 6 +++--- .../preview-editor/preview-editor.tsx | 2 +- .../components/block/block.tsx | 2 +- .../command-items/command-items.tsx | 2 +- apps/sim/blocks/custom/custom-block-icon.tsx | 20 ++++++++++--------- .../workflow-block/workflow-block-view.tsx | 2 +- 16 files changed, 32 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts index c468042a18d..4b33f7de8aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -14,9 +14,10 @@ export const SUGGESTION_SURFACE_CLASS = */ export const SUGGESTION_SCROLL_CLASS = 'max-h-[240px] scroll-py-1.5 overflow-y-auto overscroll-none' -/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */ +/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. The `img` rules + * size custom-block image icons (rendered as ``, so the `svg` rules never reach them). */ export const SUGGESTION_ITEM_CLASS = - 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' + 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)] [&_img]:pointer-events-none [&_img]:size-[14px] [&_img]:shrink-0' /** A group heading above a run of rows. */ export const SUGGESTION_GROUP_LABEL_CLASS = diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 2b94fcff9b4..f5d50141d1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -107,13 +107,7 @@ export function ToolCallItem({ return (
{BlockIcon && ( - // Size via inline style: a custom block's image icon carries a trailing - // `size-full` that defeats size *classes* (it fills tiled surfaces), so a - // class-only size renders the uploaded icon at natural size here. - + )} {isExecuting ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 9ea1131717c..6315cc4e304 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -332,7 +332,7 @@ const TraceTreeRow = memo(function TraceTreeRow({ )} {!isIterationType(span.type) && (
{BlockIcon && } @@ -707,7 +707,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
{!isIterationType(span.type) && (
{BlockIcon && } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index 8392000e51a..b6702c721f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -178,7 +178,7 @@ const TagIcon: React.FC<{ color: string }> = ({ icon, color }) => (
{typeof icon === 'string' ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index a4a0b8fd1f9..c6c9ee01ec6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -28,7 +28,7 @@ const TagIcon: React.FC<{ color: string }> = ({ icon, color }) => (
{typeof icon === 'string' ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx index 7899d1cf8fd..d28620e2a38 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx @@ -145,7 +145,7 @@ function ConnectionItem({ }} >
{Icon && ( @@ -154,7 +154,7 @@ function ConnectionItem({ 'transition-transform duration-200', getTileIconColorClass(bgColor), hasFields && 'group-hover:scale-110', - '!h-[9px] !w-[9px]' + '!size-[9px]' )} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 6f62851c289..cd8b9df42cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -387,7 +387,7 @@ const TagIcon: React.FC<{ color: string }> = ({ icon, color }) => (
{typeof icon === 'string' ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 3cfcb3fe3e8..0b34f353382 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -408,7 +408,7 @@ function createToolIcon( ) { return (
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 bd9558e6789..e88f9aa79d1 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 @@ -373,7 +373,7 @@ export function Editor() {
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
{Icon && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 7d36f6a97bf..6d945a8bcc2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -124,7 +124,7 @@ const BlockRow = memo(function BlockRow({ >
{BlockIcon && ( @@ -300,7 +300,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({ >
{BlockIcon && ( @@ -427,7 +427,7 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({ >
{BlockIcon && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 1c095a9c4c1..d287c7a94a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -1198,7 +1198,7 @@ function PreviewEditorContent({
{block.type !== 'note' && (
{!isNoteBlock && (
() + /** * Build a `BlockIcon` from an uploaded icon image URL. Rendered as an `` so * any uploaded PNG/JPEG/SVG works; `className` (size) is forwarded like every * other block icon. Cached by URL so the component reference stays stable across * the many tiles/nodes that render a custom block. */ -const cache = new Map() - export function makeImageIcon(url: string): BlockIcon { const cached = cache.get(url) if (cached) return cached - // Fill the tile so an uploaded image/logo reads at the same footprint as other - // blocks' colored tiles, instead of a small glyph floating in a transparent - // square. Trailing `size-full` beats a consumer size *class* (twMerge keeps the - // last of a conflict group) so a tiled surface (canvas/toolbar/palette) fills; - // it loses to a consumer inline `style` (specificity) so a tile-less inline - // surface that sizes via `style={{ width, height }}` still renders at its px. + /** + * `size-full` is only the DEFAULT (fills a fixed-size tile parent when no size + * is given); a consumer size class or inline style always wins, so flow + * surfaces that render icons at `size-[14px]` get exactly that. Tiled surfaces + * (canvas node, toolbar, search modal, …) pass a glyph-size class but want the + * image to fill the tile — they opt in with `[&_img]:size-full` on the fixed + * wrapper, which out-specifies the size class on the img. + */ const ImageComponent = memo(({ className, style }: SVGProps) => ( )) // double-cast-allowed: an renderer must satisfy the SVG-typed BlockIcon slot diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 2ee141b10b2..82ce02c9d05 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -212,7 +212,7 @@ export function WorkflowBlockView({ >
Date: Thu, 23 Jul 2026 10:40:51 -0700 Subject: [PATCH 03/14] improvement(landing): keyword-forward SEO copy on the home and enterprise pages (#5887) * improvement(landing): keyword-forward SEO copy on the home and enterprise pages * fix(landing): sync homepage JSON-LD title and restore visible open-source claim --- .../components/hero-header/hero-header.tsx | 13 +++++++++++ .../app/(landing)/components/hero/hero.tsx | 7 +++--- .../home-structured-data.tsx | 9 +++++++- .../components/home-structured-data/index.ts | 2 +- .../components/mothership/mothership.tsx | 2 +- .../components/product-demo/product-demo.tsx | 5 ++-- .../solutions-card-row-header.tsx | 10 ++++++-- .../solutions-hero/solutions-hero.tsx | 1 + .../components/solutions-page/types.ts | 12 ++++++++++ .../app/(landing)/enterprise/enterprise.tsx | 23 +++++++++++-------- apps/sim/app/(landing)/files/files.tsx | 2 +- .../sim/app/(landing)/knowledge/knowledge.tsx | 2 +- apps/sim/app/(landing)/logs/logs.tsx | 2 +- apps/sim/app/(landing)/page.tsx | 11 +++++---- .../scheduled-tasks/scheduled-tasks.tsx | 2 +- apps/sim/app/(landing)/tables/tables.tsx | 4 ++-- .../sim/app/(landing)/workflows/workflows.tsx | 2 +- 17 files changed, 79 insertions(+), 30 deletions(-) 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 index c2e9b566956..d06e6ca7a88 100644 --- 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 @@ -6,6 +6,12 @@ import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout' interface LandingHeroHeaderProps { description: string + /** + * Optional second paragraph beneath the description - a self-contained + * definition of the page's subject, kept quotable for answer engines (GEO). + * Omitted by the homepage, so its hero renders unchanged. + */ + definition?: string eyebrow?: ReactNode heading: ReactNode headingId: string @@ -17,6 +23,7 @@ interface LandingHeroHeaderProps { */ export function LandingHeroHeader({ description, + definition, eyebrow, heading, headingId, @@ -37,6 +44,12 @@ export function LandingHeroHeader({ {description}

+ {definition ? ( +

+ {definition} +

+ ) : null} +
diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx index e591cf4c6b4..f28d618087c 100644 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ b/apps/sim/app/(landing)/components/hero/hero.tsx @@ -86,11 +86,12 @@ export function Hero() { headingId='hero-heading' heading={ <> - Sim is the AI workspace
- for building AI agents. + Sim is the AI workspace for
+ building and managing AI agents. } - description='Open source, with 1,000+ integrations and every major LLM. Build, deploy, and manage agents visually, conversationally, or with code.' + description='Sim is an AI agent and workflow builder for teams creating agents that automate real work. Design workflows visually, describe what you need in natural language, or use code for complete control.' + definition='Connect your agents to 1,000+ integrations and every major LLM, then deploy, monitor, and improve them from one collaborative, open-source workspace.' />
`, OG/Twitter titles (`page.tsx`), and the JSON-LD `WebPage.name` + * below, so the title surfaces never drift. + */ +export const HOME_PAGE_TITLE = 'The AI Workspace | Build, Deploy & Manage AI Agents | Sim' + const HOME_JSON_LD = { '@context': 'https://schema.org', '@graph': [ @@ -31,7 +38,7 @@ const HOME_JSON_LD = { '@type': 'WebPage', '@id': `${SITE_URL}#webpage`, url: SITE_URL, - name: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + name: HOME_PAGE_TITLE, isPartOf: { '@id': `${SITE_URL}#website` }, about: { '@id': `${SITE_URL}#software` }, datePublished: '2024-01-01T00:00:00+00:00', diff --git a/apps/sim/app/(landing)/components/home-structured-data/index.ts b/apps/sim/app/(landing)/components/home-structured-data/index.ts index 61d458f204c..d4a9af3c897 100644 --- a/apps/sim/app/(landing)/components/home-structured-data/index.ts +++ b/apps/sim/app/(landing)/components/home-structured-data/index.ts @@ -1 +1 @@ -export { HOME_PAGE_DESCRIPTION, HomeStructuredData } from './home-structured-data' +export { HOME_PAGE_DESCRIPTION, HOME_PAGE_TITLE, HomeStructuredData } from './home-structured-data' diff --git a/apps/sim/app/(landing)/components/mothership/mothership.tsx b/apps/sim/app/(landing)/components/mothership/mothership.tsx index 62f0d9d34f5..24995307640 100644 --- a/apps/sim/app/(landing)/components/mothership/mothership.tsx +++ b/apps/sim/app/(landing)/components/mothership/mothership.tsx @@ -83,7 +83,7 @@ export function Mothership() { className='max-w-[1200px] text-balance text-[28px] leading-[1.2] max-sm:text-[22px]' > - Everything your agents need, in one workspace. + Everything your AI agents need, in one workspace. Build, run, and watch every agent. diff --git a/apps/sim/app/(landing)/components/product-demo/product-demo.tsx b/apps/sim/app/(landing)/components/product-demo/product-demo.tsx index 10d1f030024..948a27234ba 100644 --- a/apps/sim/app/(landing)/components/product-demo/product-demo.tsx +++ b/apps/sim/app/(landing)/components/product-demo/product-demo.tsx @@ -45,8 +45,9 @@ export function ProductDemo() { Describe it. Sim builds it.

- Tell Sim what you need in plain English and it wires blocks, models, and integrations - into a working agent. + Describe what you want to automate in plain English. Sim acts as an AI agent builder, + connecting the right blocks, models, tools, and data into a working workflow that you + can review, customize, test, and deploy.

diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx index c430eed9f7f..3aa995e65f3 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx @@ -3,8 +3,9 @@ import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/con import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solutions-page/types' /** - * The header block of a card row - an `

` title, a body-color subtitle, and - * a single pill CTA, stacked with named spacing constants. Extracted from + * The header block of a card row - an `

` title, a body-color subtitle, an + * optional second subtitle paragraph, and a single pill CTA, stacked with named + * spacing constants. Extracted from * {@link SolutionsCardRow} so layouts that place row headers inside a shared * grid (the enterprise feature grid) render the exact same header chrome. */ @@ -27,6 +28,11 @@ export function SolutionsCardRowHeader({ row, headingId }: SolutionsCardRowHeade

{row.subtitle}

+ {row.note ? ( +

+ {row.note} +

+ ) : null}
diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx index 1c40d68d4e8..80fd08911c0 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx @@ -67,6 +67,7 @@ export function SolutionsHero({ hero, align = 'left', variant = 'solutions' }: S heading={hero.heading} headingId='solutions-hero-heading' description={hero.description} + definition={hero.definition} />
, }, { @@ -100,7 +103,8 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = { }, { title: 'Manage the full lifecycle', - description: 'Version, monitor, and edit every agent in Sim as your workflows evolve.', + description: + 'Version, monitor, and update every enterprise AI agent in Sim as your business requirements, integrations, and governance policies evolve.', visual: , }, ], @@ -109,7 +113,7 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = { id: 'governance', title: 'Governance and Security for Enterprise AI Agents', subtitle: - 'Sim gives security teams role-based access, approval paths, and a complete audit trail for every deployment.', + 'Control how enterprise AI agents access data, use tools, and take action. Sim provides SSO, permission groups, approval paths, configurable data retention, and append-only audit logs for every security-relevant change.', cta: { label: 'See security', href: DEMO_HREF }, cards: [ { @@ -137,7 +141,8 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = { id: 'deploy', title: 'Deploy Enterprise Workflow Agents with Confidence', subtitle: - 'Test in staging, watch live runs, and roll back in seconds. Sim versions every deployment.', + 'Enterprise workflow agents need controlled release processes. Test changes in staging, observe production runs, version every deployment, and restore a stable version if updates cause an issue.', + note: 'Deploy Sim on your own infrastructure with Docker or Kubernetes when your organization requires greater control over its environment and data.', cta: { label: 'Explore deployment', href: SIGNUP_HREF }, cards: [ { diff --git a/apps/sim/app/(landing)/files/files.tsx b/apps/sim/app/(landing)/files/files.tsx index ccf2123ed40..10033e2a708 100644 --- a/apps/sim/app/(landing)/files/files.tsx +++ b/apps/sim/app/(landing)/files/files.tsx @@ -119,7 +119,7 @@ const FILES_CONFIG: SolutionsPageConfig = { }, { id: 'agents', - title: 'Agents read, parse, and produce files.', + title: 'AI agents read, parse, and produce files.', subtitle: 'Sim agents take files as inputs, pull the data out, and write new files back to the store for your team.', cta: { label: 'Build a file-handling agent', href: '/signup' }, diff --git a/apps/sim/app/(landing)/knowledge/knowledge.tsx b/apps/sim/app/(landing)/knowledge/knowledge.tsx index f70d1f06e41..4ccdb912189 100644 --- a/apps/sim/app/(landing)/knowledge/knowledge.tsx +++ b/apps/sim/app/(landing)/knowledge/knowledge.tsx @@ -55,7 +55,7 @@ const KNOWLEDGE_CONFIG: SolutionsPageConfig = { rows: [ { id: 'memory', - title: 'Give agents memory of your data.', + title: 'Give AI agents memory of your data.', subtitle: 'Sim turns your docs and sources into searchable memory every agent can read, so answers come from your data instead of guesswork.', cta: { label: 'Explore Knowledge Base', href: '/signup' }, diff --git a/apps/sim/app/(landing)/logs/logs.tsx b/apps/sim/app/(landing)/logs/logs.tsx index b3755a2973f..8a00c03f2e7 100644 --- a/apps/sim/app/(landing)/logs/logs.tsx +++ b/apps/sim/app/(landing)/logs/logs.tsx @@ -57,7 +57,7 @@ const LOGS_CONFIG: SolutionsPageConfig = { rows: [ { id: 'trace', - title: 'See exactly what every agent did.', + title: 'See exactly what every AI agent did.', subtitle: 'Sim records every step of every agent run, so teams can follow each decision, tool call, and output on one timeline.', cta: { label: 'Explore Logs in Sim', href: '/signup' }, diff --git a/apps/sim/app/(landing)/page.tsx b/apps/sim/app/(landing)/page.tsx index 27bd43bbad6..b6ac4c57574 100644 --- a/apps/sim/app/(landing)/page.tsx +++ b/apps/sim/app/(landing)/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next' import { SITE_URL } from '@/lib/core/utils/urls' -import { HOME_PAGE_DESCRIPTION } from '@/app/(landing)/components/home-structured-data' +import { + HOME_PAGE_DESCRIPTION, + HOME_PAGE_TITLE, +} from '@/app/(landing)/components/home-structured-data' import Landing from '@/app/(landing)/landing' export const revalidate = 3600 @@ -8,7 +11,7 @@ export const revalidate = 3600 export const metadata: Metadata = { metadataBase: new URL(SITE_URL), title: { - absolute: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + absolute: HOME_PAGE_TITLE, }, description: HOME_PAGE_DESCRIPTION, keywords: @@ -22,7 +25,7 @@ export const metadata: Metadata = { telephone: false, }, openGraph: { - title: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + title: HOME_PAGE_TITLE, description: HOME_PAGE_DESCRIPTION, type: 'website', url: SITE_URL, @@ -42,7 +45,7 @@ export const metadata: Metadata = { card: 'summary_large_image', site: '@simdotai', creator: '@simdotai', - title: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + title: HOME_PAGE_TITLE, description: HOME_PAGE_DESCRIPTION, images: { url: '/logo/426-240/reverse/small.png', diff --git a/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx index 91a019b78b3..464d2313b8d 100644 --- a/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx @@ -56,7 +56,7 @@ const SCHEDULED_TASKS_CONFIG: SolutionsPageConfig = { rows: [ { id: 'schedule', - title: 'Put any agent on a schedule.', + title: 'Put any AI agent on a schedule.', subtitle: 'Sim turns a workflow into a scheduled task with one trigger, from 15-minute intervals to monthly closes, in your timezone.', cta: { label: 'Schedule your first agent', href: '/signup' }, diff --git a/apps/sim/app/(landing)/tables/tables.tsx b/apps/sim/app/(landing)/tables/tables.tsx index 8921d827660..5f16e7420a2 100644 --- a/apps/sim/app/(landing)/tables/tables.tsx +++ b/apps/sim/app/(landing)/tables/tables.tsx @@ -56,7 +56,7 @@ const TABLES_CONFIG: SolutionsPageConfig = { rows: [ { id: 'records', - title: 'Give agents structured data to act on.', + title: 'Give AI agents structured data to act on.', subtitle: 'Sim stores the leads, tickets, and invoices agents work with as tables in the same workspace as the agents.', cta: { label: 'Explore Tables', href: '/signup' }, @@ -85,7 +85,7 @@ const TABLES_CONFIG: SolutionsPageConfig = { }, { id: 'memory', - title: 'Tables are your agents’ memory.', + title: 'Tables are your AI agents’ memory.', subtitle: 'Sim carries state between runs. Every run writes rows, every change is recorded, and agents pick up exactly where they left off.', cta: { label: 'See how agents use Tables', href: '/signup' }, diff --git a/apps/sim/app/(landing)/workflows/workflows.tsx b/apps/sim/app/(landing)/workflows/workflows.tsx index 8edf9c34431..862082c26c1 100644 --- a/apps/sim/app/(landing)/workflows/workflows.tsx +++ b/apps/sim/app/(landing)/workflows/workflows.tsx @@ -58,7 +58,7 @@ const WORKFLOWS_CONFIG: SolutionsPageConfig = { rows: [ { id: 'build', - title: 'Build agents the way that fits.', + title: 'Build AI agents the way that fits.', subtitle: 'Sim lets teams build agents visually, in natural language, or with code, wiring up any model and 1,000+ integrations in one workspace.', cta: { label: 'Explore the workflow builder', href: '/signup' }, From 78fb2c0679d16e0c6f6a51a71f01a5ecd1849d92 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 10:47:07 -0700 Subject: [PATCH 04/14] chore(deps): bump next to 16.2.11 to clear security advisories (#5890) * chore(deps): bump next to 16.2.11 to clear security advisories Patches SSRF, cache confusion, DoS, and middleware-bypass advisories (GHSA-89xv-2m56-2m9x et al.) affecting next < 16.2.11 across apps/sim, apps/docs, and packages/emcn. Excludes next/@next/env from the minimum-release-age gate until the 7-day window elapses on 2026-07-28. * chore(deps): drop aged-out typescript entries from release-age excludes typescript and @typescript/typescript6 passed the 7-day minimum-release-age gate (aged out 2026-07-15 and 2026-07-13), so their exclusions are no longer needed. Keeps @typescript/native-preview (permanent nightly builds) and the Pi packages (age out 2026-07-24). --- apps/docs/package.json | 2 +- apps/sim/package.json | 6 +++--- bun.lock | 30 +++++++----------------------- bunfig.toml | 10 ++++++---- package.json | 4 ++-- packages/emcn/package.json | 2 +- 6 files changed, 20 insertions(+), 34 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 41a7490421c..9fde4625a2a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -30,7 +30,7 @@ "fumadocs-openapi": "10.8.1", "fumadocs-ui": "16.8.5", "lucide-react": "^0.511.0", - "next": "16.2.6", + "next": "16.2.11", "next-themes": "^0.4.6", "postgres": "^3.4.5", "react": "19.2.4", diff --git a/apps/sim/package.json b/apps/sim/package.json index 9b2b9b1413b..1b9c251ff56 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -178,7 +178,7 @@ "mongodb": "6.19.0", "mysql2": "3.14.3", "neo4j-driver": "6.0.1", - "next": "16.2.6", + "next": "16.2.11", "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", @@ -264,8 +264,8 @@ "sharp" ], "overrides": { - "next": "16.2.6", - "@next/env": "16.2.6", + "next": "16.2.11", + "@next/env": "16.2.11", "mermaid": "11.15.0", "react-floater": { "react": "$react", diff --git a/bun.lock b/bun.lock index 9d76c5e4a52..dcce09861c5 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ "fumadocs-openapi": "10.8.1", "fumadocs-ui": "16.8.5", "lucide-react": "^0.511.0", - "next": "16.2.6", + "next": "16.2.11", "next-themes": "^0.4.6", "postgres": "^3.4.5", "react": "19.2.4", @@ -246,7 +246,7 @@ "mongodb": "6.19.0", "mysql2": "3.14.3", "neo4j-driver": "6.0.1", - "next": "16.2.6", + "next": "16.2.11", "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", @@ -408,7 +408,7 @@ "framer-motion": "^12.5.0", "input-otp": "^1.4.2", "lucide-react": "^0.479.0", - "next": "16.2.6", + "next": "16.2.11", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -593,11 +593,11 @@ "sharp", ], "overrides": { - "@next/env": "16.2.6", + "@next/env": "16.2.11", "drizzle-orm": "^0.45.2", "mermaid": "11.15.0", "minimatch": "^10.2.5", - "next": "16.2.6", + "next": "16.2.11", "postgres": "^3.4.5", "react": "19.2.4", "react-dom": "19.2.4", @@ -1215,23 +1215,7 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], - - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], - - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], - - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], - - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], - - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], - - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], - - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], - - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -3309,7 +3293,7 @@ "neo4j-driver-core": ["neo4j-driver-core@6.0.1", "", {}, "sha512-5I2KxICAvcHxnWdJyDqwu8PBAQvWVTlQH2ve3VQmtVdJScPqWhpXN1PiX5IIl+cRF3pFpz9GQF53B5n6s0QQUQ=="], - "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="], "next-mdx-remote": ["next-mdx-remote@6.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^4.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ=="], diff --git a/bunfig.toml b/bunfig.toml index 6145009f007..dbb4f93a7fa 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,18 +5,20 @@ exact = true minimumReleaseAge = 604800 # @typescript/native-preview stays excluded permanently: it only publishes nightly # dev builds, so every version is structurally younger than any age gate. -# typescript@7.0.2 and @typescript/typescript6@6.0.2 were vetted in #5521; they age -# out of the gate on 2026-07-15 and 2026-07-13 — drop those two entries after that. # The exactly pinned Pi 0.80.10 packages were vetted for the cloud-review SDK # migration; they age out of the gate on 2026-07-24 — drop these four entries then. +# next@16.2.11 and @next/env@16.2.11 are the official Vercel security patch for the +# July 2026 advisories (SSRF, cache confusion, DoS, middleware bypass — GHSA-89xv-2m56-2m9x +# et al.); published 2026-07-21, they age out of the gate on 2026-07-28 — drop these two +# entries then. minimumReleaseAgeExcludes = [ - "typescript", - "@typescript/typescript6", "@typescript/native-preview", "@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", + "next", + "@next/env", ] [run] diff --git a/package.json b/package.json index f14e0a506ea..ea7fafc1fd4 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "overrides": { "react": "19.2.4", "react-dom": "19.2.4", - "next": "16.2.6", - "@next/env": "16.2.6", + "next": "16.2.11", + "@next/env": "16.2.11", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", diff --git a/packages/emcn/package.json b/packages/emcn/package.json index 7aaa3d4c467..c0a48f0da71 100644 --- a/packages/emcn/package.json +++ b/packages/emcn/package.json @@ -57,7 +57,7 @@ "framer-motion": "^12.5.0", "input-otp": "^1.4.2", "lucide-react": "^0.479.0", - "next": "16.2.6", + "next": "16.2.11", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", From 44749b85b3338e21c50c93f039f7925558be9c9a Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 11:10:23 -0700 Subject: [PATCH 05/14] improvement(credentials): actionable Shopify admin-token rejection message (#5891) Shopify custom-app token verification faithfully surfaces a real 401 from Shopify, but the generic 'double-check it in Shopify' copy didn't tell users what to check. The #1 real cause is pasting the wrong secret (API key / API secret key) instead of the shpat_ Admin API access token, or using a token bound to a different store. - Add an optional per-provider invalidCredentialsHelp override on the token service-account descriptor; set it for Shopify to name the exact fix. - Move the error-code to message mapping out of the shared connect modal into the descriptor module (getTokenServiceAccountErrorMessage) so provider copy is inherited from the definition rather than hard-coded in the modal. - Add unit tests for the mapper (override, fallback, all codes). --- .../token-service-account-modal.tsx | 37 ++---------- .../descriptors.test.ts | 60 +++++++++++++++++++ .../token-service-accounts/descriptors.ts | 42 ++++++++++++- 3 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx index ae2bdc00368..428644b62f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx @@ -12,9 +12,10 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import { isApiClientError } from '@/lib/api/client/errors' -import type { - TokenServiceAccountDescriptor, - TokenServiceAccountField, +import { + getTokenServiceAccountErrorMessage, + type TokenServiceAccountDescriptor, + type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' import { useCreateWorkspaceCredential, @@ -23,33 +24,6 @@ import { const logger = createLogger('TokenServiceAccountModal') -const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment." - -/** - * Maps server `error.code` values from token service-account verification to - * user-facing messages, personalized with the provider's own token noun. - */ -function messageForTokenAccountError( - err: unknown, - descriptor: TokenServiceAccountDescriptor -): string { - if (isApiClientError(err) && err.code) { - switch (err.code) { - case 'invalid_credentials': - return `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.` - case 'site_not_found': - return "We couldn't find an account at that domain. Check the spelling and try again." - case 'provider_unavailable': - return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.` - case 'duplicate_display_name': - return 'A credential with that name already exists in this workspace.' - default: - return FALLBACK_ERROR_MESSAGE - } - } - return FALLBACK_ERROR_MESSAGE -} - function normalizeDomainInput(raw: string): string { return raw .trim() @@ -154,7 +128,8 @@ export function TokenServiceAccountModal({ } onOpenChange(false) } catch (err: unknown) { - setError(messageForTokenAccountError(err, descriptor)) + const code = isApiClientError(err) ? err.code : undefined + setError(getTokenServiceAccountErrorMessage(descriptor, code)) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) } } diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts new file mode 100644 index 00000000000..be6cc7ad757 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getTokenServiceAccountDescriptor, + getTokenServiceAccountErrorMessage, + HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, + SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, + type TokenServiceAccountDescriptor, +} from '@/lib/credentials/token-service-accounts/descriptors' + +function descriptorFor(providerId: string): TokenServiceAccountDescriptor { + const descriptor = getTokenServiceAccountDescriptor(providerId) + if (!descriptor) throw new Error(`missing descriptor for ${providerId}`) + return descriptor +} + +describe('getTokenServiceAccountErrorMessage', () => { + const shopify = descriptorFor(SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID) + const hubspot = descriptorFor(HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID) + + it('uses the provider-specific invalidCredentialsHelp override when present', () => { + expect(shopify.invalidCredentialsHelp).toBeDefined() + expect(getTokenServiceAccountErrorMessage(shopify, 'invalid_credentials')).toBe( + shopify.invalidCredentialsHelp + ) + }) + + it('falls back to the generic token-noun message when no override is set', () => { + expect(hubspot.invalidCredentialsHelp).toBeUndefined() + expect(getTokenServiceAccountErrorMessage(hubspot, 'invalid_credentials')).toBe( + `We couldn't authenticate with that ${hubspot.tokenNoun}. Double-check it in ${hubspot.serviceLabel} and try again.` + ) + }) + + it('maps site_not_found to the domain hint', () => { + expect(getTokenServiceAccountErrorMessage(shopify, 'site_not_found')).toBe( + "We couldn't find an account at that domain. Check the spelling and try again." + ) + }) + + it('maps provider_unavailable to a service-labeled retry message', () => { + expect(getTokenServiceAccountErrorMessage(hubspot, 'provider_unavailable')).toBe( + `We couldn't reach ${hubspot.serviceLabel} to verify these credentials. Try again in a moment.` + ) + }) + + it('maps duplicate_display_name to the name-collision message', () => { + expect(getTokenServiceAccountErrorMessage(shopify, 'duplicate_display_name')).toBe( + 'A credential with that name already exists in this workspace.' + ) + }) + + it('falls back to a generic message for an unknown or absent code', () => { + const fallback = "We couldn't add this credential. Try again in a moment." + expect(getTokenServiceAccountErrorMessage(shopify, 'something_else')).toBe(fallback) + expect(getTokenServiceAccountErrorMessage(shopify, undefined)).toBe(fallback) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index e60338c3b71..9b606e0fd4f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -6,8 +6,9 @@ * API key, …) instead of running an OAuth flow — mirroring the Atlassian * service-account pattern: the token is verified once server-side, encrypted, * and returned as the access token at execution time with no exchange or - * refresh. This module holds only UI/contract metadata (field lists, labels, - * docs links); server-side verification lives in + * refresh. This module holds the client-safe UI/contract metadata (field + * lists, labels, docs links) plus pure derivations over it (required-field + * lookups, connect-modal error copy); server-side verification lives in * `@/lib/credentials/token-service-accounts/server`. */ @@ -46,6 +47,13 @@ export interface TokenServiceAccountDescriptor { docsUrl: string /** Optional one-line caveat rendered under the token field. */ helpText?: string + /** + * Optional provider-specific message that replaces the generic + * `invalid_credentials` rejection copy. Use it to name the exact + * credential-paste mistake most users make (e.g. copying the API secret key + * instead of the Admin API access token) rather than a vague "double-check". + */ + invalidCredentialsHelp?: string /** * HTTP auth scheme the pasted token requires at execution time. Defaults to * `bearer` (`Authorization: Bearer `); `x-api-token` providers (e.g. @@ -263,6 +271,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account', helpText: 'Legacy admin-created custom apps reveal the shpat_ token once; new Dev Dashboard apps issue tokens via OAuth, not a UI reveal. The token is store-bound and does not expire.', + invalidCredentialsHelp: + 'Shopify rejected this token. Make sure you copied the Admin API access token (starts with shpat_) — not the API key or API secret key — for an app installed on this exact store domain, and that it has not since been revoked or regenerated.', }, [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, @@ -398,3 +408,31 @@ export function getTokenServiceAccountDescriptor( ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[providerId] : undefined } + +/** + * Maps a credential-verification `error.code` to a user-facing message for a + * given provider. Provider-specific copy is inherited from the descriptor + * (token noun, service label, and the optional `invalidCredentialsHelp` + * override) rather than hard-coded in the shared connect modal. An + * unknown/absent code falls back to a generic retry message. + */ +export function getTokenServiceAccountErrorMessage( + descriptor: TokenServiceAccountDescriptor, + code: string | undefined +): string { + switch (code) { + case 'invalid_credentials': + return ( + descriptor.invalidCredentialsHelp ?? + `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.` + ) + case 'site_not_found': + return "We couldn't find an account at that domain. Check the spelling and try again." + case 'provider_unavailable': + return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.` + case 'duplicate_display_name': + return 'A credential with that name already exists in this workspace.' + default: + return "We couldn't add this credential. Try again in a moment." + } +} From efe1de315eb592bcb2ced4fc84c18eac65e87b68 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 11:18:38 -0700 Subject: [PATCH 06/14] fix(tables): sniff CSV delimiter and capture the real header row on import (#5888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tables): sniff CSV delimiter and capture the real header row on import Table CSV import derived the delimiter purely from the file extension, so semicolon-delimited exports (European-locale Excel) parsed as a single column. Header derivation also read Object.keys(rows[0]), so with relax_column_count a ragged/sparse first data row dropped its trailing columns from schema inference. - Add detectCsvDelimiter: trial-parse the head against ,/;/tab/| and pick the candidate with the most columns (tie-break on row-width consistency), quote-aware so separators inside quoted cells don't skew the guess; extension is now only the fallback - Add sniffCsvDelimiterFromStream: memory-bounded (64KB) peek-then-replay for the streaming import paths so multi-GB files sniff without buffering - Capture the true header row via the csv-parse columns callback on every path, fixing dropped columns when the first row is short - Wire into the create route, append/replace route, background runner, client dialog preview, and parseFileRows (copilot) * fix(tables): rank CSV delimiter by row consistency and bound the peek buffer Addresses review findings on the delimiter sniffer: - Rank candidates by row-width consistency first (modal column count), then column count. Ranking on column count alone let a comma that appears in a semicolon file's unquoted header win over the real separator; the wide-but-ragged split now loses to the uniform one. - Move the partial-trailing-line trim into detectCsvDelimiter so every path (stream, client preview, parseFileRows) prepares the sniff sample identically and can't disagree on the delimiter. - Cap the stream peeker's detection copy at the sniff window via Buffer.concat's length arg and replay the buffered chunks by reference, so an oversized source chunk can't break the bounded-memory contract. * fix(tables): score CSV delimiter by width*consistency and dedupe headers Addresses round-2 review findings: - Score each delimiter candidate by modalWidth * consistency instead of consistency alone. Consistency-only let a separator that appears uniformly inside values (e.g. a pipe once per row) beat a real delimiter whose rows are legitimately ragged; the product rewards a split that is both wide and uniform, with width breaking ties. Genuinely symmetric files (two valid columns under either separator) fall back to the global-default candidate order. - Dedupe the captured header row to match the parser's record keys. csv-parse collapses duplicate column names onto one key (last value wins), so reporting the raw duplicates made schema inference invent a phantom empty column and could fail mapping validation. dedupeHeaders keeps first-occurrence order, case-sensitively, matching the emitted keys. * fix(tables): keep the final row when sniffing a complete CSV detectCsvDelimiter always trimmed to the last newline, so a complete file with no trailing newline lost its final data row from scoring — leaving the header alone and letting a header-widening separator beat the real one. It now takes a `complete` flag: the trim runs only for truncated prefixes, where a mid-record cut must be dropped. The stream sniffer passes it from `exhausted`, and the buffered callers (client preview, parseFileRows) pass it when the sample covers the whole file. * fix(tables): keep the double-cast annotation adjacent to its cast Hoist the csv-parse options out of the multi-line parse() call so the `// double-cast-allowed` annotation sits directly above the `as unknown as` token — the strict boundary audit only matches an annotation within three lines of the cast. Also simplify the stream sniffer's completeness check to `exhausted` (the loop only ends early on end-of-stream, so it already means the whole file fit the sniff window). * fix(tables): observe EOF at the exact sniff-window boundary The stream sniffer's read loop stopped as soon as the buffered size reached the sniff window, so a file whose size is exactly the window never triggered the extra read that observes end-of-stream — it was judged a truncated prefix and dropped its final newline-less row, disagreeing with the buffered callers (which mark that size complete). Read while size <= window so the boundary case sees EOF and `exhausted` (hence `complete`) is set correctly. Regression test added. * test(tables): use sleep helper instead of raw setTimeout promise --- .../app/api/table/[tableId]/import/route.ts | 18 +- apps/sim/app/api/table/import-csv/route.ts | 19 +- .../import-csv-dialog/import-csv-dialog.tsx | 27 ++- apps/sim/lib/table/csv-delimiter-stream.ts | 80 +++++++ apps/sim/lib/table/import-runner.ts | 17 +- apps/sim/lib/table/import.test.ts | 226 +++++++++++++++++- apps/sim/lib/table/import.ts | 186 ++++++++++++-- 7 files changed, 533 insertions(+), 40 deletions(-) create mode 100644 apps/sim/lib/table/csv-delimiter-stream.ts diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 63ed87220fb..1d6482390fd 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -36,6 +36,7 @@ import { validateMapping, wouldExceedRowLimit, } from '@/lib/table' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' import { getUserSettings } from '@/lib/users/queries' import { @@ -176,11 +177,19 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro timezone = timezoneValidation.data } - const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' - const parser = createCsvParser(delimiter) + // The extension only picks the fallback — the separator is sniffed from the file's + // head so semicolon/pipe exports (European-locale Excel) don't land in one column. + const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( + file.stream, + extensionValidation.data === 'tsv' ? '\t' : ',' + ) + let headers: string[] = [] + const parser = createCsvParser(delimiter, (parsedHeaders) => { + headers = parsedHeaders + }) // `.pipe` doesn't forward source errors; forward them so the iterator throws. - file.stream.on('error', (streamErr) => parser.destroy(streamErr)) - file.stream.pipe(parser) + csvStream.on('error', (streamErr) => parser.destroy(streamErr)) + csvStream.pipe(parser) const rows: Record[] = [] for await (const record of parser as AsyncIterable>) { rows.push(record) @@ -188,7 +197,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (rows.length === 0) { return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) } - const headers = Object.keys(rows[0]) let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) let prospectiveTable: TableDefinition = table diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index fb29cffab8f..2c0a3cf6614 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -26,6 +26,7 @@ import { type TableDefinition, type TableSchema, } from '@/lib/table' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { @@ -107,12 +108,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const delimiter = extensionResult.data === 'tsv' ? '\t' : ',' + // The extension only picks the fallback — the separator is sniffed from the file's + // head so semicolon/pipe exports (European-locale Excel) don't land in one column. + const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( + file.stream, + extensionResult.data === 'tsv' ? '\t' : ',' + ) - const parser = createCsvParser(delimiter) + let csvHeaders: string[] = [] + const parser = createCsvParser(delimiter, (headers) => { + csvHeaders = headers + }) // `.pipe` doesn't forward source errors; forward them so the iterator throws. - file.stream.on('error', (err) => parser.destroy(err)) - file.stream.pipe(parser) + csvStream.on('error', (err) => parser.destroy(err)) + csvStream.pipe(parser) interface ImportState { table: TableDefinition @@ -139,7 +148,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { /** Infer the schema from the buffered sample and create the (empty) table. */ const buildTable = async (sampleRows: Record[]): Promise => { - const inferred = inferSchemaFromCsv(Object.keys(sampleRows[0]), sampleRows) + const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } const planLimits = await getWorkspaceTableLimits(workspaceId) const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index 2eee7a1c4ee..6ea241ab22f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -25,7 +25,13 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants' -import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import' +import { + buildAutoMapping, + CSV_DELIMITER_SNIFF_BYTES, + type CsvDelimiter, + detectCsvDelimiter, + parseCsvBuffer, +} from '@/lib/table/import' import type { TableDefinition } from '@/lib/table/types' import { type CsvImportMode, @@ -107,8 +113,13 @@ interface ParsedCsv { sampleRows: Record[] } -/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */ -async function parseCsvPreview(file: File, delimiter: ',' | '\t') { +/** + * Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. + * + * The separator is sniffed from the same leading bytes the server sniffs, so the mapping shown + * here always matches the columns the import will actually produce. + */ +async function parseCsvPreview(file: File, fallbackDelimiter: CsvDelimiter) { const sliced = file.size > CSV_PREVIEW_BYTES const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file let bytes = new Uint8Array(await blob.arrayBuffer()) @@ -116,6 +127,13 @@ async function parseCsvPreview(file: File, delimiter: ',' | '\t') { const lastNewline = bytes.lastIndexOf(0x0a) if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1) } + // The sniff sample is the whole file only when nothing was sliced off and it fits the window; + // otherwise it's a truncated prefix whose last line may be partial. + const delimiter = await detectCsvDelimiter( + bytes.subarray(0, CSV_DELIMITER_SNIFF_BYTES), + fallbackDelimiter, + { complete: !sliced && bytes.length <= CSV_DELIMITER_SNIFF_BYTES } + ) return parseCsvBuffer(bytes, delimiter) } @@ -180,8 +198,7 @@ export function ImportCsvDialog({ setParsing(true) setParseError(null) try { - const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvPreview(file, delimiter) + const { headers, rows } = await parseCsvPreview(file, ext === 'tsv' ? '\t' : ',') const autoMapping = buildAutoMapping(headers, table.schema) setParsed({ file, diff --git a/apps/sim/lib/table/csv-delimiter-stream.ts b/apps/sim/lib/table/csv-delimiter-stream.ts new file mode 100644 index 00000000000..1c4c5c2eb70 --- /dev/null +++ b/apps/sim/lib/table/csv-delimiter-stream.ts @@ -0,0 +1,80 @@ +import { Readable } from 'node:stream' +import { + CSV_DELIMITER_SNIFF_BYTES, + type CsvDelimiter, + detectCsvDelimiter, +} from '@/lib/table/import' + +export interface SniffedCsvStream { + delimiter: CsvDelimiter + /** + * The full file contents, replayed from byte zero. Use this in place of the + * source stream — the source has already been partially consumed. + */ + stream: Readable +} + +/** + * Reads the head of a CSV/TSV stream, sniffs its field separator, then returns a + * stream that replays the buffered head followed by the untouched remainder. + * + * Memory is bounded: it buffers only the source chunks needed to reach the + * {@link CSV_DELIMITER_SNIFF_BYTES} window (one chunk past it, at worst), and the + * detection sample it copies is capped at exactly that window regardless of how + * large a single upstream chunk is. Those buffered chunks are then replayed + * *by reference* — never re-copied — so a multi-GB import stays O(sniff window) + * plus the single in-flight chunk. {@link detectCsvDelimiter} drops any partial + * trailing line, so no newline trimming is needed here. + */ +export async function sniffCsvDelimiterFromStream( + source: Readable, + fallback: CsvDelimiter = ',' +): Promise { + const reader = source[Symbol.asyncIterator]() + const chunks: Buffer[] = [] + let size = 0 + let exhausted = false + + // Read until the buffered size *exceeds* the window (not just reaches it) or the stream ends. + // The `<=` is deliberate: a file whose size is exactly the window must still trigger one more + // read so EOF is observed and `exhausted` becomes true — otherwise it would be misjudged as a + // truncated prefix and disagree with the buffered callers (which pass complete for that size). + while (size <= CSV_DELIMITER_SNIFF_BYTES) { + const next = await reader.next() + if (next.done) { + exhausted = true + break + } + const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value as Uint8Array) + chunks.push(chunk) + size += chunk.length + } + + // Copy at most the sniff window for detection — `Buffer.concat`'s length arg truncates, + // so an oversized final chunk can't inflate this allocation past CSV_DELIMITER_SNIFF_BYTES. + const sample = Buffer.concat(chunks, Math.min(size, CSV_DELIMITER_SNIFF_BYTES)) + // `exhausted` (the loop stopped on end-of-stream, never on exceeding the window) means the whole + // file fit in the window, so its last row must count even without a trailing newline. Otherwise + // the sample is a truncated prefix whose final line may be partial and should be dropped. + const delimiter = await detectCsvDelimiter(sample, fallback, { complete: exhausted }) + + const stream = Readable.from( + (async function* replay() { + // Replay the already-read chunks by reference (no re-copy), then drain the rest. + for (const chunk of chunks) yield chunk + if (exhausted) return + while (true) { + const next = await reader.next() + if (next.done) return + yield next.value + } + })() + ) + + // `Readable.from` closes the generator on destroy, which returns the source + // iterator — but an early destroy of the wrapper before the generator is + // pulled would otherwise leak the source's socket. + stream.on('close', () => source.destroy()) + + return { delimiter, stream } +} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 64694f97bf3..85f85875577 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -19,6 +19,7 @@ import { } from '@/lib/table' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { withGeneratedColumnIds } from '@/lib/table/column-keys' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { appendTableEvent } from '@/lib/table/events' import { addImportColumns, @@ -103,6 +104,11 @@ export async function runTableImport(payload: TableImportPayload): Promise // Stream the file rather than buffering it — a ~1M-row import must never be held in memory. source = await downloadFileStream({ key: fileKey, context: 'workspace' }) + // The kickoff route's extension-derived delimiter is only the fallback — the separator is + // sniffed from the file's head so semicolon/pipe exports don't collapse into one column. + const sniffed = await sniffCsvDelimiterFromStream(source, delimiter) + const csvStream = sniffed.stream + // Append must continue after the existing rows; create/replace start empty. Read once up // front (the import is the table's sole writer) and assign contiguous positions / threaded // order keys from it. @@ -123,11 +129,14 @@ export async function runTableImport(payload: TableImportPayload): Promise }, }) - const parser = createCsvParser(delimiter) + let csvHeaders: string[] = [] + const parser = createCsvParser(sniffed.delimiter, (headers) => { + csvHeaders = headers + }) // `.pipe` doesn't forward source errors; forward so the iterator throws. - source.on('error', (err) => parser.destroy(err)) + csvStream.on('error', (err) => parser.destroy(err)) byteCounter.on('error', (err) => parser.destroy(err)) - source.pipe(byteCounter).pipe(parser) + csvStream.pipe(byteCounter).pipe(parser) let schema: TableSchema | null = null let headerToColumn: Map | null = null @@ -142,7 +151,7 @@ export async function runTableImport(payload: TableImportPayload): Promise * map onto the existing schema, optionally auto-creating `createColumns` first. */ const resolveSetup = async () => { - const headers = Object.keys(sample[0]) + const headers = csvHeaders if (mode === 'create') { const inferred = inferSchemaFromCsv(headers, sample) diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index d4997a123fe..91baff2b94e 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -2,14 +2,19 @@ * @vitest-environment node */ import { Readable } from 'node:stream' +import { sleep } from '@sim/utils/helpers' import { describe, expect, it } from 'vitest' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { buildAutoMapping, + CSV_DELIMITER_SNIFF_BYTES, CsvImportValidationError, coerceRowsForTable, coerceValue, createCsvParser, csvParseOptions, + dedupeHeaders, + detectCsvDelimiter, inferColumnType, inferSchemaFromCsv, parseCsvBuffer, @@ -316,8 +321,225 @@ describe('import', () => { }) describe('csvParseOptions', () => { - it('sets columns, bom, and the delimiter', () => { - expect(csvParseOptions('\t')).toMatchObject({ columns: true, bom: true, delimiter: '\t' }) + it('sets bom and the delimiter, with a header-capturing columns callback', () => { + const options = csvParseOptions('\t') + expect(options).toMatchObject({ bom: true, delimiter: '\t' }) + expect(typeof options.columns).toBe('function') + }) + + it('invokes onHeaders with the full header row', () => { + let captured: string[] | null = null + const options = csvParseOptions(',', (h) => { + captured = h + }) + // csv-parse calls the columns function with the parsed header array. + ;(options.columns as (h: string[]) => string[])(['a', 'b', 'c']) + expect(captured).toEqual(['a', 'b', 'c']) + }) + }) + + describe('parseCsvBuffer header derivation', () => { + it('reports every header even when the first data row is ragged (short)', async () => { + // With relax_column_count a short first row omits trailing keys, so deriving + // headers from Object.keys(rows[0]) would drop c and d. The header callback fixes this. + const { headers } = await parseCsvBuffer('a,b,c,d\n1,2\n3,4,5,6\n') + expect(headers).toEqual(['a', 'b', 'c', 'd']) + }) + + it('feeds the full header set into inferSchemaFromCsv for a ragged file', async () => { + const { headers, rows } = await parseCsvBuffer('a,b,c\n1\n2,3,4\n') + const { columns } = inferSchemaFromCsv(headers, rows) + expect(columns.map((c) => c.name)).toEqual(['a', 'b', 'c']) + }) + + it('collapses duplicate header names to match the parser record keys', async () => { + // csv-parse keys duplicate columns onto one object key (last value wins); the reported + // headers must match, so schema inference does not invent a phantom empty column. + const { headers, rows } = await parseCsvBuffer('a,a,b\n1,2,3\n4,5,6\n') + expect(headers).toEqual(['a', 'b']) + expect(Object.keys(rows[0])).toEqual(['a', 'b']) + const { columns } = inferSchemaFromCsv(headers, rows) + expect(columns.map((c) => c.name)).toEqual(['a', 'b']) + }) + }) + + describe('dedupeHeaders', () => { + it('drops later exact duplicates, preserving first-occurrence order', () => { + expect(dedupeHeaders(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']) + }) + + it('keeps case-distinct names (matches csv-parse key sensitivity)', () => { + expect(dedupeHeaders(['Name', 'name'])).toEqual(['Name', 'name']) + }) + }) + + describe('detectCsvDelimiter', () => { + it('detects a comma-delimited file', async () => { + expect(await detectCsvDelimiter('a,b,c\n1,2,3\n')).toBe(',') + }) + + it('detects a semicolon-delimited file (European Excel export)', async () => { + expect(await detectCsvDelimiter('a;b;c\n1;2;3\n')).toBe(';') + }) + + it('detects tab and pipe delimiters', async () => { + expect(await detectCsvDelimiter('a\tb\tc\n1\t2\t3\n')).toBe('\t') + expect(await detectCsvDelimiter('a|b|c\n1|2|3\n')).toBe('|') + }) + + it('ignores delimiters that appear only inside quoted fields', async () => { + // Semicolon-separated, but the values are full of commas and newlines — a raw + // character-frequency count would wrongly pick the comma. A real parse does not. + const csv = 'id;body\n1;"hello, world\nsecond line"\n2;"a, b, c"\n' + expect(await detectCsvDelimiter(csv)).toBe(';') + }) + + it('falls back for a single-column file rather than latching onto in-value characters', async () => { + expect(await detectCsvDelimiter('text\n"hello, world"\n"a, b"\n')).toBe(',') + expect(await detectCsvDelimiter('text\n"hello, world"\n', ';')).toBe(';') + }) + + it('prefers the consistent delimiter over one that only widens the header row', async () => { + // The header splits into more fields on comma, but only semicolon yields a uniform + // column count across the data rows — consistency must win over raw field count. + expect(await detectCsvDelimiter('a,b;c,d;e,f\n1;2;3\n4;5;6\n')).toBe(';') + }) + + it('detects the real delimiter when the header itself has quoted commas', async () => { + const csv = '"last, first";age;city\n"Doe, John";30;NYC\n"Roe, Jane";25;LA\n' + expect(await detectCsvDelimiter(csv)).toBe(';') + }) + + it('prefers a wider ragged split over a narrow one that only looks uniform', async () => { + // Semicolon is the real delimiter (2- and 3-column rows) while a pipe appears once per + // row inside values. Scoring width*consistency keeps the wider semicolon split. + expect(await detectCsvDelimiter('a|label;b;c\nx|y;1\nz|w;2;3\n')).toBe(';') + }) + + it('falls back to comma order only when the split is genuinely ambiguous', async () => { + // Both ; and , yield exactly two uniform columns — no content signal distinguishes them, + // so the global-default candidate order (comma first) decides and either reading is valid. + expect(await detectCsvDelimiter('name;value,unit\na;1,kg\nb;2,kg\n')).toBe(',') + }) + + it('drops a partial trailing line so a mid-record byte cut cannot skew detection', async () => { + // Simulates a fixed byte-window slice that ends mid-record; the last (partial) line + // is ignored, so the complete rows above still drive the result. + expect(await detectCsvDelimiter('a;b;c\n1;2;3\n4;5;')).toBe(';') + }) + + it('keeps the final row of a complete file that has no trailing newline', async () => { + // Without complete:true the trim would drop the only distinguishing data row, leaving the + // header alone and letting comma (which merely widens the header) win over semicolon. + const csv = 'a,b;c,d;e,f\n1;2;3' + expect(await detectCsvDelimiter(csv, ',', { complete: true })).toBe(';') + // A truncated prefix (complete:false, the default) still drops the partial tail. + expect(await detectCsvDelimiter(csv)).toBe(',') + }) + + it('returns the fallback for empty input', async () => { + expect(await detectCsvDelimiter('', ';')).toBe(';') + }) + }) + + describe('sniffCsvDelimiterFromStream', () => { + async function collect(stream: Readable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)) + } + return Buffer.concat(chunks) + } + + it('detects the delimiter and replays the full file byte-for-byte', async () => { + const rows = ['id;a;b;c'] + for (let i = 0; i < 5000; i++) rows.push(`${i};"val, with comma";x;y`) + const full = Buffer.from(`${rows.join('\n')}\n`) + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('handles a file smaller than the sniff window (exhausted during sniff)', async () => { + const full = Buffer.from('a;b;c\n1;2;3\n') + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('treats a file exactly the sniff-window size as complete (observes EOF at the boundary)', async () => { + // Header widens under comma; a single giant data row pads the file to exactly the window + // with no trailing newline. If the loop stopped at the boundary without seeing EOF, the row + // would be dropped and comma would win — so this passing proves the boundary EOF is observed. + const header = 'a,b;c,d;e,f\n' + const suffix = ';3' + const pad = 'x'.repeat( + CSV_DELIMITER_SNIFF_BYTES - header.length - '1;'.length - suffix.length + ) + const full = Buffer.from(`${header}1;${pad}${suffix}`) + expect(full.length).toBe(CSV_DELIMITER_SNIFF_BYTES) + const { delimiter } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + }) + + it('counts the final row of a fully-buffered file with no trailing newline', async () => { + // The whole file fits the sniff window (exhausted), so complete:true flows through and the + // last row is scored — semicolon must win despite comma widening the header row. + const full = Buffer.from('a,b;c,d;e,f\n1;2;3') + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('detects and replays exactly when a single chunk far exceeds the sniff window', async () => { + // One >1 MiB chunk arrives before the size check — detection must still cap its copy + // and the replay must emit the original bytes unchanged. + const rows = ['a;b;c'] + for (let i = 0; i < 40000; i++) rows.push(`${i};${'x'.repeat(20)};y`) + const full = Buffer.from(`${rows.join('\n')}\n`) + expect(full.length).toBeGreaterThan(1024 * 1024) + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('reassembles data split across many small chunks', async () => { + const parts = ['na', 'me,ag', 'e\nfo', 'o,1\nba', 'r,2\n'].map((s) => Buffer.from(s)) + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from(parts)) + expect(delimiter).toBe(',') + expect((await collect(stream)).equals(Buffer.concat(parts))).toBe(true) + }) + + it('destroys the source when the replay stream is destroyed early', async () => { + let destroyed = false + const pad = 'z'.repeat(290) + async function* infinite() { + let i = 0 + while (true) yield Buffer.from(`r${i++};x;${pad}\n`) + } + const source = Readable.from(infinite()) + source.on('close', () => { + destroyed = true + }) + const { stream } = await sniffCsvDelimiterFromStream(source) + stream.destroy() + await sleep(20) + expect(destroyed).toBe(true) + }) + + it('surfaces a source error that occurs during replay', async () => { + async function* failing() { + yield Buffer.from(`a;b;c\n${'1;2;3\n'.repeat(20000)}`) + throw new Error('storage read failed') + } + const { stream } = await sniffCsvDelimiterFromStream(Readable.from(failing())) + await expect(collect(stream)).rejects.toThrow(/storage read failed/) + }) + + it('returns the fallback for an empty stream', async () => { + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([]), ';') + expect(delimiter).toBe(';') + expect((await collect(stream)).length).toBe(0) }) }) }) diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 104186e32e1..94b4dc2272d 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -16,14 +16,44 @@ import { getColumnId } from '@/lib/table/column-keys' import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' +/** + * Field separators we sniff for, in tie-break priority order. Semicolon files are + * the standard CSV export of European-locale Excel; pipe shows up in log exports. + */ +export const CSV_DELIMITER_CANDIDATES = [',', ';', '\t', '|'] as const + +export type CsvDelimiter = (typeof CSV_DELIMITER_CANDIDATES)[number] + +/** + * Bytes inspected when sniffing the delimiter. Read from the head of the file on + * every path (client preview, streamed upload, background worker) so all of them + * observe the same prefix and therefore agree on the result. + */ +export const CSV_DELIMITER_SNIFF_BYTES = 64 * 1024 + /** * Single source of truth for the `csv-parse` options used by both the buffered - * sync parser and the streaming parser. `columns: true` emits each record as an - * object keyed by the (first-row) headers. + * sync parser and the streaming parser. + * + * `columns` is a function rather than `true` so the *actual header row* is + * captured. With `relax_column_count`, a record shorter than the header simply + * omits the trailing keys, so `Object.keys(records[0])` under-reports the schema + * whenever the first data row is ragged — the header callback is authoritative. */ -export function csvParseOptions(delimiter = ','): CsvParseOptions { +export function csvParseOptions( + delimiter = ',', + onHeaders?: (headers: string[]) => void +): CsvParseOptions { return { - columns: true, + columns: (header: string[]) => { + // Deliver headers deduped to match the record keys: csv-parse collapses duplicate + // column names into a single object key (last value wins), so the consumer's schema + // inference and mapping must see the same unique set — not the raw duplicates, which + // would invent phantom columns that never receive a value. csv-parse still keys on the + // raw array we return here, so returning it unchanged preserves that collapsing. + onHeaders?.(dedupeHeaders(header)) + return header + }, skip_empty_lines: true, trim: true, relax_column_count: true, @@ -40,9 +70,127 @@ export function csvParseOptions(delimiter = ','): CsvParseOptions { * file stream into it and iterate records with `for await`; backpressure flows * back to the source while each record is processed. Use this for HTTP uploads * so the file is never fully buffered in memory. + * + * `onHeaders` fires once, before the first record, with the full header row. + */ +export function createCsvParser(delimiter = ',', onHeaders?: (headers: string[]) => void): Parser { + return parseCsvStream(csvParseOptions(delimiter, onHeaders)) +} + +/** + * Drops later exact-duplicate header names, preserving first-occurrence order. Mirrors how + * `csv-parse` collapses duplicate column names into a single record key (last value wins), so + * the header set stays in lockstep with the object keys the parser actually emits. + */ +export function dedupeHeaders(headers: string[]): string[] { + const seen = new Set() + const unique: string[] = [] + for (const header of headers) { + if (seen.has(header)) continue + seen.add(header) + unique.push(header) + } + return unique +} + +/** Decodes CSV bytes as UTF-8, passing strings through unchanged. */ +export function decodeCsvText(input: Buffer | Uint8Array | string): string { + if (typeof input === 'string') return input + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) return input.toString('utf-8') + return new TextDecoder('utf-8').decode(input as Uint8Array) +} + +/** + * Sniffs the field separator by trial-parsing the sample with each candidate and + * keeping the one whose column count is most *consistent* across rows. + * + * A real parse (rather than counting raw characters) is what makes this safe on + * files whose quoted cells contain other candidates — `alarms.csv` exports a + * semicolon-separated file whose `raw_text` cells are full of commas and + * newlines, and a naive frequency count picks the comma. + * + * Each candidate is scored `modalWidth × consistency` — the modal (most common) + * row width times the fraction of rows at that width. This balances the two + * failure modes: ranking on width alone lets a delimiter that merely widens the + * header win (a comma splitting a semicolon file's unquoted header), while + * ranking on consistency alone lets a separator that appears uniformly *inside* + * values win over a real delimiter whose rows are legitimately ragged (a pipe + * embedded once per row beating a semicolon that yields 2- and 3-column rows). + * The product rewards a split that is both wide and uniform; ties break toward + * the wider split, then toward candidate order. Using the modal width (not the + * first row's) keeps one stray ragged row from distorting the score, and a + * single-column file (no candidate reaches two columns) falls back to `fallback`. + * + * Files that stay exactly tied on both score and width are genuinely ambiguous — + * e.g. `name;value,unit` splits into two columns under either `;` or `,` — so the + * global-default candidate order (comma first) decides, and both readings still + * produce a valid table. + * + * All callers funnel through here, so the sample is prepared identically for + * every import path: when the sample is a prefix sliced out of a larger file, a + * possibly-partial trailing line is dropped before parsing so a mid-record cut + * can't skew the counts. Pass `complete: true` when the sample is the entire file + * (nothing was sliced off) so a final row with no trailing newline still counts — + * dropping it would silently discard the only distinguishing data row of a tiny + * file and let a header-widening separator win. */ -export function createCsvParser(delimiter = ','): Parser { - return parseCsvStream(csvParseOptions(delimiter)) +export async function detectCsvDelimiter( + input: Buffer | Uint8Array | string, + fallback: CsvDelimiter = ',', + { complete = false }: { complete?: boolean } = {} +): Promise { + const { parse } = await import('csv-parse/sync') + const decoded = decodeCsvText(input) + // Drop a partial final line only when the sample is a truncated prefix; keep every row when + // the sample is the whole file (or a single line) so a trailing-newline-less last row counts. + const lastNewline = decoded.lastIndexOf('\n') + const text = !complete && lastNewline > 0 ? decoded.slice(0, lastNewline + 1) : decoded + if (text.trim() === '') return fallback + + let best: { delimiter: CsvDelimiter; fields: number; score: number } | null = null + + for (const delimiter of CSV_DELIMITER_CANDIDATES) { + let records: string[][] + try { + records = parse(text, { + columns: false, + skip_empty_lines: true, + relax_column_count: true, + relax_quotes: true, + skip_records_with_error: true, + bom: true, + delimiter, + }) as string[][] + } catch { + continue + } + + if (records.length === 0) continue + + // Modal row width: the most frequent column count, tie-broken toward the wider one. + const widthCounts = new Map() + for (const record of records) { + widthCounts.set(record.length, (widthCounts.get(record.length) ?? 0) + 1) + } + let fields = 0 + let modalFreq = 0 + for (const [width, freq] of widthCounts) { + if (freq > modalFreq || (freq === modalFreq && width > fields)) { + fields = width + modalFreq = freq + } + } + if (fields < 2) continue + + // Reward a split that is both wide and uniform; ties break toward the wider split. + const score = fields * (modalFreq / records.length) + + if (!best || score > best.score || (score === best.score && fields > best.fields)) { + best = { delimiter, fields, score } + } + } + + return best?.delimiter ?? fallback } /** Narrower type than `COLUMN_TYPES` used internally for coercion. */ @@ -103,24 +251,20 @@ export async function parseCsvBuffer( ): Promise<{ headers: string[]; rows: Record[] }> { const { parse } = await import('csv-parse/sync') - let text: string - if (typeof input === 'string') { - text = input - } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { - text = input.toString('utf-8') - } else { - text = new TextDecoder('utf-8').decode(input as Uint8Array) - } + const text = decodeCsvText(input) - // double-cast-allowed: shared csvParseOptions() loses the `columns: true` literal that drives - // csv-parse's record-vs-string[][] overload, but `columns: true` is always set so records are objects - const parsed = parse(text, csvParseOptions(delimiter)) as unknown as Record[] + let headers: string[] = [] + const options = csvParseOptions(delimiter, (h) => { + headers = h + }) + // double-cast-allowed: shared csvParseOptions() loses the `columns` literal that drives + // csv-parse's record-vs-string[][] overload, but `columns` is always set so records are objects + const parsed = parse(text, options) as unknown as Record[] if (parsed.length === 0) { throw new Error('CSV file has no data rows') } - const headers = Object.keys(parsed[0]) if (headers.length === 0) { throw new Error('CSV file has no headers') } @@ -504,7 +648,11 @@ export async function parseFileRows( return parseJsonRows(buffer) } if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') { - const delimiter = ext === 'tsv' ? '\t' : ',' + const delimiter = await detectCsvDelimiter( + buffer.subarray(0, CSV_DELIMITER_SNIFF_BYTES), + ext === 'tsv' ? '\t' : ',', + { complete: buffer.length <= CSV_DELIMITER_SNIFF_BYTES } + ) return parseCsvBuffer(buffer, delimiter) } throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`) From 5adf68f3326174ebdaa46a5730f573c46758c90b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 11:51:19 -0700 Subject: [PATCH 07/14] feat(slack): native Sim app trigger mode via the preview-gated slack_v2 block (#5892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(slack): native Sim app trigger mode behind the preview-gated slack_v2 block Restore the native Sim Slack app mode for the slack_oauth trigger using a single-credential-picker design. The trigger is only reachable through the preview-gated slack_v2 block, so the mode inherits that gate — no separate env flag. - Re-add SIM_SUBSCRIBED_EVENTS / SLACK_SIM_EVENT_OPTIONS derived exports. - Merge the trigger credential picker (credentialKind: 'any') so it lists Sim OAuth accounts and reusable custom bots together, mirroring the slack_v2 block. - Event dropdown narrows to the Sim app's subscribed events when an OAuth account is selected, all events for a custom bot (resolved client-side from the warmed credential list). - Deploy branch discriminates by the RESOLVED credential, not a UI field: a bot credential routes by credential id (custom app); otherwise validate the event against SIM_SUBSCRIBED_EVENTS, resolve the credential owner's token, derive routingKey from Slack team_id (auth.test), and route on the shared Sim app. - The ingest endpoint and team_id fan-out routing already existed on staging. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz * fix(slack): address review — set credentialId, scope OAuth path to workspace, drop event narrowing Review round 1 (Greptile 4/5 + Cursor Bugbot): - deploy.ts: the native Sim-app (OAuth) branch now sets providerConfig.credentialId (runtime token resolution in the slack provider and credential-disconnect cleanup both key slack_app rows on it — without it, file downloads / reaction text fail and disconnect leaves the webhook active). - deploy.ts: resolve the OAuth credential through resolveTriggerCredentialId (workspace- and oauth-scoped) so a pasted foreign/other-tenant credential id can't bind to the workflow; use the resolved canonical id for token owner lookup + routing. - deploy.ts: a deleted/secretless custom bot credential (getSlackBotCredential → null but a service_account row exists) now returns the "reconnect the bot" error instead of the misleading "connected Slack account" message. - oauth.ts: drop the credential-based event-option narrowing. The shared dropdown framework doesn't revalidate a stored value when its dependency changes, so switching a custom bot → Sim account left an orphaned event that failed deploy with a 400. The event picker now offers all events; the deploy path is the authoritative gate. - tests: add broken-bot and workspace-not-resolvable cases; assert credentialId is set. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz --------- Co-authored-by: Claude Opus 4.8 (1M context) --- apps/sim/lib/webhooks/deploy.test.ts | 176 ++++++++++++++++++++++++++- apps/sim/lib/webhooks/deploy.ts | 167 ++++++++++++++++++++----- apps/sim/triggers/slack/oauth.ts | 34 ++++-- apps/sim/triggers/slack/shared.ts | 11 +- 4 files changed, 341 insertions(+), 47 deletions(-) diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 2ffbf360fe7..da0f6f4a4f0 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { credential } from '@sim/db/schema' -import { resetDbChainMock } from '@sim/testing' +import { account, credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { eq } from 'drizzle-orm' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -25,7 +25,33 @@ vi.mock('@/lib/webhooks/pending-verification', () => ({ PendingWebhookVerificationTracker: vi.fn(), })) -import { buildProviderConfig, resolveTriggerCredentialId } from '@/lib/webhooks/deploy' +const { + mockGetSlackBotCredential, + mockResolveOAuthAccountId, + mockRefreshAccessTokenIfNeeded, + mockFetchSlackTeamId, +} = vi.hoisted(() => ({ + mockGetSlackBotCredential: vi.fn(), + mockResolveOAuthAccountId: vi.fn(), + mockRefreshAccessTokenIfNeeded: vi.fn(), + mockFetchSlackTeamId: vi.fn(), +})) +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getSlackBotCredential: mockGetSlackBotCredential, + resolveOAuthAccountId: mockResolveOAuthAccountId, + refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, +})) +vi.mock('@/lib/webhooks/providers/slack', () => ({ + fetchSlackTeamId: mockFetchSlackTeamId, +})) + +import { + buildProviderConfig, + resolveTriggerCredentialId, + resolveWebhookConfigForBlock, +} from '@/lib/webhooks/deploy' +import { getBlock } from '@/blocks' +import { getTrigger } from '@/triggers' afterAll(resetDbChainMock) @@ -191,3 +217,145 @@ describe('resolveTriggerCredentialId', () => { expect(eq).toHaveBeenCalledWith(credential.accountId, 'credential-1') }) }) + +describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { + const slackTriggerDef = { + provider: 'slack_app', + name: 'Slack', + subBlocks: [ + { id: 'eventType', mode: 'trigger', required: true }, + { + id: 'customBotCredential', + mode: 'trigger', + canonicalParamId: 'botCredential', + serviceId: 'slack', + required: true, + }, + { + id: 'manualBotCredential', + mode: 'trigger-advanced', + canonicalParamId: 'botCredential', + required: true, + }, + ], + } + + function resolveSlack( + values: Record, + workflow: Record = { workspaceId: 'ws-1' } + ) { + ;(getBlock as unknown as Mock).mockReturnValue({ category: 'triggers' }) + ;(getTrigger as unknown as Mock).mockReturnValue(slackTriggerDef) + return resolveWebhookConfigForBlock({ + block: makeBlock('slack_oauth', values), + workflow, + userId: 'deployer-1', + requestId: 'req-1', + }) + } + + it('routes a custom bot credential by credential id on the slack provider', async () => { + mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'ws-1', botUserId: 'BUSER' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('slack') + expect(result.config.routingKey).toBe('cred_bot_1') + expect(result.config.triggerPath).toBeNull() + expect(result.config.providerConfig.bot_user_id).toBe('BUSER') + }) + + it('rejects a custom bot credential from another workspace', async () => { + mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'other-ws', botUserId: 'BUSER' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available in this workspace') + }) + + it('rejects a deleted or secretless custom bot credential as an invalid bot', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_x' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('bot credential is missing or invalid') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('rejects an OAuth credential not resolvable in the workflow workspace', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + // No credential row queued → resolveTriggerCredentialId returns null. + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_foreign' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available in this workspace') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('rejects a non-simSubscribed event on the native Sim app (OAuth account)', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + + const result = await resolveSlack({ + eventType: 'file_shared', + customBotCredential: 'cred_oauth_1', + }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available on the Sim Slack app') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('routes an OAuth account by team_id on the slack_app provider', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + queueTableRows(account, [{ userId: 'owner-1' }]) + mockRefreshAccessTokenIfNeeded.mockResolvedValue('xoxb-token') + mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T123', userId: 'UBOT' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('slack_app') + expect(result.config.routingKey).toBe('T123') + expect(result.config.triggerPath).toBeNull() + expect(result.config.providerConfig.bot_user_id).toBe('UBOT') + // Runtime token resolution + disconnect cleanup key slack_app rows on this. + expect(result.config.providerConfig.credentialId).toBe('cred_oauth_1') + // Owner's token, not the deploying actor's. + expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith('cred_oauth_1', 'owner-1', 'req-1') + }) + + it('fails when the connected Slack account token cannot be resolved', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: '' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + mockRefreshAccessTokenIfNeeded.mockResolvedValue(null) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('Could not access the connected Slack account') + expect(mockFetchSlackTeamId).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 1678159e2f7..7bb32655690 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, webhook, workflowDeploymentVersion } from '@sim/db/schema' +import { account, credential, webhook, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -15,6 +15,7 @@ import { projectDesiredWebhookProviderConfig, } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' +import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack' import { prepareStableWebhookRegistrations, type StableDesiredWebhookRegistration, @@ -26,12 +27,17 @@ import { isCanonicalPair, resolveActiveCanonicalValue, } from '@/lib/workflows/subblocks/visibility' -import { getSlackBotCredential } from '@/app/api/auth/oauth/utils' +import { + getSlackBotCredential, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' import { getTrigger, isTriggerValid } from '@/triggers' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' +import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared' const logger = createLogger('DeployWebhookSync') @@ -355,7 +361,12 @@ export async function resolveTriggerCredentialId( return resolvedCredential?.id ?? null } -async function resolveWebhookConfigForBlock(input: { +/** + * Resolves a trigger block to its persisted webhook config, including the + * Slack-specific routing branch. Exported for unit testing that branch; not part + * of the public deploy API. + */ +export async function resolveWebhookConfigForBlock(input: { block: BlockState workflow: Record userId: string @@ -425,43 +436,135 @@ async function resolveWebhookConfigForBlock(input: { let effectivePath: string | null = triggerPath let routingKey: string | null = null if (triggerId === 'slack_oauth') { - // Custom-bot only: events route by the bot credential to one shared ingest - // URL verified with the bot's own signing secret. The native Sim-app path - // (team_id routing via the official app) is intentionally not supported yet. - const botCredentialId = + // One credential picker feeds two backends. The credential's resolved kind — + // not a UI field — picks the branch: a Slack bot credential routes by the + // credential id (custom bring-your-own app); an OAuth account routes by Slack + // team_id on the native shared Sim app. + const slackCredentialId = typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined - if (!botCredentialId) { + if (!slackCredentialId) { return { success: false, - error: { message: 'Select a Slack bot credential for the trigger.', status: 400 }, + error: { message: 'Select a Slack account or bot for the trigger.', status: 400 }, } } - const botCredential = await getSlackBotCredential(botCredentialId) - if (!botCredential) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', - status: 400, - }, + const botCredential = await getSlackBotCredential(slackCredentialId) + if (botCredential) { + // Custom bring-your-own bot: events route by the bot credential to one + // shared ingest URL verified with the bot's own signing secret. + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { + return { + success: false, + error: { + message: 'The selected Slack bot credential is not available in this workspace.', + status: 400, + }, + } } - } - const workflowWorkspace = - typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined - if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is not available in this workspace.', - status: 400, - }, + effectiveProvider = 'slack' + effectivePath = null + routingKey = slackCredentialId + providerConfig.credentialId = slackCredentialId + if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId + } else { + // getSlackBotCredential also returns null for a custom bot credential that + // was deleted or lost its stored secrets. Name that case so the error + // directs the user to reconnect the bot rather than mislabeling it an OAuth + // account below. + const resolvedKind = await resolveOAuthAccountId(slackCredentialId) + if (resolvedKind?.credentialType === 'service_account') { + return { + success: false, + error: { + message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', + status: 400, + }, + } + } + // Native Sim app: a workspace OAuth Slack credential. Resolve it through the + // same workspace/provider-scoped lookup the generic credential path uses, so + // a pasted foreign or other-tenant credential id can't bind here and the + // canonical id is what routing and runtime token resolution key on. + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + const resolvedCredentialId = workflowWorkspace + ? await resolveTriggerCredentialId(slackCredentialId, workflowWorkspace, 'slack') + : null + if (!resolvedCredentialId) { + return { + success: false, + error: { + message: 'The selected Slack credential is not available in this workspace.', + status: 400, + }, + } + } + // The shared app only subscribes to a fixed event set; reject anything + // outside it before deriving routing. + const eventType = + typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null + if (!eventType || !SIM_SUBSCRIBED_EVENTS.includes(eventType)) { + return { + success: false, + error: { + message: + 'This event is not available on the Sim Slack app. Use a custom bot or choose a supported event.', + status: 400, + }, + } + } + // Resolve the credential OWNER's token (not the deploying actor's) — in a + // shared workspace a teammate can deploy a trigger wired to someone else's + // Slack account. + let tokenOwnerUserId = input.userId + const resolvedAccount = await resolveOAuthAccountId(resolvedCredentialId) + if (resolvedAccount?.accountId) { + const [owner] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolvedAccount.accountId)) + .limit(1) + if (owner?.userId) tokenOwnerUserId = owner.userId + } + const botToken = await refreshAccessTokenIfNeeded( + resolvedCredentialId, + tokenOwnerUserId, + input.requestId + ) + if (!botToken) { + return { + success: false, + error: { + message: 'Could not access the connected Slack account. Reconnect it and try again.', + status: 400, + }, + } + } + try { + const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken) + routingKey = teamId + if (botUserId) providerConfig.bot_user_id = botUserId + } catch (error: unknown) { + logger.error( + `[${input.requestId}] Slack team_id resolution failed for ${input.block.id}`, + error + ) + return { + success: false, + error: { + message: 'Could not verify the connected Slack workspace. Reconnect it and try again.', + status: 400, + }, + } } + effectiveProvider = 'slack_app' + effectivePath = null + // Runtime token resolution and credential-disconnect cleanup key native + // (`slack_app`) rows on providerConfig.credentialId. + providerConfig.credentialId = resolvedCredentialId } - effectiveProvider = 'slack' - effectivePath = null - routingKey = botCredentialId - providerConfig.credentialId = botCredentialId - if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId } return { diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 368b4128dc5..fb277ef8594 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -23,11 +23,20 @@ const BOT_FILTER_EVENTS = ['message', 'app_mention'] const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reaction_removed'] /** - * Slack trigger backed by a reusable custom-bot credential (set up once, reused - * across triggers and actions). Events route by that credential - * (`webhook.routingKey = credentialId`) to one shared ingest URL, so many - * triggers on the same bot share a single Request URL, verified with the bot's - * own signing secret. + * Unified Slack trigger. A single credential picker lists both the native Sim + * Slack app (an OAuth-connected account) and reusable custom bots; the deploy + * path resolves the credential's kind server-side to pick the backend: + * - Custom bot: events route by that credential (`webhook.routingKey = + * credentialId`) to one shared ingest URL verified with the bot's own signing + * secret, so many triggers on the same bot share a single Request URL. + * - Native Sim app: events route by Slack `team_id` on the official shared app + * (derived at deploy time via `auth.test`, no path or app setup). Only the + * events the shared app subscribes to are usable; the deploy path enforces + * that (`SIM_SUBSCRIBED_EVENTS`), so the event picker offers every event + * rather than mutating its option set with the selected credential. + * + * The trigger is only reachable through the preview-gated `slack_v2` block, so + * the native Sim-app mode inherits that gate — no separate env flag. */ export const slackOAuthTrigger: TriggerConfig = { id: 'slack_oauth', @@ -51,16 +60,21 @@ export const slackOAuthTrigger: TriggerConfig = { }, { id: 'customBotCredential', - title: 'Slack Bot', + title: 'Slack Account', type: 'oauth-input', canonicalParamId: 'botCredential', serviceId: 'slack', - credentialKind: 'service-account', - credentialLabels: { serviceAccountConnect: 'Set up a custom bot' }, + credentialKind: 'any', + credentialLabels: { + oauthGroup: 'Sim app', + oauthConnect: 'Connect the Sim app', + serviceAccountGroup: 'Custom bots', + serviceAccountConnect: 'Set up a custom bot', + }, requiredScopes: getScopesForService('slack'), - placeholder: 'Select a connected bot', + placeholder: 'Select Slack account or bot', description: - 'Choose a custom Slack bot you set up once and reuse across triggers and actions.', + 'Connect the native Sim Slack app, or choose a custom Slack bot you set up once and reuse across triggers and actions.', required: true, mode: 'trigger', }, diff --git a/apps/sim/triggers/slack/shared.ts b/apps/sim/triggers/slack/shared.ts index 4372f7ba68b..eda88da4c08 100644 --- a/apps/sim/triggers/slack/shared.ts +++ b/apps/sim/triggers/slack/shared.ts @@ -271,7 +271,16 @@ export const slackEventById = new Map( SLACK_EVENT_CATALOG.map((entry) => [entry.id, entry]) ) -/** Dropdown options for the event picker — every event (manifest generated on demand). */ +/** + * Event ids the official shared Sim app subscribes to. Single source of truth + * for the deploy-time gate that rejects a native Sim-app trigger configured with + * an event the shared app can't deliver. + */ +export const SIM_SUBSCRIBED_EVENTS: readonly string[] = SLACK_EVENT_CATALOG.filter( + (entry) => entry.simSubscribed +).map((entry) => entry.id) + +/** Dropdown options for the event picker — every selectable event. */ export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({ label: entry.label, id: entry.id, From c8fea6bd3ba92801a0aac602291964340958ce02 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 11:59:24 -0700 Subject: [PATCH 08/14] fix(knowledge): replace deletion-safety heuristics with a two-phase tombstone (#5884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(knowledge): replace deletion-safety heuristics with a two-phase tombstone PR #5883 merged an interim fix (sourceConfirmedEmpty bypassing two static safety heuristics) before this follow-up redesign was ready. This supersedes that approach with a properly general fix, matching how production sync systems (Entra Connect, SCIM/Entra ID deprovisioning, Cassandra/Couchbase tombstones) handle this exact problem: never let a single observation trigger an irreversible mass deletion, no matter how confident the signal looks. A document missing from a normal sync's listing is now soft-deleted (marked pending-removal) rather than hard-deleted immediately. It's only actually purged once a *later* sync confirms it's still absent. If it reappears in between, it's resurrected automatically — this self-heals a transient outage or a bad API response without needing to distinguish 'real' emptiness from 'ambiguous' emptiness at all, which is what the removed heuristics were trying (and failing) to do from a single observation. This removes shouldSkipEmptyListing, exceedsDeletionSafetyThreshold, and sourceConfirmedEmpty entirely — Google Sheets no longer needs a connector-specific bypass flag, since a genuinely trashed spreadsheet now reconciles through the exact same general path as every other connector, with no special-casing and no new misuse surface for future connectors. A forced fullSync still purges everything absent in one pass, preserving the existing 'trigger a full sync to force cleanup' escape hatch. Uses the existing (previously unused for individual documents) document.deletedAt column as the tombstone marker — no schema migration required. shouldReconcileDeletions (the isIncremental / listingCapped / listingTruncated gate) is unchanged; it still governs whether reconciliation may run at all. Resurrection runs unconditionally even when that gate is closed, since presence is trustworthy evidence regardless of whether the listing was complete. * fix(knowledge): resurrect atomically on content update, sweep tombstoned docs on connector teardown Two real gaps found by review, both stemming from the same root cause: other code paths assumed deletedAt IS NULL means 'the only real rows' and were never updated for the new tombstone semantics. - updateDocument's guard required deletedAt IS NULL, so a tombstoned document reappearing with CHANGED content failed its content update (rejected by the guard) while the separate resurrect step still cleared deletedAt regardless — the document became active again but kept serving stale pre-tombstone content. Fixed by clearing deletedAt as part of the same update statement and dropping the guard, so content and resurrection land atomically. - Both connector-teardown cleanup paths (the ConnectorDeletedException handler in sync-engine.ts, and the connector DELETE API route) only swept documents with deletedAt IS NULL, so pending-removal documents escaped cleanup entirely and were orphaned once their connector was gone. Fixed by including tombstoned docs in both sweeps — there's no future sync left to confirm or resurrect them once the connector itself is deleted. * fix(knowledge): don't resurrect a tombstoned document whose content refresh failed Critical race found by adversarial audit: resurrectIds was derived purely from 'externalId was seen in the listing', independent of whether the paired content update actually succeeded. If updateDocument threw (hydration failure, storage upload failure, or any other transient error) or the deferred-hydration fetch itself failed, the document's row was never touched — yet the separate unconditional resurrect step still cleared deletedAt for it anyway, reproducing the exact bug this PR fixes (visible again, serving stale pre-tombstone content), just triggered by a failed write instead of a gated one. Track externalIds whose refresh attempt failed (hydration rejection or write rejection) and exclude them from partitionSyncReconciliation's resurrectIds. A failed refresh leaves the document tombstoned as-is — not soft-deleted, not hard-deleted, not resurrected — so a later sync gets a clean retry instead of the row landing in an inconsistent state either way. * fix(knowledge): exclude fulfilled-but-unverified hydration outcomes from resurrection too Both bots independently found a third instance of the same bug class: when deferred hydration for an update fulfills but has no usable content (skipped as oversized, or an empty re-fetch), the code falls back to keeping the stored content as last-known-good and counts it unchanged — correct for an already-visible document, but for a tombstoned one it means content was never actually verified as current. That fallback wasn't added to failedExternalIds, so reconciliation still resurrected it with stale pre-tombstone content despite hydration never actually confirming anything. Fixed by adding both fallback branches to failedExternalIds, same as the rejected-promise cases. Verified across every connector's skippedReason call site that this only ever happens inside getDocument (the deferred hydration path already covered here) — no connector sets skippedReason directly in listDocuments, so there's no equivalent listing-time gap to fix. * fix(knowledge): force a full listing when pending-removal documents exist A subtler instance of the same bug class, this time architectural rather than a code-path gap: an incremental listing only includes documents whose content changed since the last sync. A tombstoned document that's still genuinely present at the source but unchanged would never appear in an incremental delta at all, so it could never be resurrected — and on a connector that runs incrementally from here on (its normal syncMode), it would stay tombstoned indefinitely with no self-correcting path, only a manual full resync. Added shouldRunIncrementalSync (extracted as a testable pure function, matching shouldReconcileDeletions' existing pattern) and a cheap existence check for any pending-removal document on this connector. Whenever one exists, this sync forces a full listing instead of an incremental one, guaranteeing every tombstoned document gets a real resurrect-or-confirm decision. This only affects which listing mode runs — it doesn't touch options.fullSync, so the deletion-safety grace period for other, unrelated documents in the same sync is unaffected. * fix(knowledge): bound tombstone-forced full syncs, resurrect kept docs on connector delete Two more real findings from this review round: - A document whose refresh keeps failing every sync (e.g. permanently oversized) never resurrects and never hard-deletes (it's present in the listing, just unreadable) — correct on its own, but it also never stops being counted by hasTombstonedDocs, so it would force a full listing for this connector forever, permanently disabling incremental sync on account of one stuck document. Bounded the check to the same RETRY_WINDOW_DAYS already used for the stuck-document retry sweep below: past the window, this connector stops forcing full syncs on the stuck document's account. The document itself is unaffected — it stays tombstoned either way, matching the existing 'last-known-good forever' tolerance this codebase already accepts for any document whose hydration keeps failing. - The connector-DELETE route's deleteDocuments=false path (kept docs) counted tombstoned documents but never resolved them one way or the other. With the connector gone, there's no future sync left to ever confirm or resurrect them, so they'd become permanent invisible orphans holding storage forever. Since 'kept' documents become normal standalone KB entries once detached from their connector, resurrect any pending-removal ones as part of that transition — consistent with what happens to their non-tombstoned sibling documents. * fix(knowledge): serialize reconciliation writes against a concurrent connector delete An independent adversarial audit (not just Greptile/Cursor) found the one genuinely critical gap 6 rounds of bot review missed: resurrect/ soft-delete/hard-delete writes applied raw document IDs snapshotted at the top of the sync, with no re-check immediately before the write. A connector-DELETE request choosing to keep documents detaches them (connectorId set to NULL) via the exact same FOR UPDATE lock on the connector row that this fix now also takes before applying any reconciliation write — serializing the two: whichever transaction commits first wins, and the loser's re-check sees the up-to-date connectorId and skips any document the other request already claimed. Without this, a sync racing a 'delete connector, keep documents' request could silently resurrect-then-strand or soft/hard-delete a document the user explicitly chose to keep, with a secondary effect of misclassifying it for storage-billing decrement (which keys off whether connectorId is still set). Also tightened the excludedDocs query: it previously required deletedAt IS NULL, so a document that was both userExcluded and tombstoned (reachable via excludeConnectorDocuments, which has no deletedAt filter) fell out of the exclusion set and could be silently un-excluded and re-indexed on reappearing. Dropped that requirement so userExcluded is honored regardless of tombstone state, consistent with how existingDocs/tombstonedDocs are already merged for classification. Documented (not code-changed) the remaining lower-severity finding: a document that outlives the 7-day hasTombstonedDocs bound on a persistently-incremental connector can stay unresolved indefinitely. Deliberately not hard-deleting it after the window expires — that would delete a document with no positive evidence it's actually gone, reintroducing the exact risk this whole design exists to avoid. It's already fully excluded from search/billing/listings either way, so this is an accepted, bounded, orphaned-row trade-off, not a correctness or security issue. * fix(knowledge): close remaining hard-delete race window, guard listing-time skip/drop resurrection Two more real findings, both closing gaps in the previous round's fixes: - The FOR UPDATE lock protected resurrect/soft-delete (applied inside the same transaction) but hardDeleteDocuments still ran after that transaction committed, using IDs snapshotted under the lock. A concurrent 'delete connector, keep documents' request could still detach those same documents in the gap between commit and the hardDeleteDocuments call. Added an optional expectedConnectorId parameter to hardDeleteDocuments/hardDeleteDocumentBatch — when provided, it re-verifies connectorId at the moment of the actual delete query, not just the caller's earlier snapshot. Every other caller is unaffected (parameter is optional, defaults to no filter). - Two more listing-time paths could resurrect a tombstoned document without ever verifying its content: a listing-time skippedReason short-circuits classification straight to 'unchanged' before the hash comparison ever runs, and empty non-deferred content classifies as 'drop' unconditionally regardless of hash. Both are now added to failedExternalIds when reappearing on an existing (possibly tombstoned) document, same treatment as the deferred-hydration equivalents from the prior round. * refactor(knowledge): dedupe retry-cutoff computation, extract ownership-filter helper /simplify pass: hoist the shared RETRY_WINDOW_DAYS cutoff into one computation reused by both the tombstone-retry bound and the stuck-document retry query, and pull the FOR-UPDATE-lock reconciliation's ID filtering into a pure, directly-unit-tested filterStillOwnedReconciliationIds function matching this file's existing convention for its other decision logic. * fix(knowledge): re-verify connectorId at the actual hard-delete, fix docsDeleted count Cursor findings: expectedConnectorId was only checked on the pre-transaction SELECT in hardDeleteDocumentBatch, not on the DELETE itself — the billing lookups and KB locking in between are async and can span a concurrent "delete connector, keep documents" request, so the delete (and its embedding cleanup) now re-verifies against a fresh in-transaction snapshot instead of the stale existingIds. Also fixed docsDeleted to use hardDeleteDocuments' actual returned count instead of the pre-filter candidate count, so a sync log no longer overreports deletions that expectedConnectorId skipped. /cleanup: dropped two comments that only restated the line below them. * fix(knowledge): re-verify connectorId in updateDocument's atomic write Greptile P1: updateDocument's content-update/resurrect write only checked document.id and archivedAt, never connectorId — despite connectorId being a parameter — so a document a concurrent "delete connector, keep documents" request already detached could still be matched, resurrected, and overwritten with connector-sourced content after the connector was deleted. Adds the same connectorId ownership check already used by the reconciliation transaction and hardDeleteDocuments in this PR. * fix(knowledge): close connectorId race in the stuck-document retry path Final independent audit: the stuck-document retry block selected candidate IDs filtered by connectorId, then reset their processing state and deleted their embeddings using that stale ID set with no re-check — the same SELECT-then-write race already patched in updateDocument and hardDeleteDocumentBatch. A concurrent "delete connector, keep documents" request could null out connectorId in between, so this now re-verifies ownership immediately before the embedding delete/document update/re-enqueue and only acts on documents still owned by this connector. * fix(knowledge): lock the connector row for stuck-doc retry ownership too Cursor + Greptile (same root cause, two reports): the previous round's fix re-checked connectorId via a separate SELECT before the embedding delete and processing-state reset, but a bare re-SELECT only narrows a TOCTOU window, it never closes it — a concurrent "delete connector, keep documents" request could still commit its detach in between. Wraps the ownership re-check and both writes in a transaction that takes the same knowledge_connector FOR UPDATE lock the DELETE route takes before nulling connectorId, so the two requests serialize instead of racing, matching the pattern already used by the reconciliation transaction elsewhere in this PR. --- .../[id]/connectors/[connectorId]/route.ts | 18 +- .../google-sheets/google-sheets.test.ts | 37 +- .../connectors/google-sheets/google-sheets.ts | 16 +- .../knowledge/connectors/sync-engine.test.ts | 234 ++++++-- .../lib/knowledge/connectors/sync-engine.ts | 534 +++++++++++++----- apps/sim/lib/knowledge/documents/service.ts | 52 +- 6 files changed, 638 insertions(+), 253 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index ea6bc0bc89a..33717fdcb39 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -328,23 +328,25 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: const { deletedDocs, docCount } = await db.transaction(async (tx) => { await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`) + // Includes pending-removal (tombstoned) docs — the connector is being + // deleted, so there's no future sync left to confirm or resurrect them. const docs = await tx .select({ id: document.id, fileUrl: document.fileUrl }) .from(document) - .where( - and( - eq(document.connectorId, connectorId), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) + .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) + const documentIds = docs.map((doc) => doc.id) if (deleteDocuments) { - const documentIds = docs.map((doc) => doc.id) if (documentIds.length > 0) { await tx.delete(embedding).where(inArray(embedding.documentId, documentIds)) await tx.delete(document).where(inArray(document.id, documentIds)) } + } else if (documentIds.length > 0) { + // Kept documents become normal standalone KB entries once their connector + // is gone — resurrect any pending-removal ones rather than leaving them + // invisible tombstones with no future sync left to ever confirm or + // resurrect them. + await tx.update(document).set({ deletedAt: null }).where(inArray(document.id, documentIds)) } const deletedConnectors = await tx diff --git a/apps/sim/connectors/google-sheets/google-sheets.test.ts b/apps/sim/connectors/google-sheets/google-sheets.test.ts index df1e6c898e4..66eb84aa434 100644 --- a/apps/sim/connectors/google-sheets/google-sheets.test.ts +++ b/apps/sim/connectors/google-sheets/google-sheets.test.ts @@ -116,40 +116,26 @@ describe('googleSheetsConnector trashed handling', () => { }) describe('listDocuments', () => { - it('returns an empty listing and confirms the empty result when the spreadsheet is trashed', async () => { + it('returns an empty listing when the spreadsheet is trashed', async () => { stubFetch({ drive: { status: 200, body: { trashed: true, modifiedTime: '2026-07-01T00:00:00.000Z' } }, }) - const syncContext: Record = {} - const result = await googleSheetsConnector.listDocuments( - ACCESS_TOKEN, - SOURCE_CONFIG, - undefined, - syncContext - ) + const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) expect(result).toEqual({ documents: [], hasMore: false }) - expect(syncContext.sourceConfirmedEmpty).toBe(true) }) it('lists every tab when the trashed field is absent', async () => { stubFetch({ drive: { status: 200, body: { modifiedTime: '2026-07-01T00:00:00.000Z' } } }) - const syncContext: Record = {} - const result = await googleSheetsConnector.listDocuments( - ACCESS_TOKEN, - SOURCE_CONFIG, - undefined, - syncContext - ) + const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) expect(result.documents.map((d) => d.externalId)).toEqual([ `${SPREADSHEET_ID}__sheet__0`, `${SPREADSHEET_ID}__sheet__7`, ]) expect(result.hasMore).toBe(false) - expect(syncContext.sourceConfirmedEmpty).toBeUndefined() }) it('lists every tab when trashed is explicitly false', async () => { @@ -162,20 +148,13 @@ describe('googleSheetsConnector trashed handling', () => { it('fails open and lists every tab when the Drive read fails', async () => { stubFetch({ drive: { status: 500, body: { error: 'backend error' } } }) - const syncContext: Record = {} - const result = await googleSheetsConnector.listDocuments( - ACCESS_TOKEN, - SOURCE_CONFIG, - undefined, - syncContext - ) + const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) expect(result.documents.map((d) => d.externalId)).toEqual([ `${SPREADSHEET_ID}__sheet__0`, `${SPREADSHEET_ID}__sheet__7`, ]) - expect(syncContext.sourceConfirmedEmpty).toBeUndefined() }) it('fails open when the Drive body is not an object', async () => { @@ -185,14 +164,6 @@ describe('googleSheetsConnector trashed handling', () => { expect(result.documents).toHaveLength(2) }) - - it('does not throw when trashed and no syncContext is passed', async () => { - stubFetch({ drive: { status: 200, body: { trashed: true } } }) - - const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) - - expect(result).toEqual({ documents: [], hasMore: false }) - }) }) describe('getDocument', () => { diff --git a/apps/sim/connectors/google-sheets/google-sheets.ts b/apps/sim/connectors/google-sheets/google-sheets.ts index 11657e7842e..746bdb2bfa9 100644 --- a/apps/sim/connectors/google-sheets/google-sheets.ts +++ b/apps/sim/connectors/google-sheets/google-sheets.ts @@ -253,7 +253,7 @@ export const googleSheetsConnector: ConnectorConfig = { accessToken: string, sourceConfig: Record, _cursor?: string, - syncContext?: Record + _syncContext?: Record ): Promise => { const spreadsheetId = (sourceConfig.spreadsheetId as string)?.trim() if (!spreadsheetId) { @@ -269,18 +269,14 @@ export const googleSheetsConnector: ConnectorConfig = { /** * A trashed spreadsheet is no longer current content, so it drops out of the - * listing and stops being re-indexed. Unlike an ordinary empty listing page - * (which could equally mean the source is unreachable), this is a direct, - * single-resource confirmation from the Drive API that the spreadsheet itself - * is gone — so `sourceConfirmedEmpty` tells the sync engine's zero-document - * guard it's safe to reconcile (purge the stored tabs) on this sync, rather - * than requiring a forced full resync. `validateConfig` reports the trashed - * state so the connector does not look healthy while serving tabs from a file - * its owner has thrown away. + * listing and stops being re-indexed. The sync engine reconciles its absence + * the same way it does for every connector: pending-removal on the first + * sync that doesn't see it, purged once a later sync confirms it's still + * gone. `validateConfig` reports the trashed state so the connector does not + * look healthy while serving tabs from a file its owner has thrown away. */ if (isTrashedDriveFile(driveMetadata)) { logger.info('Spreadsheet is in the Drive trash; listing no documents', { spreadsheetId }) - if (syncContext) syncContext.sourceConfirmedEmpty = true return { documents: [], hasMore: false } } diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index bdd197df0a3..540ae694595 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -76,98 +76,236 @@ describe('shouldReconcileDeletions', () => { }) }) -describe('shouldSkipEmptyListing', () => { - it('does not skip when the listing is non-empty', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') +describe('shouldRunIncrementalSync', () => { + const lastSyncAt = '2026-07-01T00:00:00.000Z' - expect(shouldSkipEmptyListing(1, 5, undefined, {})).toBe(false) + it('runs incrementally when everything is eligible', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') + + expect( + shouldRunIncrementalSync(true, 'incremental', undefined, undefined, false, lastSyncAt) + ).toBe(true) }) - it('does not skip when there are no existing documents to lose', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally when the connector does not support it', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 0, undefined, {})).toBe(false) + expect( + shouldRunIncrementalSync(false, 'incremental', undefined, undefined, false, lastSyncAt) + ).toBe(false) }) - it('does not skip on a forced fullSync', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally when the connector is configured for full syncs', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, true, {})).toBe(false) + expect(shouldRunIncrementalSync(true, 'full', undefined, undefined, false, lastSyncAt)).toBe( + false + ) }) - it('skips by default on an empty listing with existing documents', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally on a forced fullSync or rehydrate', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, undefined, {})).toBe(true) - expect(shouldSkipEmptyListing(0, 5, undefined, undefined)).toBe(true) - expect(shouldSkipEmptyListing(0, 5, false, {})).toBe(true) + expect(shouldRunIncrementalSync(true, 'incremental', true, undefined, false, lastSyncAt)).toBe( + false + ) + expect(shouldRunIncrementalSync(true, 'incremental', undefined, true, false, lastSyncAt)).toBe( + false + ) }) - it('does not skip when the connector confirms the empty result against the source', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally before the first sync', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, undefined, { sourceConfirmedEmpty: true })).toBe(false) + expect(shouldRunIncrementalSync(true, 'incremental', undefined, undefined, false, null)).toBe( + false + ) }) - it('still skips when sourceConfirmedEmpty is falsy', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('forces a full listing whenever pending-removal documents exist, so they get a resurrect-or-confirm decision', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, undefined, { sourceConfirmedEmpty: false })).toBe(true) + expect( + shouldRunIncrementalSync(true, 'incremental', undefined, undefined, true, lastSyncAt) + ).toBe(false) }) }) -describe('exceedsDeletionSafetyThreshold', () => { - it('does not block a small deletion, even above 50%', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' +describe('partitionSyncReconciliation', () => { + const live = (id: string, externalId: string | null = id) => ({ id, externalId }) + const noFailures = new Set() + + it('marks a live document missing from the listing as pending removal, not hard-deleted', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation([live('a')], [], new Set(), noFailures, undefined) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: ['a'], hardDeleteIds: [] }) + }) + + it('hard-deletes a document already pending removal that is still absent', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation([], [live('a')], new Set(), noFailures, undefined) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: ['a'] }) + }) + + it('resurrects a pending-removal document that reappears in the listing', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('a')], + new Set(['a']), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(4, 5, undefined, {})).toBe(false) + expect(result).toEqual({ resurrectIds: ['a'], softDeleteIds: [], hardDeleteIds: [] }) }) - it('does not block a large deletion below 50%', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('leaves a document untouched when it is still present in the listing', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('a')], + [], + new Set(['a']), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(6, 20, undefined, {})).toBe(false) + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) }) - it('blocks a deletion above both the ratio and count thresholds by default', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('resurrects even on a forced fullSync', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation([], [live('a')], new Set(['a']), noFailures, true) + + expect(result.resurrectIds).toEqual(['a']) + }) + + it('hard-deletes both live and pending-removal documents immediately on a forced fullSync', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('a')], + [live('b')], + new Set(), + noFailures, + true ) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, {})).toBe(true) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, undefined)).toBe(true) + expect(result.softDeleteIds).toEqual([]) + expect(result.hardDeleteIds.sort()).toEqual(['a', 'b']) }) - it('does not block on a forced fullSync', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('handles a mixed batch of every outcome in one pass', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('kept'), live('newly-missing')], + [live('resurrected'), live('confirmed-gone')], + new Set(['kept', 'resurrected']), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(10, 10, true, {})).toBe(false) + expect(result).toEqual({ + resurrectIds: ['resurrected'], + softDeleteIds: ['newly-missing'], + hardDeleteIds: ['confirmed-gone'], + }) }) - it('does not block when the connector confirms the deletion against the source', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('ignores documents with a null externalId', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('a', null)], + [live('b', null)], + new Set(), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, { sourceConfirmedEmpty: true })).toBe( - false + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) + }) + + it('does not resurrect a reappearing document whose content refresh failed', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('a')], + new Set(['a']), + new Set(['a']), + undefined ) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) }) - it('still blocks when sourceConfirmedEmpty is falsy', async () => { - const { exceedsDeletionSafetyThreshold } = await import( + it('still refuses to resurrect a failed refresh even on a forced fullSync', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('a')], + new Set(['a']), + new Set(['a']), + true + ) + + expect(result.resurrectIds).toEqual([]) + }) + + it('resurrects the ones that succeeded while excluding the one that failed', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('ok'), live('failed')], + new Set(['ok', 'failed']), + new Set(['failed']), + undefined + ) + + expect(result.resurrectIds).toEqual(['ok']) + }) +}) + +describe('filterStillOwnedReconciliationIds', () => { + it('keeps ids present in the ownership snapshot', async () => { + const { filterStillOwnedReconciliationIds } = await import( '@/lib/knowledge/connectors/sync-engine' ) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, { sourceConfirmedEmpty: false })).toBe( - true + const result = filterStillOwnedReconciliationIds(['a'], ['b'], ['c'], new Set(['a', 'b', 'c'])) + + expect(result).toEqual({ resurrectIds: ['a'], softDeleteIds: ['b'], hardDeleteIds: ['c'] }) + }) + + it('drops ids a concurrent connector-delete already detached', async () => { + const { filterStillOwnedReconciliationIds } = await import( + '@/lib/knowledge/connectors/sync-engine' ) + + const result = filterStillOwnedReconciliationIds(['a'], ['b'], ['c'], new Set(['a'])) + + expect(result).toEqual({ resurrectIds: ['a'], softDeleteIds: [], hardDeleteIds: [] }) + }) + + it('returns all-empty lists when nothing is still owned', async () => { + const { filterStillOwnedReconciliationIds } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + const result = filterStillOwnedReconciliationIds(['a'], ['b'], ['c'], new Set()) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) }) }) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 67ecfe374b1..65b0d04542f 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -245,44 +245,107 @@ export function shouldReconcileDeletions( } /** - * Decides whether a zero-document listing should skip deletion reconciliation. + * Decides whether a sync should use the connector's incremental listing. * - * An empty listing is normally indistinguishable from a provider outage, so - * reconciliation is skipped by default rather than risk wiping a knowledge base on - * a bad response. A connector can set `sourceConfirmedEmpty` on `syncContext` to - * vouch that it verified the empty result directly against the source — not - * merely an empty listing page — e.g. a single-resource connector confirming its - * one source item was trashed/removed via a dedicated metadata lookup. + * A pending-removal document only surfaces in an incremental listing if its + * content changed since last sync — an unchanged-but-still-present document + * never appears in an incremental delta at all, so it could never be + * resurrected and would stay tombstoned indefinitely on a connector that runs + * incrementally from here on. `hasTombstonedDocs` forces a full listing + * whenever any pending-removal document exists for this connector, so every + * one of them gets a real resurrect-or-confirm decision on this sync. */ -export function shouldSkipEmptyListing( - externalDocsCount: number, - existingDocsCount: number, +export function shouldRunIncrementalSync( + supportsIncrementalSync: boolean | undefined, + syncMode: string | null | undefined, fullSync: boolean | undefined, - syncContext: Record | undefined + rehydrate: boolean | undefined, + hasTombstonedDocs: boolean, + lastSyncAt: string | Date | null | undefined ): boolean { - if (externalDocsCount !== 0 || existingDocsCount === 0 || fullSync) return false - return !syncContext?.sourceConfirmedEmpty + return Boolean( + supportsIncrementalSync && + syncMode !== 'full' && + !fullSync && + !hasTombstonedDocs && + !rehydrate && + lastSyncAt != null + ) } +/** A stored document's identity, as read back for reconciliation. */ +type ReconciliationDoc = { id: string; externalId: string | null } + /** - * Decides whether a deletion should be blocked by the mass-deletion safety - * threshold (more than half of existing documents, over 5) instead of proceeding. + * Partitions a connector's stored documents against the current listing into + * the three reconciliation actions. * - * This guards against a connector-side bug or transient glitch producing a - * listing that looks mostly empty. `sourceConfirmedEmpty` bypasses it the same - * way it bypasses `shouldSkipEmptyListing` — the connector positively verified - * the deletion against the source rather than merely inferring it from a - * listing, so the extra caution this threshold provides doesn't apply. + * A document absent from a normal (non-fullSync) listing is never purged + * immediately — an empty or shrunken listing can equally mean a transient + * source outage, and a single bad observation must never cause an + * irreversible mass deletion. It is instead marked pending-removal + * (`softDeleteIds`), and only becomes eligible for hard deletion + * (`hardDeleteIds`) once a *later* sync confirms it's still absent — i.e. it + * was already pending-removal (`tombstonedDocs`) coming into this sync. A + * document that reappears while pending-removal is resurrected + * (`resurrectIds`) regardless of `fullSync`, since presence — unlike absence — + * is trustworthy evidence even from a partial listing. A document whose + * content refresh was attempted but failed (`failedExternalIds`) is excluded + * from resurrection even though it was seen — surfacing it now would show + * known-stale pre-tombstone content; it stays tombstoned for a later sync to + * retry. + * + * A forced `fullSync` is an explicit request to reconcile right now: it skips + * the grace period and purges everything absent in one pass. */ -export function exceedsDeletionSafetyThreshold( - removedCount: number, - existingCount: number, - fullSync: boolean | undefined, - syncContext: Record | undefined -): boolean { - if (fullSync || syncContext?.sourceConfirmedEmpty) return false - const deletionRatio = existingCount > 0 ? removedCount / existingCount : 0 - return deletionRatio > 0.5 && removedCount > 5 +export function partitionSyncReconciliation( + existingDocs: ReconciliationDoc[], + tombstonedDocs: ReconciliationDoc[], + seenExternalIds: Set, + failedExternalIds: Set, + fullSync: boolean | undefined +): { resurrectIds: string[]; softDeleteIds: string[]; hardDeleteIds: string[] } { + const resurrectIds = tombstonedDocs + .filter( + (d) => + d.externalId && seenExternalIds.has(d.externalId) && !failedExternalIds.has(d.externalId) + ) + .map((d) => d.id) + const liveMissingIds = existingDocs + .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) + .map((d) => d.id) + const tombstonedStillMissingIds = tombstonedDocs + .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) + .map((d) => d.id) + + if (fullSync) { + return { + resurrectIds, + softDeleteIds: [], + hardDeleteIds: [...liveMissingIds, ...tombstonedStillMissingIds], + } + } + return { resurrectIds, softDeleteIds: liveMissingIds, hardDeleteIds: tombstonedStillMissingIds } +} + +/** + * Re-filters the three reconciliation ID lists against a fresh ownership + * snapshot taken under the connector's `FOR UPDATE` lock, dropping any + * document a concurrent "delete connector, keep documents" request already + * detached (its `connectorId` no longer matches) since the lists were first + * computed. + */ +export function filterStillOwnedReconciliationIds( + resurrectIds: string[], + softDeleteIds: string[], + hardDeleteIds: string[], + stillOwnedIds: Set +): { resurrectIds: string[]; softDeleteIds: string[]; hardDeleteIds: string[] } { + return { + resurrectIds: resurrectIds.filter((id) => stillOwnedIds.has(id)), + softDeleteIds: softDeleteIds.filter((id) => stillOwnedIds.has(id)), + hardDeleteIds: hardDeleteIds.filter((id) => stillOwnedIds.has(id)), + } } /** @@ -473,18 +536,57 @@ export async function executeSync( let hasMore = true const syncContext: Record = { syncRunId: generateId() } + // Shared cutoff for both the tombstone-retry bound below and the stuck-document + // retry near the end of this sync — same RETRY_WINDOW_DAYS window, one computation. + const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000) + + /** + * Bounded to the same retry window as the stuck-document retry below: a + * document whose refresh keeps failing every sync (e.g. permanently + * oversized) would otherwise be a tombstone that never resolves, forcing a + * full listing — and its listing-time overhead — for this connector + * forever. Past the window, this connector stops forcing full syncs on its + * account; the document itself is unaffected and stays tombstoned either way. + * + * Known accepted trade-off: once past the window, a still-tombstoned + * document that's unchanged-but-genuinely-present at the source can only + * be resurrected by a full listing — and nothing here forces one anymore. + * On a connector that never runs a full sync again (persistent incremental + * syncMode, no manual full resync), that document stays correctly + * invisible (excluded everywhere by `isNull(deletedAt)`, so no + * search/billing/listing leakage) but unresolved indefinitely. This is + * deliberately not "fixed" by hard-deleting it after the window expires — + * that would delete a document we have no positive evidence is actually + * gone, reintroducing the exact risk this whole design exists to avoid. + */ + const hasTombstonedDocs = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + isNull(document.archivedAt), + isNotNull(document.deletedAt), + gt(document.deletedAt, retryCutoff) + ) + ) + .limit(1) + .then((rows) => rows.length > 0) + /** * Determine if this sync should be incremental. A `rehydrate` request forces a * full listing too: re-hydration must see *every* document (a container page can * be unchanged itself yet transclude a page that changed), and an incremental * listing would omit those unchanged containers, so they'd never be re-fetched. */ - const isIncremental = - connectorConfig.supportsIncrementalSync && - connector.syncMode !== 'full' && - !options?.fullSync && - !options?.rehydrate && - connector.lastSyncAt != null + const isIncremental = shouldRunIncrementalSync( + connectorConfig.supportsIncrementalSync, + connector.syncMode, + options?.fullSync, + options?.rehydrate, + hasTombstonedDocs, + connector.lastSyncAt + ) const lastSyncAt = isIncremental && connector.lastSyncAt ? new Date(connector.lastSyncAt) : undefined @@ -548,7 +650,7 @@ export async function executeSync( connectorId, }) - const [existingDocs, excludedDocs] = await Promise.all([ + const [existingDocs, tombstonedDocs, excludedDocs] = await Promise.all([ db .select({ id: document.id, @@ -563,63 +665,66 @@ export async function executeSync( isNull(document.deletedAt) ) ), + // Docs already marked pending-removal by a prior sync's reconciliation (see + // shouldReconcileDeletions below): absent from the source once, not yet + // absent twice in a row. Included in classification so a document that + // reappears is recognized as existing (resurrected) rather than re-added + // as a duplicate. db - .select({ externalId: document.externalId }) + .select({ + id: document.id, + externalId: document.externalId, + contentHash: document.contentHash, + deletedAt: document.deletedAt, + }) .from(document) .where( and( eq(document.connectorId, connectorId), - eq(document.userExcluded, true), isNull(document.archivedAt), - isNull(document.deletedAt) + isNotNull(document.deletedAt) ) ), - ]) - - const excludedExternalIds = new Set(excludedDocs.map((d) => d.externalId).filter(Boolean)) - - if ( - shouldSkipEmptyListing( - externalDocs.length, - existingDocs.length, - options?.fullSync, - syncContext - ) - ) { - logger.warn( - `Source returned 0 documents but ${existingDocs.length} exist — skipping reconciliation`, - { connectorId } - ) - - await completeSyncLog(syncLogId, 'completed', result) - - const now = new Date() - await db - .update(knowledgeConnector) - .set({ - status: 'active', - lastSyncAt: now, - lastSyncError: null, - nextSyncAt: calculateNextSyncTime(connector.syncIntervalMinutes), - consecutiveFailures: 0, - updatedAt: now, - }) + // Not filtered on deletedAt: a document can be both userExcluded and + // tombstoned (e.g. excluded via a bulk request that raced a sync marking + // it pending-removal). Excluding it here regardless of tombstone state + // keeps it short-circuited in the classification loop below instead of + // silently reappearing through the normal update/resurrect path. + db + .select({ externalId: document.externalId }) + .from(document) .where( and( - eq(knowledgeConnector.id, connectorId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) + eq(document.connectorId, connectorId), + eq(document.userExcluded, true), + isNull(document.archivedAt) ) - ) + ), + ]) - return result - } + const excludedExternalIds = new Set(excludedDocs.map((d) => d.externalId).filter(Boolean)) - const existingByExternalId = new Map( - existingDocs.filter((d) => d.externalId !== null).map((d) => [d.externalId!, d]) + const priorByExternalId = new Map( + [...existingDocs, ...tombstonedDocs] + .filter((d) => d.externalId !== null) + .map((d) => [d.externalId!, d]) ) const seenExternalIds = new Set() + /** + * externalIds whose content was never verified as current: a hydration + * error, a rejected write, a fulfilled-but-unusable hydration (skipped as + * oversized, or an empty re-fetch), a listing-time skippedReason + * short-circuit, or empty non-deferred content (`drop`) — all fall back to + * either keeping the stored content as last-known-good or discarding the + * listing entry outright, without ever comparing or refreshing content. + * That's fine for an already-visible document, but for a tombstoned one it + * means we still don't have confirmed-current content — so this excludes + * them from resurrection below: a tombstoned document whose refresh didn't + * actually land must stay tombstoned rather than come back visible while + * still serving stale pre-tombstone content. + */ + const failedExternalIds = new Set() const pendingOps: DocOp[] = [] for (const extDoc of externalDocs) { @@ -631,7 +736,7 @@ export async function executeSync( continue } - const existing = existingByExternalId.get(extDoc.externalId) + const existing = priorByExternalId.get(extDoc.externalId) const classification = classifyExternalDoc(extDoc, existing, forceRehydrate) switch (classification.type) { @@ -639,6 +744,10 @@ export async function executeSync( pendingOps.push({ type: 'skip', extDoc }) break case 'drop': + // Empty, non-deferred content is never usable. If this was a + // reappearing tombstoned document, its content was never verified as + // current — see failedExternalIds below. + if (existing) failedExternalIds.add(extDoc.externalId) logger.info(`Skipping empty document: ${extDoc.title}`, { externalId: extDoc.externalId, }) @@ -650,6 +759,12 @@ export async function executeSync( pendingOps.push({ type: 'update', existingId: classification.existingId, extDoc }) break case 'unchanged': + // A listing-time skippedReason short-circuits classification before + // the hash comparison, so this is "kept as last-known-good", not a + // verified-unchanged match — same as the deferred-hydration + // equivalent above. A genuine hash match never sets skippedReason, + // so this only fires for the short-circuited case. + if (extDoc.skippedReason && existing) failedExternalIds.add(extDoc.externalId) result.docsUnchanged++ break } @@ -704,15 +819,21 @@ export async function executeSync( }) } else if (op.type === 'update') { // Already-indexed file is kept as last-known-good (not downgraded), so it - // counts as unchanged rather than slipping past every result counter. + // counts as unchanged rather than slipping past every result counter. Not a + // verified refresh, though — see failedExternalIds below. result.docsUnchanged++ + failedExternalIds.add(op.extDoc.externalId) } return null } if (!fullDoc?.content.trim()) { // An empty re-fetch leaves an already-indexed update as last-known-good; count - // it as unchanged so the totals still reconcile with documents seen. - if (op.type === 'update') result.docsUnchanged++ + // it as unchanged so the totals still reconcile with documents seen. Not a + // verified refresh, though — see failedExternalIds below. + if (op.type === 'update') { + result.docsUnchanged++ + failedExternalIds.add(op.extDoc.externalId) + } return null } const hydratedHash = fullDoc.contentHash ?? op.extDoc.contentHash @@ -725,7 +846,7 @@ export async function executeSync( if ( op.type === 'update' && !forceRehydrate && - existingByExternalId.get(op.extDoc.externalId)?.contentHash === hydratedHash + priorByExternalId.get(op.extDoc.externalId)?.contentHash === hydratedHash ) { result.docsUnchanged++ return null @@ -745,13 +866,16 @@ export async function executeSync( }) ) - for (const outcome of hydrated) { + for (let i = 0; i < hydrated.length; i++) { + const outcome = hydrated[i] if (outcome.status === 'fulfilled' && outcome.value) { readyOps.push(outcome.value) } else if (outcome.status === 'rejected') { result.docsFailed++ + failedExternalIds.add(deferredOps[i].extDoc.externalId) logger.error('Failed to hydrate deferred document', { connectorId, + externalId: deferredOps[i].extDoc.externalId, error: getErrorMessage(outcome.reason), }) } @@ -814,6 +938,7 @@ export async function executeSync( else result.docsUpdated++ } else { result.docsFailed++ + failedExternalIds.add(batch[j].extDoc.externalId) logger.error('Failed to process document', { connectorId, externalId: batch[j].extDoc.externalId, @@ -841,35 +966,108 @@ export async function executeSync( } } - if (shouldReconcileDeletions(isIncremental, syncContext, options?.fullSync)) { - const removedIds = existingDocs - .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) - .map((d) => d.id) - - if (removedIds.length > 0) { - if ( - exceedsDeletionSafetyThreshold( - removedIds.length, - existingDocs.length, - options?.fullSync, - syncContext - ) - ) { - logger.warn( - `Skipping deletion of ${removedIds.length}/${existingDocs.length} docs — exceeds safety threshold. Trigger a full sync to force cleanup.`, - { - connectorId, - deletionRatio: Math.round((removedIds.length / existingDocs.length) * 100), - } - ) - } else { - await hardDeleteDocuments(removedIds, syncLogId) - result.docsDeleted += removedIds.length + const { resurrectIds, softDeleteIds, hardDeleteIds } = partitionSyncReconciliation( + existingDocs, + tombstonedDocs, + seenExternalIds, + failedExternalIds, + options?.fullSync + ) + + const reconcileDeletionsAllowed = shouldReconcileDeletions( + isIncremental, + syncContext, + options?.fullSync + ) + const gatedSoftDeleteIds = reconcileDeletionsAllowed ? softDeleteIds : [] + const gatedHardDeleteIds = reconcileDeletionsAllowed ? hardDeleteIds : [] + + const candidateIds = [ + ...new Set([...resurrectIds, ...gatedSoftDeleteIds, ...gatedHardDeleteIds]), + ] + + let safeResurrectIds: string[] = [] + let safeSoftDeleteIds: string[] = [] + let safeHardDeleteIds: string[] = [] + + if (candidateIds.length > 0) { + /** + * A concurrent "delete connector, keep documents" request detaches these + * same documents (connectorId set to NULL) under the same FOR UPDATE lock + * the DELETE route takes on this connector row. Taking that lock here + * serializes the two requests: whichever commits first wins, and the + * loser's re-check below sees the up-to-date connectorId and skips any + * document the other request already claimed — instead of resurrecting or + * deleting a document that another request just detached (and possibly + * already billed) as a standalone KB entry. + */ + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE` + ) + + const stillOwned = new Set( + ( + await tx + .select({ id: document.id }) + .from(document) + .where(and(inArray(document.id, candidateIds), eq(document.connectorId, connectorId))) + ).map((d) => d.id) + ) + + const stillOwnedResult = filterStillOwnedReconciliationIds( + resurrectIds, + gatedSoftDeleteIds, + gatedHardDeleteIds, + stillOwned + ) + safeResurrectIds = stillOwnedResult.resurrectIds + safeSoftDeleteIds = stillOwnedResult.softDeleteIds + safeHardDeleteIds = stillOwnedResult.hardDeleteIds + + /** + * A document reappearing at the source is trustworthy evidence on its + * own — unlike absence, presence never depends on the listing being + * complete — so resurrection runs unconditionally, even on an + * incremental or otherwise gated sync. + */ + if (safeResurrectIds.length > 0) { + await tx + .update(document) + .set({ deletedAt: null }) + .where(inArray(document.id, safeResurrectIds)) } - } + if (safeSoftDeleteIds.length > 0) { + await tx + .update(document) + .set({ deletedAt: new Date() }) + .where(inArray(document.id, safeSoftDeleteIds)) + } + }) + } + + if (safeResurrectIds.length > 0) { + logger.info( + `Resurrected ${safeResurrectIds.length} documents that reappeared at the source`, + { + connectorId, + } + ) + } + if (safeSoftDeleteIds.length > 0) { + logger.info( + `Marked ${safeSoftDeleteIds.length} documents pending removal — absent from source, confirming on next sync`, + { connectorId } + ) + } + if (safeHardDeleteIds.length > 0) { + // Re-verifies connectorId once more at the moment of the actual delete + // query — the FOR UPDATE lock above only covers the window up to its + // own commit; this closes the remaining gap between that commit and + // this call. + result.docsDeleted += await hardDeleteDocuments(safeHardDeleteIds, syncLogId, connectorId) } - // Check if connector/KB were deleted before retrying stuck documents const postBatchLiveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) if (postBatchLiveness.connectorDeleted) { throw new ConnectorDeletedException(connectorId) @@ -885,7 +1083,6 @@ export async function executeSync( // abandoned (e.g. the Trigger.dev task process exited before processing completed). // Documents uploaded more than RETRY_WINDOW_DAYS ago are not retried. const staleProcessingCutoff = new Date(Date.now() - STALE_PROCESSING_MINUTES * 60 * 1000) - const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000) const stuckDocs = await db .select({ id: document.id, @@ -923,35 +1120,71 @@ export async function executeSync( logger.info(`Retrying ${stuckDocs.length} stuck documents`, { connectorId }) try { const stuckDocIds = stuckDocs.map((doc) => doc.id) + let retryDocs: typeof stuckDocs = [] + + /** + * Takes the same `knowledge_connector` FOR UPDATE lock the DELETE route + * takes before nulling connectorId on detached documents, so the two + * requests serialize instead of racing — a plain re-SELECT only + * narrows the window between the ownership check and these writes, it + * never closes it, since a concurrent detach can still commit in + * between. Embedding cleanup and the processing-state reset happen + * inside the same locked transaction so a document already claimed by + * a detach never gets its embeddings wiped or is reprocessed as if + * still connector-owned. + */ + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE` + ) - await db.delete(embedding).where(inArray(embedding.documentId, stuckDocIds)) + const stillOwnedIds = new Set( + ( + await tx + .select({ id: document.id }) + .from(document) + .where( + and(inArray(document.id, stuckDocIds), eq(document.connectorId, connectorId)) + ) + ).map((d) => d.id) + ) + retryDocs = stuckDocs.filter((doc) => stillOwnedIds.has(doc.id)) + + if (retryDocs.length > 0) { + const retryDocIds = retryDocs.map((doc) => doc.id) + + await tx.delete(embedding).where(inArray(embedding.documentId, retryDocIds)) + + await tx + .update(document) + .set({ + processingStatus: 'pending', + processingStartedAt: null, + processingCompletedAt: null, + processingError: null, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + }) + .where(inArray(document.id, retryDocIds)) + } + }) - await db - .update(document) - .set({ - processingStatus: 'pending', - processingStartedAt: null, - processingCompletedAt: null, - processingError: null, - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - }) - .where(inArray(document.id, stuckDocIds)) - - await processDocumentsWithQueue( - stuckDocs.map((doc) => ({ - documentId: doc.id, - filename: doc.filename ?? 'document.txt', - fileUrl: doc.fileUrl ?? '', - fileSize: doc.fileSize ?? 0, - mimeType: doc.mimeType ?? 'text/plain', - })), - connector.knowledgeBaseId, - {}, - generateId(), - billingAttribution - ) + if (retryDocs.length > 0) { + await processDocumentsWithQueue( + retryDocs.map((doc) => ({ + documentId: doc.id, + filename: doc.filename ?? 'document.txt', + fileUrl: doc.fileUrl ?? '', + fileSize: doc.fileSize ?? 0, + mimeType: doc.mimeType ?? 'text/plain', + })), + connector.knowledgeBaseId, + {}, + generateId(), + billingAttribution + ) + } } catch (error) { logger.warn('Failed to enqueue stuck documents for reprocessing', { connectorId, @@ -1003,20 +1236,17 @@ export async function executeSync( logger.info('Connector deleted during sync, cleaning up', { connectorId }) try { + // Includes pending-removal (tombstoned) docs — the connector is gone, so + // there's no future sync left to confirm or resurrect them. const connectorDocs = await db .select({ id: document.id }) .from(document) - .where( - and( - eq(document.connectorId, connectorId), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) + .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) await hardDeleteDocuments( connectorDocs.map((doc) => doc.id), - syncLogId + syncLogId, + connectorId ) await completeSyncLog(syncLogId, 'failed', result, 'Connector deleted during sync') @@ -1293,7 +1523,6 @@ async function updateDocument( kbOwner: KnowledgeBaseOwner, sourceConfig?: Record ): Promise { - // Fetch old file URL before uploading replacement const existingRows = await db .select({ fileUrl: document.fileUrl }) .from(document) @@ -1342,12 +1571,21 @@ async function updateDocument( ...tagValues, processingStatus: 'pending', uploadedAt: new Date(), + // A tombstoned document reappearing with changed content is resurrected + // in the same write as its content update — otherwise reconciliation's + // separate resurrect step would clear deletedAt while this update, gated + // on deletedAt IS NULL, rejects the row and leaves stale content active. + deletedAt: null, }) .where( and( eq(document.id, existingDocId), - isNull(document.archivedAt), - isNull(document.deletedAt) + // A concurrent "delete connector, keep documents" request can null out + // connectorId between this sync's liveness check and this write. Without + // this check, that now-standalone document would still match on id alone + // and get overwritten with connector-sourced content post-detachment. + eq(document.connectorId, connectorId), + isNull(document.archivedAt) ) ) .returning({ id: document.id }) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 1df4cd960a0..1b8651a8424 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -2325,7 +2325,17 @@ async function deleteDocumentsByLifecyclePolicy( export async function hardDeleteDocuments( documentIds: string[], - requestId: string + requestId: string, + /** + * When provided, re-verifies each document's connectorId still matches at + * the moment of the actual delete query — not just the caller's earlier + * snapshot. A caller (e.g. connector sync reconciliation) can compute this + * ID list well before the delete runs; a concurrent request that detaches + * these same documents from the connector in between (e.g. "delete + * connector, keep documents") would otherwise still have them purged here + * despite no longer belonging to the connector the caller reasoned about. + */ + expectedConnectorId?: string ): Promise { const ids = [...new Set(documentIds)] if (ids.length === 0) { @@ -2336,7 +2346,8 @@ export async function hardDeleteDocuments( for (let offset = 0; offset < ids.length; offset += HARD_DELETE_DOCUMENT_BATCH_SIZE) { deletedCount += await hardDeleteDocumentBatch( ids.slice(offset, offset + HARD_DELETE_DOCUMENT_BATCH_SIZE), - requestId + requestId, + expectedConnectorId ) } return deletedCount @@ -2346,7 +2357,11 @@ export async function hardDeleteDocuments( * Hard-deletes one bounded metadata batch and applies every associated ledger * delta atomically. */ -async function hardDeleteDocumentBatch(documentIds: string[], requestId: string): Promise { +async function hardDeleteDocumentBatch( + documentIds: string[], + requestId: string, + expectedConnectorId?: string +): Promise { const ids = [...new Set(documentIds)] const documentsToDelete = await db .select({ @@ -2361,7 +2376,11 @@ async function hardDeleteDocumentBatch(documentIds: string[], requestId: string) }) .from(document) .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) - .where(inArray(document.id, ids)) + .where( + expectedConnectorId + ? and(inArray(document.id, ids), eq(document.connectorId, expectedConnectorId)) + : inArray(document.id, ids) + ) if (documentsToDelete.length === 0) { return 0 @@ -2432,10 +2451,31 @@ async function hardDeleteDocumentBatch(documentIds: string[], requestId: string) } } - await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) + /** + * Re-verify `expectedConnectorId` here too, not only on the pre-transaction + * SELECT above — the billing lookups and KB locking between that SELECT + * and this delete are async and can span a concurrent "delete connector, + * keep documents" request that clears these rows' `connectorId` in + * between. Deleting a detached document's embeddings would corrupt its + * search index even if the document row itself were spared, so both the + * embedding delete and the document delete are scoped to this re-verified + * ID set rather than the stale `existingIds`. + */ + const stillTargetedIds = expectedConnectorId + ? ( + await tx + .select({ id: document.id }) + .from(document) + .where( + and(inArray(document.id, existingIds), eq(document.connectorId, expectedConnectorId)) + ) + ).map((d) => d.id) + : existingIds + + await tx.delete(embedding).where(inArray(embedding.documentId, stillTargetedIds)) const deletedRows = await tx .delete(document) - .where(inArray(document.id, existingIds)) + .where(inArray(document.id, stillTargetedIds)) .returning({ id: document.id }) const deletedIds = new Set(deletedRows.map((row) => row.id)) From d39b19320102b6112dea5234b4011cb60cb094e2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 12:02:31 -0700 Subject: [PATCH 09/14] feat(sidebar): search the workspace switcher when a user has many workspaces (#5893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): search the workspace switcher when a user has many workspaces Shows a search input in the workspace dropdown once the list exceeds WORKSPACE_SEARCH_THRESHOLD (3). ArrowUp/Down move through results, Enter switches, and the query resets on close. All the search machinery is gated on showSearch so users with few workspaces get no extra re-renders. The input reuses the emcn ChipInput chrome (icon prop) rather than hand-rolling the field. The highlight tracks the highlighted workspace by identity (a stored id, not a numeric position). An effect keeps that id pinned to a workspace that is actually in the current results — seeding the first result on open and re-seeding when a query filters the current one out — so even the default highlight is identity- stable. A live list change while the menu is open (shrink, grow, or reorder from a membership change or background refetch) therefore carries the highlight and Enter along with the same workspace instead of stranding them on whatever now sits at that row. `activeIndex` derives from the id and is the single source of truth for Enter, the visual highlight, and the scroll target. Search and highlight reset via an effect keyed on the menu-open state, so closing by any path (selecting a workspace, Escape, click-away) clears them — not only the Radix-driven close that routes through onOpenChange. Hover-highlight is wired to mousemove rather than mouseenter so a keyboard-driven scrollIntoView can't fire a synthetic enter that hijacks the keyboard selection, and composition keys are ignored while an IME is active so confirming a candidate can't switch workspace. * fix(settings): pad settings-sidebar scroll list so the last item's hover isn't clipped The scrollable settings-nav container had `pt-1.5` but no bottom padding, so the last item's rounded `--surface-active` hover pill was clipped against the container's overflow edge (visible on "Custom blocks", the final Enterprise item). Give it symmetric vertical padding (`py-1.5`) so the last row gets equal breathing room. Padding sits on the scroll container, not between items, so item spacing is unchanged. --- .../workspace-header/workspace-header.tsx | 133 +++++++++++++++++- .../components/settings/settings-sidebar.tsx | 2 +- 2 files changed, 128 insertions(+), 7 deletions(-) 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 a6af214e9b4..e648bc997df 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 @@ -5,6 +5,7 @@ import { ChevronDown, Chip, ChipConfirmModal, + ChipInput, chipGeometryClass, chipVariants, cn, @@ -19,7 +20,7 @@ import { } from '@sim/emcn' import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { MoreHorizontal } from 'lucide-react' +import { MoreHorizontal, Search } from 'lucide-react' import { useActiveOrganization } from '@/lib/auth/auth-client' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' @@ -35,6 +36,9 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' const logger = createLogger('WorkspaceHeader') +/** Show the search input once the workspace list exceeds this count. */ +const WORKSPACE_SEARCH_THRESHOLD = 3 + /** * Derives the single-letter avatar initial for a workspace, ignoring the word * "workspace" in the name (e.g. "Acme Workspace" → "A"). @@ -150,6 +154,71 @@ function WorkspaceHeaderImpl({ const contextMenuClosedRef = useRef(true) const hasInputFocusedRef = useRef(false) const renameInputRef = useRef(null) + const searchInputRef = useRef(null) + const workspaceListRef = useRef(null) + + const [workspaceSearch, setWorkspaceSearch] = useState('') + const [highlightedId, setHighlightedId] = useState(null) + + const showSearch = workspaces.length > WORKSPACE_SEARCH_THRESHOLD + const searchQuery = workspaceSearch.trim().toLowerCase() + const filteredWorkspaces = + showSearch && searchQuery + ? workspaces.filter((w) => w.name.toLowerCase().includes(searchQuery)) + : workspaces + + /** + * The highlighted row resolved from the highlighted workspace's identity, not + * a stored position. Tracking the id (rather than a numeric index) keeps the + * highlight on the same workspace when the list shrinks, grows, or reorders + * while the menu is open (a live membership change or background refetch); + * a missing id (filtered out) or no selection falls back to the first row. + * `activeIndex` is the single source of truth for Enter, the visual highlight, + * and the scroll target, so those three can never diverge. + */ + const activeIndex = highlightedId + ? Math.max( + 0, + filteredWorkspaces.findIndex((w) => w.id === highlightedId) + ) + : 0 + + useEffect(() => { + if (!showSearch || !isWorkspaceMenuOpen) return + const el = workspaceListRef.current?.querySelector( + `[data-workspace-row-idx="${activeIndex}"]` + ) + el?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, showSearch, isWorkspaceMenuOpen]) + + /** + * Seed the highlight to the first result whenever the current one is absent — + * on open, or after typing filters the highlighted workspace out. This keeps + * `highlightedId` pinned to a real workspace identity rather than falling back + * to a bare positional default, so a reorder or query change carries the + * highlight along with its workspace instead of stranding it on whatever now + * occupies the first row. + */ + useEffect(() => { + if (!showSearch || !isWorkspaceMenuOpen || filteredWorkspaces.length === 0) return + const present = highlightedId !== null && filteredWorkspaces.some((w) => w.id === highlightedId) + if (!present) setHighlightedId(filteredWorkspaces[0].id) + }, [highlightedId, filteredWorkspaces, showSearch, isWorkspaceMenuOpen]) + + /** + * Clear the query and highlight whenever the menu closes, by any path — + * selecting a workspace closes it via `setIsWorkspaceMenuOpen(false)` without + * routing through `onOpenChange`, so resetting here (not in the open handler) + * keeps a stale search from persisting into the next open. Not gated on + * `showSearch`: if the list drops to the threshold while a query is active the + * search input unmounts, and this still clears the now-invisible filter. For + * users who never search, both setters no-op (same value) so there is no cost. + */ + useEffect(() => { + if (isWorkspaceMenuOpen) return + setWorkspaceSearch('') + setHighlightedId(null) + }, [isWorkspaceMenuOpen]) const [isMounted, setIsMounted] = useState(false) useEffect(() => { @@ -361,6 +430,9 @@ function WorkspaceHeaderImpl({ return } setIsWorkspaceMenuOpen(open) + if (open && showSearch) { + requestAnimationFrame(() => searchInputRef.current?.focus()) + } }} > @@ -422,14 +494,63 @@ function WorkspaceHeaderImpl({
) : ( <> -
- {workspaces.map((workspace) => { + {showSearch && ( + setWorkspaceSearch(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.nativeEvent.isComposing) return + if (filteredWorkspaces.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = (activeIndex + 1) % filteredWorkspaces.length + setHighlightedId(filteredWorkspaces[next].id) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const next = + (activeIndex - 1 + filteredWorkspaces.length) % filteredWorkspaces.length + setHighlightedId(filteredWorkspaces[next].id) + } else if (e.key === 'Enter') { + e.preventDefault() + const target = filteredWorkspaces[activeIndex] + if (target) onWorkspaceSwitch(target) + } + }} + className='mb-1.5' + /> + )} +
+ {filteredWorkspaces.length === 0 && workspaceSearch && ( +
+ No results for "{workspaceSearch}" +
+ )} + {filteredWorkspaces.map((workspace, idx) => { const initial = getWorkspaceInitial(workspace.name) const isActive = workspace.id === workspaceId const isMenuOpen = menuOpenWorkspaceId === workspace.id - + const isKeyboardHighlighted = showSearch && idx === activeIndex + + /** + * Hover-highlight is wired to `onMouseMove`, not `onMouseEnter`: a + * keyboard-driven `scrollIntoView` slides rows under a stationary cursor + * and fires `mouseenter`, which would hijack the keyboard selection. + * `mousemove` only fires on real pointer motion, so hover follows the + * mouse without fighting the arrow keys. + */ return ( -
+
setHighlightedId(workspace.id) : undefined} + > {editingWorkspaceId === workspace.id ? (
({
From bc237a0dcc123fdad42853f0c5e9c65bf48f4e41 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 12:12:44 -0700 Subject: [PATCH 10/14] improvement(shopify): pin Admin API to supported 2025-10 via shared constant (#5895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(shopify): pin Admin API to supported 2025-10 via shared constant Every Shopify tool and the credential validator hardcoded the retired 2024-10 Admin API version. Retired versions still work (Shopify forward-falls to the oldest supported version) but the served version drifts silently and emits deprecation signals. - Add apps/sim/tools/shopify/constants.ts exporting SHOPIFY_API_VERSION as the single source of truth; bump to the supported 2025-10. - Wire all 21 Shopify tools, the token-service-account validator, and the OAuth store route to the shared constant (no more per-file inline version). - Derive the validator test's expected URL from the constant so it can't drift. Our operations already use modern 2024-10+ shapes (ProductCreateInput / ProductUpdateInput new product model, orderCancel new signature, CustomerInput, InventoryAdjustQuantitiesInput, fulfillmentCreate), none of the removed inline ProductInput.variants pattern — so the bump is a small, explicit step forward from the version Shopify already forward-serves. * chore(shopify): remove dead ShopifySetInventoryParams type Surfaced by /validate-integration: unexported, unused (no set_inventory tool). --- .../app/api/auth/oauth2/shopify/store/route.ts | 16 ++++++++++------ .../validators/shopify.test.ts | 3 ++- .../token-service-accounts/validators/shopify.ts | 9 +-------- apps/sim/tools/shopify/adjust_inventory.ts | 3 ++- apps/sim/tools/shopify/cancel_order.ts | 3 ++- apps/sim/tools/shopify/constants.ts | 14 ++++++++++++++ apps/sim/tools/shopify/create_customer.ts | 3 ++- apps/sim/tools/shopify/create_fulfillment.ts | 3 ++- apps/sim/tools/shopify/create_product.ts | 3 ++- apps/sim/tools/shopify/delete_customer.ts | 3 ++- apps/sim/tools/shopify/delete_product.ts | 3 ++- apps/sim/tools/shopify/get_collection.ts | 3 ++- apps/sim/tools/shopify/get_customer.ts | 3 ++- apps/sim/tools/shopify/get_inventory_level.ts | 3 ++- apps/sim/tools/shopify/get_order.ts | 3 ++- apps/sim/tools/shopify/get_product.ts | 3 ++- apps/sim/tools/shopify/list_collections.ts | 3 ++- apps/sim/tools/shopify/list_customers.ts | 3 ++- apps/sim/tools/shopify/list_inventory_items.ts | 3 ++- apps/sim/tools/shopify/list_locations.ts | 3 ++- apps/sim/tools/shopify/list_orders.ts | 3 ++- apps/sim/tools/shopify/list_products.ts | 3 ++- apps/sim/tools/shopify/types.ts | 6 ------ apps/sim/tools/shopify/update_customer.ts | 3 ++- apps/sim/tools/shopify/update_order.ts | 3 ++- apps/sim/tools/shopify/update_product.ts | 3 ++- 26 files changed, 69 insertions(+), 42 deletions(-) create mode 100644 apps/sim/tools/shopify/constants.ts diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index c4a26459ecb..4d26c178f5b 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -13,6 +13,7 @@ import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' const logger = createLogger('ShopifyStore') @@ -46,12 +47,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) } - const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, { - headers: { - 'X-Shopify-Access-Token': accessToken, - 'Content-Type': 'application/json', - }, - }) + const shopResponse = await fetch( + `https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': accessToken, + 'Content-Type': 'application/json', + }, + } + ) if (!shopResponse.ok) { const errorText = await shopResponse.text() diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index f8784e9bb5d..b53fc10cff3 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' const mockFetch = vi.fn() @@ -48,7 +49,7 @@ describe('validateShopifyServiceAccount', () => { normalizedDomain: 'acme-store.myshopify.com', }) expect(mockFetch).toHaveBeenCalledWith( - 'https://acme-store.myshopify.com/admin/api/2024-10/graphql.json', + `https://acme-store.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, { method: 'POST', headers: { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 4f71977b37a..e0a2f9a605d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -9,14 +9,7 @@ import type { TokenServiceAccountFields, TokenServiceAccountValidationResult, } from '@/lib/credentials/token-service-accounts/server' - -/** - * Pinned to match the Admin API version hardcoded in every Sim Shopify tool - * (`apps/sim/tools/shopify/*`) — bump both together. Note 2024-10 is retired; - * Shopify silently serves the oldest supported version for retired versions, - * so validation and tool runtime still hit the same effective API. - */ -const SHOPIFY_API_VERSION = '2024-10' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' /** * SSRF guard: the Admin API must target the permanent `*.myshopify.com` diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts index 950ca816452..78ce7bc0cc5 100644 --- a/apps/sim/tools/shopify/adjust_inventory.ts +++ b/apps/sim/tools/shopify/adjust_inventory.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyAdjustInventoryParams, ShopifyInventoryAdjustmentResponse, @@ -48,7 +49,7 @@ export const shopifyAdjustInventoryTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts index 3a7ee6d7af9..30d586422b9 100644 --- a/apps/sim/tools/shopify/cancel_order.ts +++ b/apps/sim/tools/shopify/cancel_order.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCancelOrderParams, ShopifyCancelOrderResponse } from '@/tools/shopify/types' import { CANCEL_ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -64,7 +65,7 @@ export const shopifyCancelOrderTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/constants.ts b/apps/sim/tools/shopify/constants.ts new file mode 100644 index 00000000000..92fb5cb5867 --- /dev/null +++ b/apps/sim/tools/shopify/constants.ts @@ -0,0 +1,14 @@ +/** + * The Shopify Admin API version every Sim Shopify tool targets, plus the + * credential validator in + * `@/lib/credentials/token-service-accounts/validators/shopify`. Keep this the + * single source of truth — bump it here and every caller moves in lockstep. + * + * Shopify supports each stable version for at least 12 months and forward-falls + * to the oldest supported version for retired ones, so a request never breaks + * the day a version retires — but the served version drifts silently, so pin an + * explicitly supported version and bump on the quarterly cadence. + * + * @see https://shopify.dev/docs/api/usage/versioning + */ +export const SHOPIFY_API_VERSION = '2025-10' diff --git a/apps/sim/tools/shopify/create_customer.ts b/apps/sim/tools/shopify/create_customer.ts index e56d1f2c48d..56460768ec4 100644 --- a/apps/sim/tools/shopify/create_customer.ts +++ b/apps/sim/tools/shopify/create_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCreateCustomerParams, ShopifyCustomerResponse } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -69,7 +70,7 @@ export const shopifyCreateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_fulfillment.ts b/apps/sim/tools/shopify/create_fulfillment.ts index 05435306d17..76ca156c5dd 100644 --- a/apps/sim/tools/shopify/create_fulfillment.ts +++ b/apps/sim/tools/shopify/create_fulfillment.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCreateFulfillmentParams, ShopifyFulfillmentResponse, @@ -61,7 +62,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts index dd1fb16c8d3..3060658be9c 100644 --- a/apps/sim/tools/shopify/create_product.ts +++ b/apps/sim/tools/shopify/create_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCreateProductParams, ShopifyProductResponse } from '@/tools/shopify/types' import { PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -63,7 +64,7 @@ export const shopifyCreateProductTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_customer.ts b/apps/sim/tools/shopify/delete_customer.ts index 010cf109c83..7b3611872fb 100644 --- a/apps/sim/tools/shopify/delete_customer.ts +++ b/apps/sim/tools/shopify/delete_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyDeleteCustomerParams, ShopifyDeleteResponse } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -32,7 +33,7 @@ export const shopifyDeleteCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_product.ts b/apps/sim/tools/shopify/delete_product.ts index 42b3aa843fa..40a7e2dd300 100644 --- a/apps/sim/tools/shopify/delete_product.ts +++ b/apps/sim/tools/shopify/delete_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyDeleteProductParams, ShopifyDeleteResponse } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -32,7 +33,7 @@ export const shopifyDeleteProductTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts index 6cf7afa8dd6..d137b12c08a 100644 --- a/apps/sim/tools/shopify/get_collection.ts +++ b/apps/sim/tools/shopify/get_collection.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCollectionResponse, ShopifyGetCollectionParams } from '@/tools/shopify/types' import { COLLECTION_WITH_PRODUCTS_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyGetCollectionTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_customer.ts b/apps/sim/tools/shopify/get_customer.ts index e7657df42c7..9763831091d 100644 --- a/apps/sim/tools/shopify/get_customer.ts +++ b/apps/sim/tools/shopify/get_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCustomerResponse, ShopifyGetCustomerParams } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -31,7 +32,7 @@ export const shopifyGetCustomerTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts index 215bd731a36..394796f8e72 100644 --- a/apps/sim/tools/shopify/get_inventory_level.ts +++ b/apps/sim/tools/shopify/get_inventory_level.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyGetInventoryLevelParams, ShopifyInventoryResponse, @@ -42,7 +43,7 @@ export const shopifyGetInventoryLevelTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_order.ts b/apps/sim/tools/shopify/get_order.ts index 65f69ddaa02..be9fbadf279 100644 --- a/apps/sim/tools/shopify/get_order.ts +++ b/apps/sim/tools/shopify/get_order.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyGetOrderParams, ShopifyOrderResponse } from '@/tools/shopify/types' import { ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -30,7 +31,7 @@ export const shopifyGetOrderTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_product.ts b/apps/sim/tools/shopify/get_product.ts index e99c3b1140f..e024ff6d877 100644 --- a/apps/sim/tools/shopify/get_product.ts +++ b/apps/sim/tools/shopify/get_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyGetProductParams, ShopifyProductResponse } from '@/tools/shopify/types' import { PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -30,7 +31,7 @@ export const shopifyGetProductTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts index bc7bd3e2f0e..af8a8c26fbf 100644 --- a/apps/sim/tools/shopify/list_collections.ts +++ b/apps/sim/tools/shopify/list_collections.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCollectionsResponse, ShopifyListCollectionsParams, @@ -44,7 +45,7 @@ export const shopifyListCollectionsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_customers.ts b/apps/sim/tools/shopify/list_customers.ts index 0ecc1ab31e5..ed68a448359 100644 --- a/apps/sim/tools/shopify/list_customers.ts +++ b/apps/sim/tools/shopify/list_customers.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCustomersResponse, ShopifyListCustomersParams } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyListCustomersTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts index 57de928cd96..9f74f969f80 100644 --- a/apps/sim/tools/shopify/list_inventory_items.ts +++ b/apps/sim/tools/shopify/list_inventory_items.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import { INVENTORY_ITEM_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES, @@ -44,7 +45,7 @@ export const shopifyListInventoryItemsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts index f51817b23a3..0aba9f82e3a 100644 --- a/apps/sim/tools/shopify/list_locations.ts +++ b/apps/sim/tools/shopify/list_locations.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyListLocationsParams, ShopifyLocationsResponse } from '@/tools/shopify/types' import { LOCATION_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyListLocationsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_orders.ts b/apps/sim/tools/shopify/list_orders.ts index 4d818bde393..0d69519c6d6 100644 --- a/apps/sim/tools/shopify/list_orders.ts +++ b/apps/sim/tools/shopify/list_orders.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyListOrdersParams, ShopifyOrdersResponse } from '@/tools/shopify/types' import { ORDER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -43,7 +44,7 @@ export const shopifyListOrdersTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_products.ts b/apps/sim/tools/shopify/list_products.ts index ceb6cae7d23..e8c15fca72c 100644 --- a/apps/sim/tools/shopify/list_products.ts +++ b/apps/sim/tools/shopify/list_products.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyListProductsParams, ShopifyProductsResponse } from '@/tools/shopify/types' import { PAGE_INFO_OUTPUT_PROPERTIES, PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyListProductsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/types.ts b/apps/sim/tools/shopify/types.ts index d010c0b0cb9..f735931218c 100644 --- a/apps/sim/tools/shopify/types.ts +++ b/apps/sim/tools/shopify/types.ts @@ -847,12 +847,6 @@ export interface ShopifyAdjustInventoryParams extends ShopifyBaseParams { delta: number } -interface ShopifySetInventoryParams extends ShopifyBaseParams { - inventoryItemId: string - locationId: string - quantity: number -} - // Fulfillment Tool Params export interface ShopifyCreateFulfillmentParams extends ShopifyBaseParams { fulfillmentOrderId: string diff --git a/apps/sim/tools/shopify/update_customer.ts b/apps/sim/tools/shopify/update_customer.ts index af3ee478cbd..4a85667ad90 100644 --- a/apps/sim/tools/shopify/update_customer.ts +++ b/apps/sim/tools/shopify/update_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCustomerResponse, ShopifyUpdateCustomerParams } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -69,7 +70,7 @@ export const shopifyUpdateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_order.ts b/apps/sim/tools/shopify/update_order.ts index 9e34c3476aa..d03934a16a4 100644 --- a/apps/sim/tools/shopify/update_order.ts +++ b/apps/sim/tools/shopify/update_order.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyOrderResponse, ShopifyUpdateOrderParams } from '@/tools/shopify/types' import { ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -48,7 +49,7 @@ export const shopifyUpdateOrderTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts index 771dca61343..9ae243b4757 100644 --- a/apps/sim/tools/shopify/update_product.ts +++ b/apps/sim/tools/shopify/update_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyProductResponse, ShopifyUpdateProductParams } from '@/tools/shopify/types' import { PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -69,7 +70,7 @@ export const shopifyUpdateProductTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { From 84e7aad633b67ce29c6fb93d67c4c0745100c670 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 13:44:37 -0700 Subject: [PATCH 11/14] improvement(slack): request the approved mention/assistant/DM scopes, advertised on v2 only (#5898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack app review has since approved `app_mentions:read`, `assistant:write`, and `im:history` — they are live in the prod app manifest's `oauth_config.scopes.bot` — but the repo still had them commented out behind a stale "re-add once approved" TODO. Sim was therefore requesting a narrower grant than the app is entitled to, so newly connected accounts got tokens missing exactly the scopes backing three `simSubscribed` events on the native Sim app trigger: `app_mention` (app_mentions:read), assistant threads (assistant:write), and DMs (im:history). The requested scope set now matches the manifest's 20 approved bot scopes exactly. Scopes are per-credential, not per-block (one Slack app -> one `slack` provider -> one shared token, requested server-side via getCanonicalScopesForProvider), so the grant itself cannot be scoped to v2. What is per-block is what each picker advertises and treats as missing, so: - slack_v2 + the slack_oauth trigger advertise the full set (they host the native Sim app trigger that needs it). - The legacy v1 block stays pinned to the pre-expansion 17 scopes. It has no feature needing the new three, and advertising them there would flag every existing Slack credential as missing scopes and prompt a needless reconnect. Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz Co-authored-by: Claude Opus 4.8 (1M context) --- apps/sim/blocks/blocks/slack.ts | 24 +++++++++++++++++++++++- apps/sim/lib/oauth/oauth.ts | 9 +++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 1792db96c52..0cdb2266280 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -7,6 +7,25 @@ import { normalizeFileInput } from '@/blocks/utils' import type { SlackResponse } from '@/tools/slack/types' import { getTrigger } from '@/triggers' +/** + * Scopes added for the native Sim Slack app trigger (`slack_oauth`): the shared + * app's `app_mention`, assistant-thread, and DM events don't deliver without + * them. Advertised by `slack_v2` and the trigger only — the legacy block has no + * feature that needs them, and listing them there would flag every existing + * Slack credential as missing scopes and prompt a reconnect. + * + * This only controls what each picker *advertises* and treats as missing. The + * authorization request itself is provider-wide + * (`getCanonicalScopesForProvider('slack')`), so any reconnect grants the full + * set regardless of which block started it. + */ +const SLACK_V2_ONLY_SCOPES = new Set(['app_mentions:read', 'assistant:write', 'im:history']) + +/** Slack scopes the legacy v1 block advertises — the set from before the trigger expansion. */ +const SLACK_V1_ADVERTISED_SCOPES = getScopesForService('slack').filter( + (scope) => !SLACK_V2_ONLY_SCOPES.has(scope) +) + export const SlackBlock: BlockConfig = { type: 'slack', name: 'Slack', @@ -107,7 +126,7 @@ export const SlackBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'slack', - requiredScopes: getScopesForService('slack'), + requiredScopes: SLACK_V1_ADVERTISED_SCOPES, placeholder: 'Select Slack workspace', dependsOn: ['authMethod'], condition: { @@ -2642,6 +2661,9 @@ function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { ...rest, credentialKind: 'any', placeholder: 'Select Slack account or bot', + // Full set, unlike v1: v2 hosts the native Sim app trigger, whose events + // need the mention/assistant/DM scopes. + requiredScopes: getScopesForService('slack'), credentialLabels: { oauthGroup: 'Sim app', oauthConnect: 'Connect the Sim app', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 99477a53525..1d74cac2b63 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -782,12 +782,9 @@ export const OAUTH_PROVIDERS: Record = { 'groups:write', 'chat:write', 'chat:write.public', - // TODO: Re-add once Slack app review approves these. Requesting a scope - // the app is not yet approved for makes Slack reject the entire - // authorization with "unapproved permissions requested", breaking connect. - // 'assistant:write', - // 'app_mentions:read', - // 'im:history', + 'assistant:write', + 'app_mentions:read', + 'im:history', 'im:write', 'im:read', 'users:read', From 5c427795a224b75d2d89dd83328aca33d8c99ce7 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 14:08:09 -0700 Subject: [PATCH 12/14] fix(confluence): preserve panel/callout macro semantics through sync (#5896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(confluence): preserve panel/callout macro semantics through sync Confluence's rendered view HTML wraps Info/Note/Warning/Tip and custom Panel macros in divs whose class/color convey meaning that the shared htmlToPlainText tag-stripper discards along with the tags — a red "do not use" warning panel becomes indistinguishable from a plain paragraph once flattened, so RAG has no signal that a bullet under it is an exclusion rule rather than a normal one. Adds preserveConfluenceCallouts, a Confluence-specific pre-pass that rewrites each detected panel into a single bracketed label (e.g. "[WARNING] Do NOT use this form for: GitLab") before the generic plain-text conversion runs, so the callout semantic survives both the tag strip and htmlToPlainText's trailing whitespace collapse. Bumps the connector's content-representation marker so already-synced pages get one automatic re-hydration under the new extraction, rather than silently keeping their stale flattened content until their next edit. * fix(confluence): preserve word boundaries when extracting callout body text Greptile P1: cheerio's .text() concatenates every descendant text node with no separator, so pulling a macro body's text in one call fused adjacent blocks together (e.g. a paragraph ending in "for:" immediately followed by a list item "GitLab" became "for:GitLab"), corrupting the exact word boundaries RAG chunking and keyword matching depend on. extractBlockJoinedText now extracts each paragraph/list-item/heading/cell/quote individually and joins them with a single space, keeping every block's text intact and properly separated, matching how htmlToPlainText already treats the rest of the page. * fix(confluence): fix nested-block duplication in callout text extraction Greptile P1: filtering the found blocks to only top-level ones still wasn't enough — a nested block (an outer
  • containing its own nested
    • , a containing a
      ) matched the selector once, but its .text() call recurses into and flattens its own matched descendants with no separator, reproducing the exact word-fusion bug one level deeper (and any duplicate-selection would have double-counted the same text). Replaces the block-selector approach with a recursive text-node walk: extractBlockJoinedText now visits every text node individually and joins them all with a single space, so word boundaries are preserved at every nesting depth with no double-counting, matching the pattern html-parser.ts already uses elsewhere in this codebase for the same class of problem. * fix(confluence): apply the same word-boundary-safe extraction to panel headers Greptile: panelHeader text extraction was left on the plain .text() call while panel/macro body extraction was already fixed to use extractBlockJoinedText, so a rich multi-node header (e.g. Warning: followed by a sibling ) could still fuse into "Warning:Do not use" with no space. Panel headers now go through the same recursive text-node walk as bodies, for consistency across every text extraction in this file. * fix(confluence): distinguish inline formatting from block boundaries in extraction Greptile: the recursive text-node walk unconditionally inserted a space between every text node, which fixed block-boundary fusion but broke genuinely inline-formatted text — "unbelieveable" became "un believe able" and "Hello!" became "Hello !", corrupting valid callout content on its way into the index. Adds an INLINE_FORMATTING_TAGS allowlist (b, strong, i, em, span, a, etc.): text flowing through those tags accumulates with no artificial separator, preserving exact source adjacency, while every other tag boundary (p, li, td, headings, br, ...) still flushes to a new segment — a block always implies a break even with no literal whitespace in the source, but an inline tag never does. Fixed one test that had encoded the old, incorrect expectation for two genuinely adjacent inline tags with no source whitespace between them, and added regression tests for mid-word inline formatting, punctuation attached to an inline tag, and a header with real source spacing. * fix(confluence): process nested panels/macros innermost-first Cursor: processing matches in document order (outermost first) read a nested, not-yet-converted panel/macro as plain body text before it ever got its own bracketed label, silently dropping the inner callout's type. Worse, an untitled outer panel's `.find('.panelHeader')` could reach past its own missing header into a nested panel's header and adopt it as its own title. Replaces the two independent .each() passes with a loop that converts only "leaf" macros (no remaining nested macro/panel inside them) and repeats until none are left. This processes innermost-first, so a nested macro is already its own bracketed

      by the time its parent's body/header text is read, and an untitled outer panel's .find() can no longer reach a header that isn't its own, since a leaf by definition has no nested panel left to reach into. * test(confluence): add explicit regression for the exact reported
      repro Formalizes an explicit test for the exact
      -separated string Greptile's review cited as broken (verified manually not to reproduce, but wasn't directly asserted in the suite before this). --- .../connectors/confluence/confluence.test.ts | 252 +++++++++++++++++- apps/sim/connectors/confluence/confluence.ts | 160 ++++++++++- 2 files changed, 407 insertions(+), 5 deletions(-) diff --git a/apps/sim/connectors/confluence/confluence.test.ts b/apps/sim/connectors/confluence/confluence.test.ts index 706023ccb93..25b3dfff5cb 100644 --- a/apps/sim/connectors/confluence/confluence.test.ts +++ b/apps/sim/connectors/confluence/confluence.test.ts @@ -2,7 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { escapeCql, isCurrentContent } from '@/connectors/confluence/confluence' +import { + escapeCql, + isCurrentContent, + preserveConfluenceCallouts, +} from '@/connectors/confluence/confluence' +import { htmlToPlainText } from '@/connectors/utils' describe('escapeCql', () => { it.concurrent('returns plain strings unchanged', () => { @@ -48,3 +53,248 @@ describe('isCurrentContent', () => { expect(isCurrentContent({ id: '1', status: 'deleted' })).toBe(false) }) }) + +describe('preserveConfluenceCallouts', () => { + it.concurrent('handles empty content', () => { + expect(preserveConfluenceCallouts('')).toBe('') + }) + + it.concurrent('leaves content with no macros unchanged', () => { + const html = '

      Just a normal paragraph.

      ' + expect(preserveConfluenceCallouts(html)).toContain('Just a normal paragraph.') + }) + + it.concurrent('labels a built-in warning macro and keeps its body', () => { + const html = + '
      ' + + '' + + '

      Do NOT use this form for GitLab access.

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[WARNING]') + expect(result).toContain('Do NOT use this form for GitLab access.') + }) + + it.concurrent('labels a built-in info macro', () => { + const html = + '
      ' + + '

      Heads up.

      ' + + '
      ' + expect(preserveConfluenceCallouts(html)).toContain('[INFO] Heads up.') + }) + + it.concurrent('labels a built-in note macro', () => { + const html = + '
      ' + + '

      See also.

      ' + + '
      ' + expect(preserveConfluenceCallouts(html)).toContain('[NOTE] See also.') + }) + + it.concurrent('labels a built-in tip macro', () => { + const html = + '
      ' + + '

      Pro tip.

      ' + + '
      ' + expect(preserveConfluenceCallouts(html)).toContain('[TIP] Pro tip.') + }) + + it.concurrent('labels a generic custom-colored Panel macro using its header title', () => { + const html = + '
      ' + + '
      Do NOT use this form for:
      ' + + '

      GitLab access requests go to the private channel instead.

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Do NOT use this form for:]') + expect(result).toContain('GitLab access requests go to the private channel instead.') + }) + + it.concurrent('preserves word boundaries between a block header and its own content', () => { + const html = + '
      Warning:
      ' + + '

      See replacement form.

      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Warning:] See replacement form.') + }) + + it.concurrent( + 'keeps a rich header with a real source space intact, without adding a second one', + () => { + const html = + '
      Warning: Do not use
      ' + + '

      See replacement form.

      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Warning: Do not use]') + } + ) + + it.concurrent('falls back to a bare CALLOUT label when a Panel macro has no header text', () => { + const html = + '

      Untitled panel body.

      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT]') + expect(result).toContain('Untitled panel body.') + }) + + it.concurrent( + 'keeps the exclusion marker attached to its content through htmlToPlainText, even across surrounding whitespace collapse', + () => { + const html = + '

      Intro paragraph.

      \n\n' + + '
      ' + + '

      Do NOT use this form for:

      ' + + '
      • GitLab
      ' + + '
      \n\n' + + '

      Trailing paragraph.

      ' + const plainText = htmlToPlainText(preserveConfluenceCallouts(html)) + expect(plainText).toContain('[WARNING] Do NOT use this form for: GitLab') + expect(plainText).toContain('Intro paragraph.') + expect(plainText).toContain('Trailing paragraph.') + } + ) + + it.concurrent( + 'does not fuse adjacent paragraph and list-item text together (word-boundary regression)', + () => { + const html = + '
      ' + + '
      ' + + '

      Do NOT use this form for:

      ' + + '
      • GitLab
      • ServiceNow
      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('for:GitLab') + expect(result).not.toContain('GitLabServiceNow') + expect(result).toContain('Do NOT use this form for: GitLab ServiceNow') + } + ) + + it.concurrent( + 'preserves word boundaries across multiple paragraphs in a generic Panel macro', + () => { + const html = + '
      ' + + '

      First sentence.

      Second sentence.

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('First sentence. Second sentence.') + expect(result).not.toContain('sentence.Second') + } + ) + + it.concurrent( + 'does not duplicate or fuse text from a nested list inside a callout body (nesting regression)', + () => { + const html = + '
      ' + + '
      ' + + '
      • Outer item' + + '
        • Nested item A
        • Nested item B
        ' + + '
      • Outer item two
      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + // Each nested
    • 's text must appear exactly once, not duplicated by the + // outer
    • also being matched and its .text() recursing into it. + const occurrences = (result.match(/Nested item A/g) ?? []).length + expect(occurrences).toBe(1) + expect(result).not.toContain('Nested item ANested item B') + expect(result).toContain('Outer item Nested item A Nested item B Outer item two') + } + ) + + it.concurrent('does not fuse text from a blockquote nested inside a table cell', () => { + const html = + '
      ' + + '
      Cell text

      quoted text

      after quote
      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('quotedtext') + expect(result).not.toContain('textafter') + expect(result).toContain('Cell text quoted text after quote') + }) + + it.concurrent( + 'does not inject an artificial space into inline-formatted text mid-word (inline vs. block regression)', + () => { + const html = + '
      ' + + '

      This is unbelieveable.

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('un believe able') + expect(result).toContain('This is unbelieveable.') + } + ) + + it.concurrent('does not inject a space before punctuation carried by an inline tag', () => { + const html = + '
      ' + + '

      Do not proceed!

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('proceed !') + expect(result).toContain('[WARNING] Do not proceed!') + }) + + it.concurrent('keeps natural word spacing when inline tags wrap a whole word', () => { + const html = + '
      ' + + '

      Do NOT use this form.

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('Do NOT use this form.') + }) + + it.concurrent( + 'labels a nested panel-in-panel with both its own and its parent label (nesting regression)', + () => { + const html = + '
      Outer
      ' + + '
      Inner
      ' + + '

      inner body

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Outer]') + expect(result).toContain('[CALLOUT: Inner] inner body') + } + ) + + it.concurrent( + 'labels a nested info-macro inside a panel with its own type instead of dropping it', + () => { + const html = + '
      ' + + '
      ' + + '

      Do not use this.

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[WARNING] Do not use this.') + } + ) + + it.concurrent( + "does not let an untitled outer panel adopt a nested panel's header as its own", + () => { + const html = + '
      ' + + '
      Inner title
      ' + + '

      inner body

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + // The outer panel has no header of its own — it must fall back to a + // bare [CALLOUT], not steal "Inner title" from the nested panel. + expect(result).toContain('[CALLOUT] [CALLOUT: Inner title] inner body') + } + ) + + it.concurrent('does not fuse text on either side of a
      line break', () => { + const html = + '
      ' + + '

      Do NOT use this form for:
      GitLab

      ' + + '
      ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('for:GitLab') + expect(result).toContain('[WARNING] Do NOT use this form for: GitLab') + }) +}) diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 47cf4f7a626..470cdc8ab68 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import * as cheerio from 'cheerio' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' @@ -8,6 +9,155 @@ import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/con const logger = createLogger('ConfluenceConnector') +/** Label prefixes for Confluence's built-in Info/Note/Warning/Tip macros, by their rendered CSS suffix. */ +const CALLOUT_LABELS: Record = { + information: '[INFO]', + note: '[NOTE]', + warning: '[WARNING]', + tip: '[TIP]', + error: '[ERROR]', +} + +/** + * Inline formatting tags whose text flows directly into their surrounding + * sentence with no implied word break — e.g. `unbelieveable` must stay + * `unbelievable`, and `Hello!` must stay `Hello!`, not gain an + * artificial space. Anything not in this set (p, li, td, div, headings, br, + * etc.) is treated as a block boundary that always implies a break, even when + * the source HTML has no literal whitespace there. + */ +const INLINE_FORMATTING_TAGS = new Set([ + 'b', + 'strong', + 'i', + 'em', + 'u', + 's', + 'strike', + 'del', + 'ins', + 'sup', + 'sub', + 'small', + 'mark', + 'code', + 'span', + 'a', + 'abbr', + 'cite', + 'q', + 'kbd', + 'var', + 'samp', + 'time', +]) + +/** + * Cheerio's `.text()` concatenates every descendant text node with no + * separator at all, so pulling a macro body's text in one call fuses adjacent + * blocks together (e.g. a `

      ...for:

      ` immediately followed by + * `
    • GitLab
    • ` becomes `for:GitLab`, corrupting the very word boundaries + * RAG chunking depends on). Simply joining every text node with a space isn't + * right either — that would corrupt genuinely inline-formatted text the same + * way. This walks the DOM, accumulating text through inline tags without a + * separator (preserving exact source adjacency) and flushing to a new segment + * at every other tag boundary (a block always implies a break, regardless of + * source whitespace) — matching how `html-parser.ts` already walks HTML for a + * related reason elsewhere in this codebase, extended with the inline/block + * distinction real Confluence rich text requires. + */ +function extractBlockJoinedText($: cheerio.CheerioAPI, $el: cheerio.Cheerio): string { + const parts: string[] = [] + let current = '' + + const flush = () => { + const text = current.trim() + if (text) parts.push(text) + current = '' + } + + const visit = ($node: cheerio.Cheerio) => { + $node.contents().each((_, child) => { + if (child.type === 'text') { + current += $(child).text() + } else if (child.type === 'tag') { + const tag = child.tagName?.toLowerCase() + if (tag && INLINE_FORMATTING_TAGS.has(tag)) { + visit($(child)) + } else { + flush() + visit($(child)) + flush() + } + } + }) + } + + visit($el) + flush() + return parts.join(' ').trim() +} + +/** Matches either flavor of panel/macro this function rewrites. */ +const MACRO_SELECTOR = 'div.confluence-information-macro, div.panel' + +/** + * Confluence's rendered `view` HTML wraps Info/Note/Warning/Tip macros in + * `confluence-information-macro confluence-information-macro-{type}` divs, and + * the customizable Panel macro in `.panel` > `.panelHeader` + `.panelContent` + * divs. `htmlToPlainText`'s blind tag-stripping discards the divs' classes along + * with the tags, so a red "do not use" warning panel becomes indistinguishable + * from a plain paragraph once flattened — and its trailing whitespace collapse + * would erase any newline-based separation too. Each detected panel is rewritten + * into a single bracketed label plus its own text so the callout semantic + * survives both the tag strip and the whitespace collapse. + * + * A panel can itself contain another panel or macro (e.g. a nested Note inside + * a Warning panel). Processing matches in document order — outermost first — + * would read a not-yet-converted nested macro as plain body text before it + * ever got its own label, silently dropping the inner callout's semantic, and + * `.find('.panelHeader')` would then risk pulling a nested panel's header up + * as if it were the outer panel's own title. Converting only "leaf" macros + * (ones with no remaining nested macro/panel inside them) and repeating until + * none are left processes innermost-first, so a nested macro is already a + * bracketed `

      ` by the time its parent's body/header text is read — at which + * point it correctly reads as plain text carrying its own label. + */ +export function preserveConfluenceCallouts(html: string): string { + if (!html) return html + + const $ = cheerio.load(html) + + let progressed = true + while (progressed) { + progressed = false + const leaves = $(MACRO_SELECTOR).filter((_, el) => $(el).find(MACRO_SELECTOR).length === 0) + if (leaves.length === 0) break + + leaves.each((_, el) => { + const $el = $(el) + if ($el.hasClass('confluence-information-macro')) { + const type = ($el.attr('class') ?? '') + .match(/confluence-information-macro-(\w+)/)?.[1] + ?.toLowerCase() + const label = (type && CALLOUT_LABELS[type]) || CALLOUT_LABELS.information + const macroBody = $el.find('.confluence-information-macro-body').first() + const body = extractBlockJoinedText($, macroBody.length > 0 ? macroBody : $el) + $el.replaceWith($('

      ').text(`${label} ${body}`)) + } else { + const headerText = extractBlockJoinedText($, $el.find('.panelHeader').first()) + const panelContent = $el.find('.panelContent').first() + const bodyText = extractBlockJoinedText($, panelContent.length > 0 ? panelContent : $el) + const label = headerText ? `[CALLOUT: ${headerText}]` : '[CALLOUT]' + $el.replaceWith($('

      ').text(`${label} ${bodyText}`)) + } + progressed = true + }) + } + + return $.html() +} + /** * Escapes a value for use inside CQL double-quoted strings. */ @@ -108,10 +258,12 @@ async function fetchLabelsForPages( * invalidates every previously-synced Confluence document so a one-time * re-hydration picks up content newly reachable by the current extraction * (e.g. the switch from `storage` to rendered `view`, which expands Include - * Page / Excerpt macros). Without it, already-indexed pages whose version is - * unchanged classify as `unchanged` and keep their stale (empty) content. + * Page / Excerpt macros; or `preserveConfluenceCallouts`, which stops + * flattening panel/info/note/warning/tip macros into indistinguishable plain + * text). Without it, already-indexed pages whose version is unchanged + * classify as `unchanged` and keep their stale (pre-fix) content. */ -const CONTENT_REPRESENTATION = 'view' +const CONTENT_REPRESENTATION = 'view-callouts' /** * Produces a canonical metadata stub with a deterministic contentHash that @@ -288,7 +440,7 @@ export const confluenceConnector: ConnectorConfig = { const body = page.body as Record | undefined const view = body?.view as Record | undefined const rawContent = (view?.value as string) || '' - const plainText = htmlToPlainText(rawContent) + const plainText = htmlToPlainText(preserveConfluenceCallouts(rawContent)) const labelMap = await fetchLabelsForPages(cloudId, accessToken, [String(page.id)]) const labels = labelMap.get(String(page.id)) ?? [] From e737901b0b52bded5a4d87c387a8eff5a128f1ad Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 14:14:16 -0700 Subject: [PATCH 13/14] chore(blocks): rename webhook block (#5900) --- .../workflow-preview/block-display-workflows.ts | 4 ++-- .../content/docs/en/workflows/blocks/webhook.mdx | 14 +++++++------- .../panel/components/toolbar/toolbar.tsx | 4 ++-- apps/sim/blocks/blocks/generic_webhook.ts | 4 ++-- apps/sim/blocks/blocks/webhook_request.ts | 2 +- apps/sim/lib/workflows/triggers/trigger-utils.ts | 10 +--------- apps/sim/lib/workflows/triggers/triggers.ts | 3 --- apps/sim/stores/modals/search/store.ts | 2 +- apps/sim/triggers/generic/webhook.ts | 2 +- 9 files changed, 17 insertions(+), 28 deletions(-) diff --git a/apps/docs/components/workflow-preview/block-display-workflows.ts b/apps/docs/components/workflow-preview/block-display-workflows.ts index aa4e2cf326c..eb456340eac 100644 --- a/apps/docs/components/workflow-preview/block-display-workflows.ts +++ b/apps/docs/components/workflow-preview/block-display-workflows.ts @@ -326,11 +326,11 @@ export const BLOCK_DISPLAY_WORKFLOWS: Record = { }, webhook_trigger: { id: 'webhook_trigger', - name: 'Webhook', + name: 'Webhook Trigger', blocks: [ { id: 'webhook_trigger', - name: 'Webhook', + name: 'Webhook Trigger', type: 'webhook', bgColor: '#10B981', position: { x: 0, y: 0 }, diff --git a/apps/docs/content/docs/en/workflows/blocks/webhook.mdx b/apps/docs/content/docs/en/workflows/blocks/webhook.mdx index 374fd03b913..845347e340c 100644 --- a/apps/docs/content/docs/en/workflows/blocks/webhook.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/webhook.mdx @@ -1,6 +1,6 @@ --- -title: Outgoing Webhook -description: The Outgoing Webhook block sends an HTTP POST to an external endpoint, with automatic headers and optional signing. +title: Webhook +description: The Webhook block sends an HTTP POST to an external endpoint, with automatic headers and optional signing. pageType: reference --- @@ -8,7 +8,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { BlockPreview, WorkflowPreview, WEBHOOK_NOTIFY_WORKFLOW, WEBHOOK_TRIGGER_WORKFLOW } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The Outgoing Webhook block sends HTTP POST requests to external webhook endpoints with automatic webhook headers and optional HMAC signing. +The Webhook block sends HTTP POST requests to external webhook endpoints with automatic webhook headers and optional HMAC signing. @@ -77,16 +77,16 @@ Format the result, then POST it to a Slack, Discord, or custom endpoint. -When the Condition passes, the Outgoing Webhook starts a process in another system. +When the Condition passes, the Webhook starts a process in another system. -The Outgoing Webhook block always uses POST. For other HTTP methods or more control, use the [API block](/workflows/blocks/api). +The Webhook block always uses POST. For other HTTP methods or more control, use the [API block](/workflows/blocks/api). diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 06928903968..7abb72d398c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -173,14 +173,14 @@ function syncCachesToOverlayVersion(version: number) { /** * Gets triggers data, computing it once per overlay version and caching for - * subsequent calls. Non-integration triggers (Start, Schedule, Webhook) are + * subsequent calls. Non-integration triggers (Start, Schedule, Webhook Trigger) are * prioritized first, followed by all other triggers sorted alphabetically. */ function getTriggers(overlayVersion: number): BlockItem[] { syncCachesToOverlayVersion(overlayVersion) if (cachedTriggers === null) { const allTriggers = getTriggersForSidebar() - const priorityOrder = ['Start', 'Schedule', 'Webhook'] + const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] const sortedTriggers = allTriggers.sort((a, b) => { const aIndex = priorityOrder.indexOf(a.name) diff --git a/apps/sim/blocks/blocks/generic_webhook.ts b/apps/sim/blocks/blocks/generic_webhook.ts index 5fa4a94e8a5..6d83b3860ed 100644 --- a/apps/sim/blocks/blocks/generic_webhook.ts +++ b/apps/sim/blocks/blocks/generic_webhook.ts @@ -8,7 +8,7 @@ const WebhookIcon = (props: SVGProps) => createElement(Webhook, p export const GenericWebhookBlock: BlockConfig = { type: 'generic_webhook', - name: 'Webhook', + name: 'Webhook Trigger', description: 'Receive webhooks from any service by configuring a custom webhook.', category: 'triggers', icon: WebhookIcon, @@ -17,7 +17,7 @@ export const GenericWebhookBlock: BlockConfig = { triggerAllowed: true, bestPractices: ` - You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}' - - Continuing example above, the body can be accessed in downstream block using dot notation. E.g. and + - Continuing example above, the body can be accessed in downstream block using dot notation. E.g. and - To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped. - Only use when there's no existing integration for the service with triggerAllowed flag set to true. `, diff --git a/apps/sim/blocks/blocks/webhook_request.ts b/apps/sim/blocks/blocks/webhook_request.ts index b34e78413d4..6fad3395995 100644 --- a/apps/sim/blocks/blocks/webhook_request.ts +++ b/apps/sim/blocks/blocks/webhook_request.ts @@ -4,7 +4,7 @@ import type { RequestResponse } from '@/tools/http/types' export const WebhookRequestBlock: BlockConfig = { type: 'webhook_request', - name: 'Outgoing Webhook', + name: 'Webhook', description: 'Send a webhook request', longDescription: 'Send an HTTP POST request to a webhook URL with automatic webhook headers. Optionally sign the payload with HMAC-SHA256 for secure webhook delivery.', diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 50fb1b1938d..4f55dd0bbb7 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -1,11 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' -import { - type StartBlockCandidate, - StartBlockPath, - TRIGGER_TYPES, -} from '@/lib/workflows/triggers/triggers' +import { type StartBlockCandidate, StartBlockPath } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' @@ -239,10 +235,6 @@ export function getTriggerDisplayName(blockType: string): string { const block = getBlock(blockType) if (!block) return blockType - if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK) { - return 'Webhook' - } - return block.name } diff --git a/apps/sim/lib/workflows/triggers/triggers.ts b/apps/sim/lib/workflows/triggers/triggers.ts index a7b5077afcb..6fe61714756 100644 --- a/apps/sim/lib/workflows/triggers/triggers.ts +++ b/apps/sim/lib/workflows/triggers/triggers.ts @@ -356,9 +356,6 @@ export class TriggerUtils { // Use the block's actual name from the registry const block = getBlock(triggerType) if (block) { - if (triggerType === TRIGGER_TYPES.GENERIC_WEBHOOK) { - return 'Webhook' - } return block.name } diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index efce810b781..7084b591044 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -145,7 +145,7 @@ export const useSearchModalStore = create()( const allTriggers = getTriggersForSidebar() const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers - const priorityOrder = ['Start', 'Schedule', 'Webhook'] + const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] const sortedTriggers = [...filteredTriggers].sort( (a: (typeof filteredTriggers)[number], b: (typeof filteredTriggers)[number]) => { diff --git a/apps/sim/triggers/generic/webhook.ts b/apps/sim/triggers/generic/webhook.ts index 9fc953817af..c546c4d4f5c 100644 --- a/apps/sim/triggers/generic/webhook.ts +++ b/apps/sim/triggers/generic/webhook.ts @@ -4,7 +4,7 @@ import type { TriggerConfig } from '@/triggers/types' export const genericWebhookTrigger: TriggerConfig = { id: 'generic_webhook', - name: 'Generic Webhook', + name: 'Webhook Trigger', provider: 'generic', description: 'Receive webhooks from any service or API', version: '1.0.0', From a5e1030e9e175124fe957c8e06153a85a8709c1e Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 14:17:52 -0700 Subject: [PATCH 14/14] fix(mcp): stream guarded transport via undici.request so SSE responses work under Bun (#5897) * fix(mcp): stream guarded transport via undici.request so SSE responses work under Bun undici's fetch exposes its response body as a WHATWG ReadableStream whose bridge is broken under the Bun runtime the standalone server runs on: headers arrive but response.body never yields data, hanging every MCP streamable-HTTP (text/event-stream) tools/list read to its 30s timeout. undici's lower-level request() returns a Node Readable, which Bun streams natively. createSsrfGuardedFetchWithDispatcher (the MCP transport builder) now routes through undiciRequestAsResponse: same guarded Agent + connect.lookup (SSRF unchanged), request() instead of fetch(), and a hand-rolled Node->Web body bridge (not Readable.toWeb, which throws ERR_INVALID_STATE on the redirect body.cancel()). Buffered reads, followRedirectsGuarded, maxResponseSize, and abort all preserved and verified on both Node and Bun. * fix(mcp): use a single cast for header-iterable detection to satisfy boundary audit * fix(mcp): handle URLSearchParams OAuth bodies and copy stream chunks - toUndiciRequestBody serializes a URLSearchParams body (the MCP SDK's OAuth token/refresh exchange sends one); undici.request rejects it otherwise. - Default content-type application/x-www-form-urlencoded when the caller didn't set one (fetch parity). - nodeReadableToWebStream enqueues a copy (new Uint8Array(chunk)) not a view, so undici recycling the pooled source buffer can't corrupt queued chunks. - Tests for both. * fix(mcp): decode Content-Encoding on the undici.request transport (fetch parity) undici.request returns raw bytes; fetch auto-decompresses. Restore that: pipe a gzip/deflate/br response body through the matching zlib decoder before the WHATWG bridge and strip content-encoding/content-length. maxResponseSize still caps the compressed wire bytes; errors forward into the decoder and the source is torn down when the decoded stream ends or is cancelled. Adds a gzip decode test. * fix(mcp): guard decoder errors and null-body drain against unhandled crashes - Attach the stream bridge's error listener before piping into the zlib decoder, so a synchronous zlib error (server mislabeling a non-gzip body as gzip) rejects the reader instead of crashing the process. - Attach an error listener before draining a null-body response, and wrap Response construction in try/catch that destroys the source (no socket leak) on an out-of-range status. Adds an invalid-gzip regression test. --- .../guarded-request-fetch.server.test.ts | 232 +++++++++++++++ .../core/security/input-validation.server.ts | 271 +++++++++++++++++- 2 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/core/security/guarded-request-fetch.server.test.ts diff --git a/apps/sim/lib/core/security/guarded-request-fetch.server.test.ts b/apps/sim/lib/core/security/guarded-request-fetch.server.test.ts new file mode 100644 index 00000000000..397b22043e8 --- /dev/null +++ b/apps/sim/lib/core/security/guarded-request-fetch.server.test.ts @@ -0,0 +1,232 @@ +/** + * @vitest-environment node + * + * Covers the `undici.request()`-backed guarded fetch: `createSsrfGuardedFetchWithDispatcher` + * builds its `fetch` on `undici.request` (not `undici.fetch`) because undici's `fetch` never + * delivers a streaming `response.body` under the Bun runtime the server runs on. These tests + * drive the real builder with a mocked `undici.request` and assert the constructed `Response` + * preserves status/headers/url, streams its body, follows redirects via `followRedirectsGuarded`, + * serves buffered reads, and settles the reader when the source is destroyed without an error. + */ +import { Readable } from 'node:stream' +import { gzipSync } from 'node:zlib' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAgent, mockUndiciRequest } = vi.hoisted(() => { + class MockAgent { + close() { + return Promise.resolve() + } + destroy() { + return Promise.resolve() + } + } + return { mockAgent: MockAgent, mockUndiciRequest: vi.fn() } +}) + +vi.mock('undici', () => ({ Agent: mockAgent, request: mockUndiciRequest, fetch: vi.fn() })) + +declare module '@/lib/core/security/input-validation.server?guarded-request-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient re-declaration for the query-suffixed specifier + export * from '@/lib/core/security/input-validation.server' +} + +import { createSsrfGuardedFetchWithDispatcher } from '@/lib/core/security/input-validation.server?guarded-request-test' + +/** A byte stream that yields a single `Buffer` chunk then ends — mirrors undici's body. */ +function byteStream(text: string): Readable { + const stream = new Readable({ read() {} }) + stream.push(Buffer.from(text)) + stream.push(null) + return stream +} + +function undiciReply( + statusCode: number, + headers: Record, + body: Readable +) { + return { statusCode, headers, body, trailers: {}, opaque: null, context: {} } +} + +describe('createSsrfGuardedFetchWithDispatcher (undici.request backed)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs a Response with the reply status, headers, url, and a streaming body', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply( + 200, + { 'content-type': 'text/event-stream', 'mcp-session-id': 's-1' }, + byteStream('event: message\ndata: {"id":1}\n\n') + ) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/serve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"jsonrpc":"2.0"}', + }) + + expect(response.status).toBe(200) + expect(response.headers.get('mcp-session-id')).toBe('s-1') + expect(response.url).toBe('https://mcp.example.com/serve') + + const text = await response.text() + expect(text).toContain('"id":1') + + // Does NOT auto-follow redirects — followRedirectsGuarded drives them instead. + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.method).toBe('POST') + expect(options.headers).toEqual({ 'content-type': 'application/json' }) + expect(options.body).toBe('{"jsonrpc":"2.0"}') + expect(options.maxRedirections).toBeUndefined() + }) + + it('follows a redirect through followRedirectsGuarded and reports the final url', async () => { + mockUndiciRequest + .mockResolvedValueOnce( + undiciReply(302, { location: 'https://mcp.example.com/final' }, byteStream('redirect')) + ) + .mockResolvedValueOnce(undiciReply(200, {}, byteStream('final-body'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/start', { method: 'GET' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(2) + expect(response.status).toBe(200) + expect(response.url).toBe('https://mcp.example.com/final') + expect(await response.text()).toBe('final-body') + }) + + it('supports buffered reads (.json()) through the constructed body', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply( + 200, + { 'content-type': 'application/json' }, + byteStream(JSON.stringify({ ok: true })) + ) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/data', { method: 'GET' }) + + expect(await response.json()).toEqual({ ok: true }) + }) + + it('normalizes a Headers instance and an ArrayBuffer body for undici.request', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('x'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + await fetch('https://mcp.example.com/x', { + method: 'POST', + headers: new Headers({ authorization: 'Bearer t' }), + body: new TextEncoder().encode('payload').buffer, + }) + + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.headers).toEqual({ authorization: 'Bearer t' }) + expect(Buffer.isBuffer(options.body)).toBe(true) + expect(Buffer.from(options.body).toString()).toBe('payload') + }) + + it('serializes a URLSearchParams body and defaults the form content-type (OAuth token exchange)', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('{}'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + await fetch('https://auth.example.com/token', { + method: 'POST', + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'r-1' }), + }) + + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.body).toBe('grant_type=refresh_token&refresh_token=r-1') + expect(options.headers['content-type']).toBe('application/x-www-form-urlencoded;charset=UTF-8') + }) + + it('does not override an explicit content-type on a URLSearchParams body', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('{}'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + await fetch('https://auth.example.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code' }), + }) + + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.body).toBe('grant_type=authorization_code') + expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + expect(options.headers['content-type']).toBeUndefined() + }) + + it('copies each chunk so a recycled source buffer cannot corrupt queued data', async () => { + const source = new Readable({ read() {} }) + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, source)) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/stream', { method: 'GET' }) + const reader = response.body!.getReader() + + // Emit a chunk, then mutate the SAME backing buffer (as undici's pool reuse would). + const buf = Buffer.from('AB') + source.push(buf) + const first = await reader.read() + buf[0] = 0x00 // corrupt the source buffer after the chunk was enqueued + expect(Buffer.from(first.value!).toString()).toBe('AB') // copy is unaffected + source.push(null) + await reader.read() + }) + + it('decodes a gzip Content-Encoding body and strips the framing headers (fetch parity)', async () => { + const gzipped = gzipSync(Buffer.from(JSON.stringify({ ok: true, msg: 'compressed' }))) + const source = new Readable({ read() {} }) + source.push(gzipped) + source.push(null) + mockUndiciRequest.mockResolvedValueOnce( + undiciReply( + 200, + { 'content-type': 'application/json', 'content-encoding': 'gzip', 'content-length': '40' }, + source + ) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/data', { method: 'GET' }) + + expect(await response.json()).toEqual({ ok: true, msg: 'compressed' }) + expect(response.headers.get('content-encoding')).toBeNull() + expect(response.headers.get('content-length')).toBeNull() + }) + + it('rejects the reader (not crash) when Content-Encoding is gzip but the body is not', async () => { + const source = new Readable({ read() {} }) + source.push(Buffer.from('this is definitely not gzip')) + source.push(null) + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' }, source) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/bad', { method: 'GET' }) + + // The zlib error must surface as a rejected read, never an unhandled 'error' event. + await expect(response.text()).rejects.toThrow() + }) + + it('rejects the reader when the source is destroyed without an error (abort/reset)', async () => { + const source = new Readable({ read() {} }) // stays open, never pushes + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, source)) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/hang', { method: 'GET' }) + const reader = response.body!.getReader() + const read = reader.read() + source.destroy() // no error argument — mirrors an aborted/reset socket + + await expect(read).rejects.toThrow(/closed before completing/) + }) +}) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 17de7aa63c8..c8a632e167f 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -1,3 +1,5 @@ +import { Readable } from 'node:stream' +import zlib from 'node:zlib' import dns from 'dns/promises' import http from 'http' import https from 'https' @@ -8,7 +10,13 @@ import { omit } from '@sim/utils/object' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' import * as ipaddr from 'ipaddr.js' -import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' +import { + Agent, + type Dispatcher, + type RequestInit as UndiciRequestInit, + fetch as undiciFetch, + request as undiciRequest, +} from 'undici' import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -619,6 +627,259 @@ export async function followRedirectsGuarded( } } +/** Coerce a DOM/undici `HeadersInit` into the record shape undici `request` accepts. */ +function toUndiciRequestHeaders( + headers: UndiciRequestInit['headers'] +): Record | undefined { + if (!headers) return undefined + const record: Record = {} + if (Array.isArray(headers)) { + for (const [key, value] of headers as [string, string][]) { + if (value != null) record[key] = String(value) + } + return record + } + // Single cast (no `as unknown`): the optional `forEach` is satisfiable by both a plain + // record (absent) and a `Headers` instance (present), so it detects the iterable form. + const iterableHeaders = headers as { + forEach?: (cb: (value: string, key: string) => void) => void + } + if (typeof iterableHeaders.forEach === 'function') { + iterableHeaders.forEach((value, key) => { + record[key] = value + }) + return record + } + for (const [key, value] of Object.entries(headers as Record)) { + if (value != null) record[key] = Array.isArray(value) ? value.join(', ') : String(value) + } + return record +} + +/** Coerce a DOM/undici body init into a value undici `request` accepts. */ +function toUndiciRequestBody( + body: UndiciRequestInit['body'] +): string | Buffer | Uint8Array | Readable | undefined { + if (body == null) return undefined + // fetch accepts URLSearchParams (form-encoded) and undici.request does not — the MCP SDK's + // OAuth token/refresh exchange sends one. Serialize it to its wire form. + if (body instanceof URLSearchParams) return body.toString() + if (body instanceof ArrayBuffer) return Buffer.from(body) + if (ArrayBuffer.isView(body) && !(body instanceof Uint8Array)) { + return Buffer.from(body.buffer, body.byteOffset, body.byteLength) + } + if (typeof (body as ReadableStream).getReader === 'function') { + // double-cast-allowed: DOM ReadableStream and the node:stream Web type differ but are structurally compatible at runtime + return Readable.fromWeb(body as unknown as Parameters[0]) + } + // string, Uint8Array/Buffer, or Readable — passed through unchanged. + // double-cast-allowed: undici BodyInit is wider than what request() accepts; our guarded/pinned callers only send these + return body as unknown as string | Buffer | Uint8Array | Readable +} + +/** + * Decompression transform for a `Content-Encoding`, or `null` to pass the body through. + * `undici.fetch` decodes the body automatically; `undici.request` does not, so this restores + * fetch parity for gzip/deflate/br responses (common behind CDNs). Unknown/absent encodings + * pass through untouched. + */ +function contentEncodingDecoder( + encoding: string +): zlib.Gunzip | zlib.Inflate | zlib.BrotliDecompress | null { + switch (encoding) { + case 'gzip': + case 'x-gzip': + return zlib.createGunzip() + case 'deflate': + return zlib.createInflate() + case 'br': + return zlib.createBrotliDecompress() + default: + return null + } +} + +/** + * Bridges an undici `request()` Node `Readable` into a WHATWG `ReadableStream` for a + * `Response` body. Node's built-in `Readable.toWeb` is NOT used: its adapter throws an + * unhandled `ERR_INVALID_STATE` ("Controller is already closed") when the web stream is + * cancelled while the Node stream is still flowing — which `followRedirectsGuarded` does + * on every redirect hop (`response.body.cancel()`). This bridge instead swallows a late + * enqueue after close and destroys the source on cancel, so cancelling a live body frees + * its socket cleanly. `maxResponseSize` overruns surface as the source's `error` event and + * reject the read, preserving the DoS backstop. + */ +function nodeReadableToWebStream(nodeStream: Readable): ReadableStream { + let settled = false + return new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk: Buffer) => { + try { + // Copy, not a view: undici may recycle the pooled buffer backing `chunk` after + // this handler returns, which would corrupt a chunk still queued for a slow + // consumer. `new Uint8Array(chunk)` allocates a fresh backing buffer. + controller.enqueue(new Uint8Array(chunk)) + } catch { + // Controller already closed (consumer cancelled) — stop the source, drop the chunk. + nodeStream.destroy() + return + } + if ((controller.desiredSize ?? 1) <= 0) nodeStream.pause() + }) + nodeStream.once('end', () => { + settled = true + try { + controller.close() + } catch {} + }) + nodeStream.once('error', (err) => { + settled = true + try { + controller.error(err) + } catch {} + }) + // An abort (signal) or upstream reset can `destroy()` the source with no `error` + // event; without this the reader would hang forever. `close` fires after every + // terminal path, so only act when `end`/`error` didn't already settle the stream. + nodeStream.once('close', () => { + if (settled) return + settled = true + try { + controller.error(new Error('MCP transport stream closed before completing')) + } catch {} + }) + // Start paused so nothing buffers before the consumer pulls (backpressure). + nodeStream.pause() + }, + pull() { + nodeStream.resume() + }, + cancel(reason) { + nodeStream.destroy(reason instanceof Error ? reason : undefined) + }, + }) +} + +/** + * Streaming-safe replacement for `undiciFetch(url, { ...init, dispatcher })`. + * + * undici's `fetch` exposes the response body as a WHATWG `ReadableStream` whose + * bridge is broken under the Bun runtime (which the standalone server runs on): + * response headers arrive but `response.body` never yields data, hanging every + * incremental read — MCP SSE `tools/list`, provider streaming — to its timeout. + * undici's lower-level `request()` returns a Node `Readable` instead, which Bun + * implements natively and streams correctly; `Readable.toWeb` bridges it back to + * a spec `Response`. Buffered reads (`.json()`/`.text()`/`.arrayBuffer()`) behave + * identically on both runtimes, so this is a drop-in substitute. + * + * SSRF is unchanged: the same `dispatcher` (Agent carrying the guarded/pinned + * `connect.lookup`) governs every connection, and `maxResponseSize` still caps the + * body. Redirects are NOT followed here (`maxRedirections: 0`); the caller drives + * them via {@link followRedirectsGuarded}, exactly as it did over `fetch`'s + * `redirect: 'manual'`. + */ +async function undiciRequestAsResponse( + input: RequestInfo | URL, + init: RequestInit, + dispatcher: Agent +): Promise { + let url: string + let effectiveInit = init as UndiciRequestInit + if (typeof Request !== 'undefined' && input instanceof Request) { + // A Request input carries its own method/headers/body/signal; lift them (explicit + // init fields win, per fetch semantics) so a guarded POST isn't downgraded to GET. + const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD' + effectiveInit = { + method: input.method, + headers: input.headers, + body: bodyAllowed ? await input.clone().arrayBuffer() : undefined, + signal: input.signal, + ...(init as UndiciRequestInit), + // double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime + } as unknown as UndiciRequestInit + url = input.url + } else { + url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + } + + const method = (effectiveInit.method ?? 'GET').toUpperCase() + const canHaveBody = method !== 'GET' && method !== 'HEAD' + const requestHeaders = toUndiciRequestHeaders(effectiveInit.headers) ?? {} + const requestBody = canHaveBody ? toUndiciRequestBody(effectiveInit.body) : undefined + // fetch auto-adds a form content-type for a URLSearchParams body; preserve that parity + // when the caller didn't set one (the MCP SDK does set it explicitly, but not every caller). + if ( + canHaveBody && + effectiveInit.body instanceof URLSearchParams && + !Object.keys(requestHeaders).some((key) => key.toLowerCase() === 'content-type') + ) { + requestHeaders['content-type'] = 'application/x-www-form-urlencoded;charset=UTF-8' + } + const { statusCode, headers, body } = await undiciRequest(url, { + method: method as Dispatcher.HttpMethod, + headers: requestHeaders, + body: requestBody, + signal: effectiveInit.signal ?? undefined, + dispatcher, + // No `maxRedirections`: request() does not auto-follow by default, so the caller's + // `followRedirectsGuarded` drives every hop with per-hop SSRF validation. + }) + + const responseHeaders = new Headers() + for (const [key, value] of Object.entries(headers)) { + if (Array.isArray(value)) for (const v of value) responseHeaders.append(key, v) + else if (value != null) responseHeaders.append(key, value) + } + + // Null-body statuses (204/205/304) can't carry a body; drain undici's (empty) stream so its + // socket returns to the pool. Attach an error listener first so a socket reset mid-drain + // surfaces as a handled event, not an unhandled 'error' that crashes the process. + const isNullBody = statusCode === 204 || statusCode === 205 || statusCode === 304 + if (isNullBody) { + body.on('error', () => {}) + body.resume() + const response = new Response(null, { status: statusCode, headers: responseHeaders }) + Object.defineProperty(response, 'url', { value: url, configurable: true }) + return response + } + + // Decode Content-Encoding like `fetch` does (`request()` returns raw bytes). `maxResponseSize` + // still caps the compressed wire bytes on `body`. + const contentEncoding = String(headers['content-encoding'] ?? '') + .toLowerCase() + .trim() + const decoder = contentEncodingDecoder(contentEncoding) + if (decoder) { + // The bridged body is now decoded; drop framing headers that would misdescribe it. + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + } + // Build the bridge over the stream the consumer reads (the decoder when decoding). + // `nodeReadableToWebStream` attaches its `error` listener synchronously, so wiring the pipe + // AFTER it means a synchronous zlib error (e.g. a server mislabeling a non-gzip body as gzip) + // is caught and rejects the reader instead of taking down the process. + const webBody = nodeReadableToWebStream(decoder ?? body) + if (decoder) { + body.once('error', (err) => decoder.destroy(err)) // forward maxResponseSize / socket reset + decoder.once('close', () => body.destroy()) // tear the source down so the socket can't leak + body.pipe(decoder) + } + + try { + const response = new Response(webBody, { status: statusCode, headers: responseHeaders }) + // undici.request never sets `url`; `fetch` did, and consumers rely on it (the MCP + // transport's response-cap wrapper copies it; the SDK resolves relative + // auth-metadata URLs against it). Preserve parity. + Object.defineProperty(response, 'url', { value: url, configurable: true }) + return response + } catch (err) { + // `new Response` rejects an out-of-range status (a 1xx undici shouldn't surface, but + // defensively): destroy the source so its socket can't leak, then rethrow. + body.destroy() + throw err + } +} + /** * SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled * hosts: DNS resolves normally, and every socket connect validates the chosen @@ -637,11 +898,9 @@ export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize ...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}), }) - const rawFetch = async (url: string, init: UndiciRequestInit): Promise => { - const response = await undiciFetch(url, { ...init, dispatcher }) - // double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime - return response as unknown as Response - } + const rawFetch = (url: string, init: UndiciRequestInit): Promise => + // double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime + undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher) const guarded = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url