Compare commits

...

24 Commits

Author SHA1 Message Date
Waleed
52edbea659 v0.5.22: rss feed trigger, sftp tool, billing fixes, 413 surfacing, copilot improvements 2025-12-09 10:27:36 -08:00
Waleed
aa1d896b38 feat(i18n): update translations (#2268)
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-12-09 00:46:09 -08:00
Waleed
2fcd07e82d feat(triggers): added rss feed trigger & poller (#2267) 2025-12-08 23:07:07 -08:00
Vikhyath Mondreti
0db5ba1b27 fix(org-limits): remove fallbacks for enterprise plan (#2255)
* fix(org-limits): remove fallbacks for enterprise plan

* remove comment

* remove comments

* make logger use new helper
2025-12-08 21:43:43 -08:00
Waleed
e390ba0491 feat(dropdowns): added searchbox to the operation dropdown for all blocks (#2266) 2025-12-08 20:54:59 -08:00
Vikhyath Mondreti
2f0509adaf fix(nextjs-size-limit): surface 413s accurately (#2265)
* fix(api-call-size-limit): cannot exceed nextjs size limits

* fix

* Convert to buffer

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
2025-12-08 20:54:25 -08:00
Waleed
9f0584a818 feat(redis): added redis option for rate limiter, 10x speed improvement in rate limit checks & reduction of DB load (#2263)
* feat(redis): added redis option for rate limiter, 10x speed improvement in rate limit checks & reduction of DB load

* ack PR comments

* improvements
2025-12-08 20:39:29 -08:00
Vikhyath Mondreti
d480057fd3 fix(migration): migration got removed by force push (#2253) 2025-12-08 14:08:12 -08:00
Waleed
c27c233da0 v0.5.21: google groups, virtualized code viewer, ui, autolayout, docs improvements 2025-12-08 13:10:50 -08:00
Waleed
ebef5f3a27 v0.5.20: google slides, ui fixes, subflow resizing improvements 2025-12-06 15:36:09 -08:00
Vikhyath Mondreti
12c4c2d44f v0.5.19: copilot fix 2025-12-05 15:27:31 -08:00
Vikhyath Mondreti
929a352edb fix(build): added trigger.dev sdk mock to tests (#2216) 2025-12-05 14:26:50 -08:00
Vikhyath Mondreti
6cd078b0fe v0.5.18: ui fixes, nextjs16, workspace notifications, admin APIs, loading improvements, new slack tools 2025-12-05 14:03:09 -08:00
Waleed
31874939ee v0.5.17: modals, billing fixes, bun update, zoom, dropbox, kalshi, polymarket, datadog, ahrefs, gitlab, shopify, ssh, wordpress integrations 2025-12-04 13:29:46 -08:00
Waleed
e157ce5fbc v0.5.16: MCP fixes, code refactors, jira fixes, new mistral models 2025-12-02 22:02:11 -08:00
Vikhyath Mondreti
774e5d585c v0.5.15: add tools, revert subblock prop change 2025-12-01 13:52:12 -08:00
Vikhyath Mondreti
54cc93743f v0.5.14: fix issue with teams, google selectors + cleanup code 2025-12-01 12:39:39 -08:00
Waleed
8c32ad4c0d v0.5.13: polling fixes, generic agent search tool, status page, smtp, sendgrid, linkedin, more tools (#2148)
* feat(tools): added smtp, sendgrid, mailgun, linkedin, fixed permissions in context menu (#2133)

* feat(tools): added twilio sendgrid integration

* feat(tools): added smtp, sendgrid, mailgun, fixed permissions in context menu

* added top level mocks for sporadically failing tests

* incr type safety

* fix(team-plans): track departed member usage so value not lost (#2118)

* fix(team-plans): track departed member usage so value not lost

* reset usage to 0 when they leave team

* prep merge with stagig

* regen migrations

* fix org invite + ws selection'

---------

Co-authored-by: Waleed <walif6@gmail.com>

* feat(i18n): update translations (#2134)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(creators): add verification for creators (#2135)

* feat(tools): added apify block/tools  (#2136)

* feat(tools): added apify

* cleanup

* feat(i18n): update translations (#2137)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(env): added more optional env var examples (#2138)

* feat(statuspage): added statuspage, updated list of tools in footer, renamed routes (#2139)

* feat(statuspage): added statuspage, updated list of tools in footer, renamed routes

* ack PR comments

* feat(tools): add generic search tool (#2140)

* feat(i18n): update translations (#2141)

* fix(sdks): bump sdk versions (#2142)

* fix(webhooks): count test webhooks towards usage limit (#2143)

* fix(bill): add requestId to webhook processing (#2144)

* improvement(subflow): remove all associated edges when moving a block into a subflow (#2145)

* improvement(subflow): remove all associated edges when moving a block into a subflow

* ack PR comments

* fix(polling): mark webhook failed on webhook trigger errors (#2146)

* fix(deps): declare core transient deps explicitly (#2147)

* fix(deps): declare core transient deps explicitly

* ack PR comments

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-12-01 10:15:36 -08:00
Waleed
1d08796853 v0.5.12: memory optimizations, sentry, incidentio, posthog, zendesk, pylon, intercom, mailchimp, loading optimizations (#2132)
* fix(memory-util): fixed unbounded array of gmail/outlook pollers causing high memory util, added missing db indexes/removed unused ones, auto-disable schedules/webhooks after 10 consecutive failures (#2115)

* fix(memory-util): fixed unbounded array of gmail/outlook pollers causing high memory util, added missing db indexes/removed unused ones, auto-disable schedules/webhooks after 10 consecutive failures

* ack PR comments

* ack

* improvement(teams-plan): seats increase simplification + not triggering checkout session (#2117)

* improvement(teams-plan): seats increase simplification + not triggering checkout session

* cleanup via helper

* feat(tools): added sentry, incidentio, and posthog tools (#2116)

* feat(tools): added sentry, incidentio, and posthog tools

* update docs

* fixed docs to use native fumadocs for llms.txt and copy markdown, fixed tool issues

* cleanup

* enhance error extractor, fixed posthog tools

* docs enhancements, cleanup

* added more incident io ops, remove zustand/shallow in favor of zustand/react/shallow

* fix type errors

* remove unnecessary comments

* added vllm to docs

* feat(i18n): update translations (#2120)

* feat(i18n): update translations

* fix build

---------

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* improvement(workflow-execution): perf improvements to passing workflow state + decrypted env vars (#2119)

* improvement(execution): load workflow state once instead of 2-3 times

* decrypt only in get helper

* remove comments

* remove comments

* feat(models): host google gemini models (#2122)

* feat(models): host google gemini models

* remove unused primary key

* feat(i18n): update translations (#2123)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* feat(tools): added zendesk, pylon, intercom, & mailchimp (#2126)

* feat(tools): added zendesk, pylon, intercom, & mailchimp

* finish zendesk and pylon

* updated docs

* feat(i18n): update translations (#2129)

* feat(i18n): update translations

* fixed build

---------

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal (#2130)

* fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal

* fix failing test

* fix test

* cleanup

* fix(custom-tools): add composite index on custom tool names & workspace id (#2131)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-11-28 16:08:06 -08:00
Waleed
ebcd243942 v0.5.11: stt, videogen, vllm, billing fixes, new models 2025-11-25 01:14:12 -08:00
Waleed
b7e814b721 v0.5.10: copilot upgrade, preprocessor, logs search, UI, code hygiene 2025-11-21 12:04:34 -08:00
Waleed
842ef27ed9 v0.5.9: add backwards compatibility for agent messages array 2025-11-20 11:19:42 -08:00
Vikhyath Mondreti
31c34b2ea3 v0.5.8: notifications, billing, ui changes, store loading state machine 2025-11-20 01:32:32 -08:00
Vikhyath Mondreti
8f0ef58056 v0.5.7: combobox selectors, usage indicator, workflow loading race condition, other improvements 2025-11-17 21:25:51 -08:00
51 changed files with 2098 additions and 348 deletions

View File

@@ -4170,3 +4170,32 @@ export function DuckDuckGoIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function RssIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<circle cx='5' cy='19' r='1' fill='currentColor' />
</svg>
)
}

View File

@@ -30,15 +30,19 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl
<Card title="Schedule" href="/triggers/schedule">
Cron- oder intervallbasierte Ausführung
</Card>
<Card title="RSS Feed" href="/triggers/rss">
RSS- und Atom-Feeds auf neue Inhalte überwachen
</Card>
</Cards>
## Schneller Vergleich
| Trigger | Startbedingung |
|---------|-----------------|
| **Start** | Editor-Ausführungen, deploy-to-API Anfragen oder Chat-Nachrichten |
| **Start** | Editor-Ausführungen, Deploy-to-API-Anfragen oder Chat-Nachrichten |
| **Schedule** | Timer, der im Schedule-Block verwaltet wird |
| **Webhook** | Bei eingehender HTTP-Anfrage |
| **RSS Feed** | Neues Element im Feed veröffentlicht |
> Der Start-Block stellt immer `input`, `conversationId` und `files` Felder bereit. Füge benutzerdefinierte Felder zum Eingabeformat für zusätzliche strukturierte Daten hinzu.

View File

@@ -0,0 +1,49 @@
---
title: RSS-Feed
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Der RSS-Feed-Block überwacht RSS- und Atom-Feeds wenn neue Einträge veröffentlicht werden, wird Ihr Workflow automatisch ausgelöst.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSS-Feed-Block"
width={500}
height={400}
className="my-6"
/>
</div>
## Konfiguration
1. **RSS-Feed-Block hinzufügen** - Ziehen Sie den RSS-Feed-Block, um Ihren Workflow zu starten
2. **Feed-URL eingeben** - Fügen Sie die URL eines beliebigen RSS- oder Atom-Feeds ein
3. **Bereitstellen** - Stellen Sie Ihren Workflow bereit, um das Polling zu aktivieren
Nach der Bereitstellung wird der Feed jede Minute auf neue Einträge überprüft.
## Ausgabefelder
| Feld | Typ | Beschreibung |
|-------|------|-------------|
| `title` | string | Titel des Eintrags |
| `link` | string | Link des Eintrags |
| `pubDate` | string | Veröffentlichungsdatum |
| `item` | object | Rohdaten des Eintrags mit allen Feldern |
| `feed` | object | Rohdaten der Feed-Metadaten |
Greifen Sie direkt auf zugeordnete Felder zu (`<rss.title>`) oder verwenden Sie die Rohobjekte für beliebige Felder (`<rss.item.author>`, `<rss.feed.language>`).
## Anwendungsfälle
- **Inhaltsüberwachung** - Verfolgen Sie Blogs, Nachrichtenseiten oder Updates von Wettbewerbern
- **Podcast-Automatisierung** - Lösen Sie Workflows aus, wenn neue Episoden erscheinen
- **Release-Tracking** - Überwachen Sie GitHub-Releases, Changelogs oder Produkt-Updates
- **Social-Media-Aggregation** - Sammeln Sie Inhalte von Plattformen, die RSS-Feeds anbieten
<Callout>
RSS-Trigger werden nur für Einträge ausgelöst, die nach dem Speichern des Triggers veröffentlicht wurden. Bestehende Feed-Einträge werden nicht verarbeitet.
</Callout>

View File

@@ -30,6 +30,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
<Card title="Schedule" href="/triggers/schedule">
Cron or interval based execution
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Monitor RSS and Atom feeds for new content
</Card>
</Cards>
## Quick Comparison
@@ -39,6 +42,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| **Start** | Editor runs, deploy-to-API requests, or chat messages |
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |
| **RSS Feed** | New item published to feed |
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.

View File

@@ -1,3 +1,3 @@
{
"pages": ["index", "start", "schedule", "webhook"]
"pages": ["index", "start", "schedule", "webhook", "rss"]
}

View File

@@ -0,0 +1,49 @@
---
title: RSS Feed
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
The RSS Feed block monitors RSS and Atom feeds when new items are published, your workflow triggers automatically.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSS Feed Block"
width={500}
height={400}
className="my-6"
/>
</div>
## Configuration
1. **Add RSS Feed Block** - Drag the RSS Feed block to start your workflow
2. **Enter Feed URL** - Paste the URL of any RSS or Atom feed
3. **Deploy** - Deploy your workflow to activate polling
Once deployed, the feed is checked every minute for new items.
## Output Fields
| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Item title |
| `link` | string | Item link |
| `pubDate` | string | Publication date |
| `item` | object | Raw item with all fields |
| `feed` | object | Raw feed metadata |
Access mapped fields directly (`<rss.title>`) or use the raw objects for any field (`<rss.item.author>`, `<rss.feed.language>`).
## Use Cases
- **Content monitoring** - Track blogs, news sites, or competitor updates
- **Podcast automation** - Trigger workflows when new episodes drop
- **Release tracking** - Monitor GitHub releases, changelogs, or product updates
- **Social aggregation** - Collect content from platforms that expose RSS feeds
<Callout>
RSS triggers only fire for items published after you save the trigger. Existing feed items are not processed.
</Callout>

View File

@@ -0,0 +1,184 @@
---
title: SFTP
description: Transferir archivos a través de SFTP (Protocolo de transferencia de
archivos SSH)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTP (Protocolo de transferencia de archivos SSH)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) es un protocolo de red seguro que te permite subir, descargar y gestionar archivos en servidores remotos. SFTP opera sobre SSH, lo que lo hace ideal para transferencias de archivos automatizadas y cifradas, así como para la gestión remota de archivos dentro de flujos de trabajo modernos.
Con las herramientas SFTP integradas en Sim, puedes automatizar fácilmente el movimiento de archivos entre tus agentes de IA y sistemas o servidores externos. Esto permite a tus agentes gestionar intercambios críticos de datos, copias de seguridad, generación de documentos y orquestación de sistemas remotos, todo con una seguridad robusta.
**Funcionalidades clave disponibles a través de las herramientas SFTP:**
- **Subir archivos:** Transfiere sin problemas archivos de cualquier tipo desde tu flujo de trabajo a un servidor remoto, con soporte tanto para autenticación por contraseña como por clave privada SSH.
- **Descargar archivos:** Recupera archivos de servidores SFTP remotos directamente para su procesamiento, archivo o automatización adicional.
- **Listar y gestionar archivos:** Enumera directorios, elimina o crea archivos y carpetas, y gestiona permisos del sistema de archivos de forma remota.
- **Autenticación flexible:** Conéctate usando contraseñas tradicionales o claves SSH, con soporte para frases de contraseña y control de permisos.
- **Soporte para archivos grandes:** Gestiona programáticamente cargas y descargas de archivos grandes, con límites de tamaño incorporados para mayor seguridad.
Al integrar SFTP en Sim, puedes automatizar operaciones seguras de archivos como parte de cualquier flujo de trabajo, ya sea recopilación de datos, informes, mantenimiento de sistemas remotos o intercambio dinámico de contenido entre plataformas.
Las secciones a continuación describen las principales herramientas SFTP disponibles:
- **sftp_upload:** Sube uno o más archivos a un servidor remoto.
- **sftp_download:** Descarga archivos desde un servidor remoto a tu flujo de trabajo.
- **sftp_list:** Lista el contenido de directorios en un servidor SFTP remoto.
- **sftp_delete:** Elimina archivos o directorios de un servidor remoto.
- **sftp_create:** Crea nuevos archivos en un servidor SFTP remoto.
- **sftp_mkdir:** Crea nuevos directorios de forma remota.
Consulta la documentación de la herramienta a continuación para conocer los parámetros detallados de entrada y salida para cada operación.
{/* MANUAL-CONTENT-END */}
## Instrucciones de uso
Sube, descarga, lista y gestiona archivos en servidores remotos a través de SFTP. Compatible con autenticación por contraseña y clave privada para transferencias seguras de archivos.
## Herramientas
### `sftp_upload`
Subir archivos a un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Directorio de destino en el servidor remoto |
| `files` | file[] | No | Archivos para subir |
| `fileContent` | string | No | Contenido directo del archivo para subir \(para archivos de texto\) |
| `fileName` | string | No | Nombre del archivo cuando se usa contenido directo |
| `overwrite` | boolean | No | Si se deben sobrescribir archivos existentes \(predeterminado: true\) |
| `permissions` | string | No | Permisos del archivo \(p. ej., 0644\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la subida fue exitosa |
| `uploadedFiles` | json | Array de detalles de archivos subidos \(nombre, rutaRemota, tamaño\) |
| `message` | string | Mensaje de estado de la operación |
### `sftp_download`
Descargar un archivo desde un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta al archivo en el servidor remoto |
| `encoding` | string | No | Codificación de salida: utf-8 para texto, base64 para binario \(predeterminado: utf-8\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la descarga fue exitosa |
| `fileName` | string | Nombre del archivo descargado |
| `content` | string | Contenido del archivo \(texto o codificado en base64\) |
| `size` | number | Tamaño del archivo en bytes |
| `encoding` | string | Codificación del contenido \(utf-8 o base64\) |
| `message` | string | Mensaje de estado de la operación |
### `sftp_list`
Listar archivos y directorios en un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta del directorio en el servidor remoto |
| `detailed` | boolean | No | Incluir información detallada de archivos \(tamaño, permisos, fecha de modificación\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la operación fue exitosa |
| `path` | string | Ruta del directorio que fue listado |
| `entries` | json | Array de entradas del directorio con nombre, tipo, tamaño, permisos, modifiedAt |
| `count` | number | Número de entradas en el directorio |
| `message` | string | Mensaje de estado de la operación |
### `sftp_delete`
Eliminar un archivo o directorio en un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Requerido | Descripción |
| --------- | ---- | -------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta al archivo o directorio a eliminar |
| `recursive` | boolean | No | Eliminar directorios recursivamente |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la eliminación fue exitosa |
| `deletedPath` | string | Ruta que fue eliminada |
| `message` | string | Mensaje de estado de la operación |
### `sftp_mkdir`
Crear un directorio en un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta para el nuevo directorio |
| `recursive` | boolean | No | Crear directorios principales si no existen |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si el directorio se creó correctamente |
| `createdPath` | string | Ruta del directorio creado |
| `message` | string | Mensaje de estado de la operación |
## Notas
- Categoría: `tools`
- Tipo: `sftp`

View File

@@ -30,6 +30,9 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
<Card title="Schedule" href="/triggers/schedule">
Ejecución basada en cron o intervalos
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Monitorea feeds RSS y Atom para nuevo contenido
</Card>
</Cards>
## Comparación rápida
@@ -39,6 +42,7 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
| **Start** | Ejecuciones del editor, solicitudes de despliegue a API o mensajes de chat |
| **Schedule** | Temporizador gestionado en el bloque de programación |
| **Webhook** | Al recibir una solicitud HTTP entrante |
| **RSS Feed** | Nuevo elemento publicado en el feed |
> El bloque Start siempre expone los campos `input`, `conversationId` y `files`. Añade campos personalizados al formato de entrada para datos estructurados adicionales.

View File

@@ -0,0 +1,49 @@
---
title: Feed RSS
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
El bloque de Feed RSS monitorea feeds RSS y Atom cuando se publican nuevos elementos, tu flujo de trabajo se activa automáticamente.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="Bloque de Feed RSS"
width={500}
height={400}
className="my-6"
/>
</div>
## Configuración
1. **Añadir bloque de Feed RSS** - Arrastra el bloque de Feed RSS para iniciar tu flujo de trabajo
2. **Introducir URL del feed** - Pega la URL de cualquier feed RSS o Atom
3. **Implementar** - Implementa tu flujo de trabajo para activar el sondeo
Una vez implementado, el feed se comprueba cada minuto en busca de nuevos elementos.
## Campos de salida
| Campo | Tipo | Descripción |
|-------|------|-------------|
| `title` | string | Título del elemento |
| `link` | string | Enlace del elemento |
| `pubDate` | string | Fecha de publicación |
| `item` | object | Elemento en bruto con todos los campos |
| `feed` | object | Metadatos en bruto del feed |
Accede a los campos mapeados directamente (`<rss.title>`) o utiliza los objetos en bruto para cualquier campo (`<rss.item.author>`, `<rss.feed.language>`).
## Casos de uso
- **Monitoreo de contenido** - Sigue blogs, sitios de noticias o actualizaciones de competidores
- **Automatización de podcasts** - Activa flujos de trabajo cuando se publican nuevos episodios
- **Seguimiento de lanzamientos** - Monitorea lanzamientos de GitHub, registros de cambios o actualizaciones de productos
- **Agregación social** - Recopila contenido de plataformas que exponen feeds RSS
<Callout>
Los disparadores RSS solo se activan para elementos publicados después de guardar el disparador. Los elementos existentes en el feed no se procesan.
</Callout>

View File

@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiement vers l'API ou des expériences de déploiement vers le chat. D'autres déclencheurs restent disponibles pour les flux de travail basés sur des événements :
<Cards>
<Card title="Démarrer" href="/triggers/start">
<Card title="Start" href="/triggers/start">
Point d'entrée unifié qui prend en charge les exécutions de l'éditeur, les déploiements d'API et les déploiements de chat
</Card>
<Card title="Webhook" href="/triggers/webhook">
Recevoir des charges utiles de webhook externes
</Card>
<Card title="Planification" href="/triggers/schedule">
<Card title="Schedule" href="/triggers/schedule">
Exécution basée sur cron ou intervalle
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Surveiller les flux RSS et Atom pour du nouveau contenu
</Card>
</Cards>
## Comparaison rapide
| Déclencheur | Condition de démarrage |
|---------|-----------------|
| **Démarrer** | Exécutions de l'éditeur, requêtes de déploiement vers l'API ou messages de chat |
| **Planification** | Minuteur géré dans le bloc de planification |
| **Start** | Exécutions de l'éditeur, requêtes de déploiement d'API ou messages de chat |
| **Schedule** | Minuteur géré dans le bloc de planification |
| **Webhook** | Sur requête HTTP entrante |
| **RSS Feed** | Nouvel élément publié dans le flux |
> Le bloc Démarrer expose toujours les champs `input`, `conversationId` et `files`. Ajoutez des champs personnalisés au format d'entrée pour des données structurées supplémentaires.

View File

@@ -0,0 +1,49 @@
---
title: Flux RSS
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Le bloc Flux RSS surveille les flux RSS et Atom lorsque de nouveaux éléments sont publiés, votre workflow se déclenche automatiquement.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="Bloc Flux RSS"
width={500}
height={400}
className="my-6"
/>
</div>
## Configuration
1. **Ajouter le bloc Flux RSS** - Faites glisser le bloc Flux RSS pour démarrer votre workflow
2. **Saisir l'URL du flux** - Collez l'URL de n'importe quel flux RSS ou Atom
3. **Déployer** - Déployez votre workflow pour activer l'interrogation
Une fois déployé, le flux est vérifié chaque minute pour détecter de nouveaux éléments.
## Champs de sortie
| Champ | Type | Description |
|-------|------|-------------|
| `title` | string | Titre de l'élément |
| `link` | string | Lien de l'élément |
| `pubDate` | string | Date de publication |
| `item` | object | Élément brut avec tous les champs |
| `feed` | object | Métadonnées brutes du flux |
Accédez directement aux champs mappés (`<rss.title>`) ou utilisez les objets bruts pour n'importe quel champ (`<rss.item.author>`, `<rss.feed.language>`).
## Cas d'utilisation
- **Surveillance de contenu** - Suivez les blogs, sites d'actualités ou mises à jour des concurrents
- **Automatisation de podcast** - Déclenchez des workflows lors de la sortie de nouveaux épisodes
- **Suivi des versions** - Surveillez les versions GitHub, les journaux de modifications ou les mises à jour de produits
- **Agrégation sociale** - Collectez du contenu à partir de plateformes qui exposent des flux RSS
<Callout>
Les déclencheurs RSS ne s'activent que pour les éléments publiés après l'enregistrement du déclencheur. Les éléments existants du flux ne sont pas traités.
</Callout>

View File

@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
エディタ、APIへのデプロイ、またはチャットへのデプロイエクスペリエンスから始まるすべてのものにはスタートブロックを使用します。イベント駆動型ワークフローには他のトリガーも利用可能です
<Cards>
<Card title="スタート" href="/triggers/start">
<Card title="Start" href="/triggers/start">
エディタ実行、APIデプロイメント、チャットデプロイメントをサポートする統合エントリーポイント
</Card>
<Card title="ウェブフック" href="/triggers/webhook">
外部ウェブフックペイロードを受信
<Card title="Webhook" href="/triggers/webhook">
外部のwebhookペイロードを受信
</Card>
<Card title="スケジュール" href="/triggers/schedule">
<Card title="Schedule" href="/triggers/schedule">
Cronまたは間隔ベースの実行
</Card>
<Card title="RSS Feed" href="/triggers/rss">
新しいコンテンツのRSSとAtomフィードを監視
</Card>
</Cards>
## クイック比較
| トリガー | 開始条件 |
|---------|-----------------|
| **スタート** | エディタ実行、APIへのデプロイリクエスト、またはチャットメッセージ |
| **スケジュール** | スケジュールブロックで管理されるタイマー |
| **ウェブフック** | 受信HTTPリクエスト時 |
| **Start** | エディタ実行、APIへのデプロイリクエスト、またはチャットメッセージ |
| **Schedule** | スケジュールブロックで管理されるタイマー |
| **Webhook** | 受信HTTPリクエスト時 |
| **RSS Feed** | フィードに新しいアイテムが公開された時 |
> スタートブロックは常に `input`、`conversationId`、および `files` フィールドを公開します。追加の構造化データには入力フォーマットにカスタムフィールドを追加してください。

View File

@@ -0,0 +1,49 @@
---
title: RSSフィード
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
RSSフィードブロックはRSSとAtomフィードを監視します - 新しいアイテムが公開されると、ワークフローが自動的にトリガーされます。
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSSフィードブロック"
width={500}
height={400}
className="my-6"
/>
</div>
## 設定
1. **RSSフィードブロックを追加** - RSSフィードブロックをドラッグしてワークフローを開始
2. **フィードURLを入力** - 任意のRSSまたはAtomフィードのURLを貼り付け
3. **デプロイ** - ワークフローをデプロイしてポーリングを有効化
デプロイ後、フィードは1分ごとに新しいアイテムをチェックします。
## 出力フィールド
| フィールド | 型 | 説明 |
|-------|------|-------------|
| `title` | string | アイテムのタイトル |
| `link` | string | アイテムのリンク |
| `pubDate` | string | 公開日 |
| `item` | object | すべてのフィールドを含む生のアイテム |
| `feed` | object | 生のフィードメタデータ |
マッピングされたフィールドに直接アクセスするか(`<rss.title>`)、任意のフィールドに生のオブジェクトを使用します(`<rss.item.author>`、`<rss.feed.language>`)。
## ユースケース
- **コンテンツ監視** - ブログ、ニュースサイト、または競合他社の更新を追跡
- **ポッドキャスト自動化** - 新しいエピソードが公開されたときにワークフローをトリガー
- **リリース追跡** - GitHubリリース、変更ログ、または製品アップデートを監視
- **ソーシャルアグリゲーション** - RSSフィードを公開しているプラットフォームからコンテンツを収集
<Callout>
RSSトリガーは、トリガーを保存した後に公開されたアイテムに対してのみ実行されます。既存のフィードアイテムは処理されません。
</Callout>

View File

@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
使用 Start 块处理从编辑器、部署到 API 或部署到聊天的所有操作。其他触发器可用于事件驱动的工作流:
<Cards>
<Card title="Start" href="/triggers/start">
<Card title="开始" href="/triggers/start">
支持编辑器运行、API 部署和聊天部署的统一入口点
</Card>
<Card title="Webhook" href="/triggers/webhook">
接收外部 webhook 负载
</Card>
<Card title="Schedule" href="/triggers/schedule">
<Card title="计划" href="/triggers/schedule">
基于 Cron 或间隔的执行
</Card>
<Card title="RSS 源" href="/triggers/rss">
监控 RSS 和 Atom 源的新内容
</Card>
</Cards>
## 快速对比
| 触发器 | 启动条件 |
|---------|-----------------|
| **Start** | 编辑器运行、部署到 API 请求或聊天消息 |
| **Schedule** | 在 Schedule 块中管理的计时器 |
| **Webhook** | 收到入站 HTTP 请求 |
| **开始** | 编辑器运行、部署到 API 请求或聊天消息 |
| **计划** | 在计划块中管理的计时器 |
| **Webhook** | 收到入站 HTTP 请求 |
| **RSS 源** | 源中发布了新项目 |
> Start 块始终公开 `input`、`conversationId` 和 `files` 字段。通过向输入格式添加自定义字段来增加结构化数据。

View File

@@ -0,0 +1,49 @@
---
title: RSS 订阅源
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
RSS 订阅源模块监控 RSS 和 Atom 订阅源——当有新内容发布时,您的工作流会自动触发。
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSS 订阅源模块"
width={500}
height={400}
className="my-6"
/>
</div>
## 配置
1. **添加 RSS 订阅源模块** - 拖动 RSS 订阅源模块以开始您的工作流
2. **输入订阅源 URL** - 粘贴任意 RSS 或 Atom 订阅源的 URL
3. **部署** - 部署您的工作流以激活轮询
部署后,订阅源每分钟检查一次是否有新内容。
## 输出字段
| 字段 | 类型 | 描述 |
|-------|------|-------------|
| `title` | string | 内容标题 |
| `link` | string | 内容链接 |
| `pubDate` | string | 发布日期 |
| `item` | object | 包含所有字段的原始内容 |
| `feed` | object | 原始订阅源元数据 |
可以直接访问映射字段 (`<rss.title>`),或者使用原始对象访问任意字段 (`<rss.item.author>`, `<rss.feed.language>`)。
## 使用场景
- **内容监控** - 跟踪博客、新闻网站或竞争对手的更新
- **播客自动化** - 当新剧集发布时触发工作流
- **版本跟踪** - 监控 GitHub 发布、更新日志或产品更新
- **社交聚合** - 收集支持 RSS 订阅源的平台内容
<Callout>
RSS 触发器仅对您保存触发器后发布的内容生效。现有的订阅源内容不会被处理。
</Callout>

View File

@@ -5760,9 +5760,9 @@ checksums:
content/1: e71056df0f7b2eb3b2f271f21d0052cc
content/2: da2b445db16c149f56558a4ea876a5f0
content/3: cec18f48b2cd7974eb556880e6604f7f
content/4: c187ae3362455acfe43282399f0d163a
content/4: b200402d6a01ab565fd56d113c530ef6
content/5: 4c3a5708af82c1ee42a12d14fd34e950
content/6: 12a43b499c1e8bb06b050964053ebde3
content/6: 64fbd5b16f4cff18ba976492a275c05e
content/7: a28151eeb5ba3518b33809055b04f0f6
content/8: cffe5b901d78ebf2000d07dc7579533e
content/9: 73486253d24eeff7ac44dfd0c8868d87
@@ -49300,3 +49300,17 @@ checksums:
content/42: dc2cfed837ea55adfa23bd7c87d5299d
content/43: b3f310d5ef115bea5a8b75bf25d7ea9a
content/44: df2ef65659b8ea0a13916358943f965b
ebed3bd73520bf81399749586796f9d0:
meta/title: 1763bebd6001500cdfc1b5127b0c1cde
content/0: eb0ed7078f192304703144f4cac3442f
content/1: ba5ba29787a0eb35c46dacb3544bafe1
content/2: 5ed74bf0e91235f71eeceb25712ad2d3
content/3: 0441638444240cd20a6c69ea1d3afbb1
content/4: ef102e10f1402df7290680c1e9df8a5e
content/5: 95afa83a30cb01724b932b19dd69f20b
content/6: 8ebc5e005f61d253c006824168abaf22
content/7: df81a49b54d378523fb74aa0b0fb8be1
content/8: c5fb77d31bae86aa85f2b2b84ce0beab
content/9: 7a3be8a3771ee428ecf09008e42c0e2e
content/10: 42e4caf9b036a8d7726a8968f3ed201f
content/11: e74f8ee79105babdaa8dfec520ecdf74

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,9 +1,10 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { member, organization, subscription } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
@@ -172,6 +173,39 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
})
.where(eq(subscription.id, orgSubscription.id))
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
const { basePrice } = getPlanPricing('team')
const newMinimumLimit = newSeatCount * basePrice
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const currentOrgLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
: 0
// Update if new minimum is higher than current limit
if (newMinimumLimit > currentOrgLimit) {
await db
.update(organization)
.set({
orgUsageLimit: newMinimumLimit.toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
logger.info('Updated organization usage limit for seat change', {
organizationId,
newSeatCount,
newMinimumLimit,
previousLimit: currentOrgLimit,
})
}
logger.info('Successfully updated seat count', {
organizationId,
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,

View File

@@ -4,9 +4,9 @@ import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse } from '@/app/api/workflows/utils'
import { RateLimiter } from '@/services/queue'
const logger = createLogger('UsageLimitsAPI')

View File

@@ -1,7 +1,7 @@
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
import { RateLimiter } from '@/services/queue'
import { RateLimiter } from '@/lib/core/rate-limiter'
export interface UserLimits {
workflowExecutionRateLimit: {

View File

@@ -1,8 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { createLogger } from '@/lib/logs/console/logger'
import { authenticateV1Request } from '@/app/api/v1/auth'
import { RateLimiter } from '@/services/queue/RateLimiter'
const logger = createLogger('V1Middleware')
const rateLimiter = new RateLimiter()

View File

@@ -0,0 +1,66 @@
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { createLogger } from '@/lib/logs/console/logger'
import { pollRssWebhooks } from '@/lib/webhooks/rss-polling-service'
const logger = createLogger('RssPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'rss-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = nanoid()
logger.info(`RSS webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'RSS webhook polling')
if (authError) {
return authError
}
lockValue = requestId
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollRssWebhooks()
return NextResponse.json({
success: true,
message: 'RSS polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during RSS polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'RSS polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
await releaseLock(LOCK_KEY).catch(() => {})
}
}

View File

@@ -544,6 +544,43 @@ export async function POST(request: NextRequest) {
}
// --- End Outlook specific logic ---
// --- RSS webhook setup ---
if (savedWebhook && provider === 'rss') {
logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`)
try {
const { configureRssPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureRssPolling(savedWebhook, requestId)
if (!success) {
logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to configure RSS polling',
details: 'Please try again',
},
{ status: 500 }
)
}
logger.info(`[${requestId}] Successfully configured RSS polling`)
} catch (err) {
logger.error(
`[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to configure RSS webhook',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End RSS specific logic ---
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {

View File

@@ -140,7 +140,7 @@ vi.mock('@/lib/workspaces/utils', async () => {
}
})
vi.mock('@/services/queue', () => ({
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,

View File

@@ -395,8 +395,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
triggerType: loggingTriggerType,
executionId,
requestId,
checkRateLimit: false, // Manual executions bypass rate limits
checkDeployment: !shouldUseDraftState, // Check deployment unless using draft
checkDeployment: !shouldUseDraftState,
loggingSession,
})

View File

@@ -363,6 +363,8 @@ export function Dropdown({
)
}, [multiSelect, multiValues, optionMap])
const isSearchable = subBlockId === 'operation'
return (
<Combobox
options={comboboxOptions}
@@ -375,7 +377,6 @@ export function Dropdown({
editable={false}
onOpenChange={(open) => {
if (open) {
// Fetch options when the dropdown is opened to ensure freshness
void fetchOptionsIfNeeded()
}
}}
@@ -383,6 +384,8 @@ export function Dropdown({
multiSelect={multiSelect}
isLoading={isLoadingOptions}
error={fetchError}
searchable={isSearchable}
searchPlaceholder='Search operations...'
/>
)
}

View File

@@ -11,13 +11,13 @@ import { and, eq, isNull, lte, or, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
import type { AlertConfig } from '@/lib/notifications/alert-rules'
import { RateLimiter } from '@/services/queue'
const logger = createLogger('WorkspaceNotificationDelivery')

View File

@@ -0,0 +1,36 @@
import { RssIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
export const RssBlock: BlockConfig = {
type: 'rss',
name: 'RSS Feed',
description: 'Monitor RSS feeds and trigger workflows when new items are published',
longDescription:
'Subscribe to any RSS or Atom feed and automatically trigger your workflow when new content is published. Perfect for monitoring blogs, news sites, podcasts, and any content that publishes an RSS feed.',
category: 'triggers',
bgColor: '#F97316',
icon: RssIcon,
triggerAllowed: true,
subBlocks: [...getTrigger('rss_poller').subBlocks],
tools: {
access: [], // Trigger-only for now
},
inputs: {},
outputs: {
title: { type: 'string', description: 'Item title' },
link: { type: 'string', description: 'Item link' },
pubDate: { type: 'string', description: 'Publication date' },
item: { type: 'json', description: 'Raw item object with all fields' },
feed: { type: 'json', description: 'Raw feed object with all fields' },
},
triggers: {
enabled: true,
available: ['rss_poller'],
},
}

View File

@@ -89,6 +89,7 @@ import { RedditBlock } from '@/blocks/blocks/reddit'
import { ResendBlock } from '@/blocks/blocks/resend'
import { ResponseBlock } from '@/blocks/blocks/response'
import { RouterBlock } from '@/blocks/blocks/router'
import { RssBlock } from '@/blocks/blocks/rss'
import { S3Block } from '@/blocks/blocks/s3'
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
import { ScheduleBlock } from '@/blocks/blocks/schedule'
@@ -229,6 +230,7 @@ export const registry: Record<string, BlockConfig> = {
reddit: RedditBlock,
resend: ResendBlock,
response: ResponseBlock,
rss: RssBlock,
router: RouterBlock,
s3: S3Block,
salesforce: SalesforceBlock,

View File

@@ -4170,3 +4170,32 @@ export function DuckDuckGoIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function RssIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<circle cx='5' cy='19' r='1' fill='currentColor' />
</svg>
)
}

View File

@@ -1,7 +1,6 @@
import { db } from '@sim/db'
import { member, organization, userStats } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
@@ -108,19 +107,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
)
}
}
// Determine org cap
let orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
// Determine org cap from orgUsageLimit (should always be set for team/enterprise)
const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
if (!orgCap || Number.isNaN(orgCap)) {
// Fall back to minimum billing amount from Stripe subscription
const orgSub = await getOrganizationSubscription(org.id)
if (orgSub?.seats) {
const { basePrice } = getPlanPricing(orgSub.plan)
orgCap = (orgSub.seats ?? 0) * basePrice
} else {
// If no subscription, use team default
const { basePrice } = getPlanPricing('team')
orgCap = basePrice // Default to 1 seat minimum
}
logger.warn('Organization missing usage limit', { orgId: org.id })
}
if (pooledUsage >= orgCap) {
isExceeded = true

View File

@@ -22,6 +22,56 @@ import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
const logger = createLogger('UsageManagement')
export interface OrgUsageLimitResult {
limit: number
minimum: number
}
/**
* Calculates the effective usage limit for a team or enterprise organization.
* - Enterprise: Uses orgUsageLimit directly (fixed pricing)
* - Team: Uses orgUsageLimit but never below seats × basePrice
*/
export async function getOrgUsageLimit(
organizationId: string,
plan: string,
seats: number | null
): Promise<OrgUsageLimitResult> {
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const configured =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
: null
if (plan === 'enterprise') {
// Enterprise: Use configured limit directly (no per-seat minimum)
if (configured !== null) {
return { limit: configured, minimum: configured }
}
logger.warn('Enterprise org missing usage limit', { orgId: organizationId })
return { limit: 0, minimum: 0 }
}
const { basePrice } = getPlanPricing(plan)
const minimum = (seats ?? 0) * basePrice
if (configured !== null) {
return { limit: Math.max(configured, minimum), minimum }
}
logger.warn('Team org missing usage limit, using seats × basePrice fallback', {
orgId: organizationId,
seats,
minimum,
})
return { limit: minimum, minimum }
}
/**
* Handle new user setup when they join the platform
* Creates userStats record with default free credits
@@ -87,22 +137,13 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
? Number.parseFloat(stats.currentUsageLimit)
: getFreeTierLimit()
} else {
// Team/Enterprise: Use organization limit but never below minimum (seats × cost per seat)
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
limit = Math.max(configured, minimum)
} else {
limit = minimum
}
// Team/Enterprise: Use organization limit
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
limit = orgLimit.limit
}
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0
@@ -159,24 +200,15 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
minimumLimit = getPerUserMinimumLimit(subscription)
canEdit = canEditUsageLimit(subscription)
} else {
// Team/Enterprise: Use organization limits (users cannot edit)
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
currentLimit = Math.max(configured, minimum)
} else {
currentLimit = minimum
}
minimumLimit = minimum
canEdit = false // Team/enterprise members cannot edit limits
// Team/Enterprise: Use organization limits
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
currentLimit = orgLimit.limit
minimumLimit = orgLimit.minimum
canEdit = false
}
return {
@@ -323,27 +355,23 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
return Number.parseFloat(userStatsQuery[0].currentUsageLimit)
}
// Team/Enterprise: Use organization limit but never below minimum
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
// Team/Enterprise: Verify org exists then use organization limit
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
if (orgData.length === 0) {
if (orgExists.length === 0) {
throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`)
}
if (orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
return Math.max(configured, minimum)
}
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
const { basePrice } = getPlanPricing(subscription.plan)
return (subscription.seats ?? 0) * basePrice
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
return orgLimit.limit
}
/**

View File

@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { createLogger } from '@/lib/logs/console/logger'
@@ -145,11 +146,52 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
plan: subscription.plan,
})
} else {
// Organization subscription - sync usage limits for all members
// Organization subscription - set org usage limit and sync member limits
const organizationId = subscription.referenceId
// Set orgUsageLimit for team plans (enterprise is set via webhook with custom pricing)
if (subscription.plan === 'team') {
const { basePrice } = getPlanPricing(subscription.plan)
const seats = subscription.seats ?? 1
const orgLimit = seats * basePrice
// Only set if not already set or if updating to a higher value based on seats
const orgData = await db
.select({ orgUsageLimit: schema.organization.orgUsageLimit })
.from(schema.organization)
.where(eq(schema.organization.id, organizationId))
.limit(1)
const currentLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
: 0
// Update if no limit set, or if new seat-based minimum is higher
if (currentLimit < orgLimit) {
await db
.update(schema.organization)
.set({
orgUsageLimit: orgLimit.toFixed(2),
updatedAt: new Date(),
})
.where(eq(schema.organization.id, organizationId))
logger.info('Set organization usage limit for team plan', {
organizationId,
seats,
basePrice,
orgLimit,
previousLimit: currentLimit,
})
}
}
// Sync usage limits for all members
const members = await db
.select({ userId: schema.member.userId })
.from(schema.member)
.where(eq(schema.member.organizationId, subscription.referenceId))
.where(eq(schema.member.organizationId, organizationId))
if (members.length > 0) {
for (const member of members) {
@@ -158,7 +200,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
} catch (memberError) {
logger.error('Failed to sync usage limits for organization member', {
userId: member.userId,
organizationId: subscription.referenceId,
organizationId,
subscriptionId: subscription.id,
error: memberError,
})
@@ -166,7 +208,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
}
logger.info('Synced usage limits for organization members', {
organizationId: subscription.referenceId,
organizationId,
memberCount: members.length,
subscriptionId: subscription.id,
plan: subscription.plan,

View File

@@ -0,0 +1,7 @@
export { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
export type {
RateLimitConfig,
SubscriptionPlan,
TriggerType,
} from '@/lib/core/rate-limiter/types'
export { RATE_LIMITS, RateLimitError } from '@/lib/core/rate-limiter/types'

View File

@@ -0,0 +1,309 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS } from '@/lib/core/rate-limiter/types'
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value })),
sql: vi.fn((strings, ...values) => ({ sql: strings.join('?'), values })),
and: vi.fn((...conditions) => ({ and: conditions })),
}))
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: vi.fn().mockReturnValue(null),
}))
import { db } from '@sim/db'
import { getRedisClient } from '@/lib/core/config/redis'
describe('RateLimiter', () => {
const rateLimiter = new RateLimiter()
const testUserId = 'test-user-123'
const freeSubscription = { plan: 'free', referenceId: testUserId }
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getRedisClient).mockReturnValue(null)
})
describe('checkRateLimitWithSubscription', () => {
it('should allow unlimited requests for manual trigger type', async () => {
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'manual',
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(result.resetAt).toBeInstanceOf(Date)
expect(db.select).not.toHaveBeenCalled()
})
it('should allow first API request for sync execution (DB fallback)', async () => {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
expect(result.resetAt).toBeInstanceOf(Date)
})
it('should allow first API request for async execution (DB fallback)', async () => {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 0,
asyncApiRequests: 1,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
true
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.asyncApiExecutionsPerMinute - 1)
expect(result.resetAt).toBeInstanceOf(Date)
})
it('should work for all trigger types except manual (DB fallback)', async () => {
const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const
for (const triggerType of triggerTypes) {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
triggerType,
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
}
})
it('should use Redis when available', async () => {
const mockRedis = {
eval: vi.fn().mockResolvedValue(1), // Lua script returns count after INCR
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
expect(mockRedis.eval).toHaveBeenCalled()
expect(db.select).not.toHaveBeenCalled()
})
it('should deny requests when Redis rate limit exceeded', async () => {
const mockRedis = {
eval: vi.fn().mockResolvedValue(RATE_LIMITS.free.syncApiExecutionsPerMinute + 1),
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(false)
expect(result.remaining).toBe(0)
})
it('should fall back to DB when Redis fails', async () => {
const mockRedis = {
eval: vi.fn().mockRejectedValue(new Error('Redis connection failed')),
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(true)
expect(db.select).toHaveBeenCalled()
})
})
describe('getRateLimitStatusWithSubscription', () => {
it('should return unlimited for manual trigger type', async () => {
const status = await rateLimiter.getRateLimitStatusWithSubscription(
testUserId,
freeSubscription,
'manual',
false
)
expect(status.used).toBe(0)
expect(status.limit).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.resetAt).toBeInstanceOf(Date)
})
it('should return sync API limits for API trigger type (DB fallback)', async () => {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
const status = await rateLimiter.getRateLimitStatusWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(status.used).toBe(0)
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.resetAt).toBeInstanceOf(Date)
})
it('should use Redis for status when available', async () => {
const mockRedis = {
get: vi.fn().mockResolvedValue('5'),
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
const status = await rateLimiter.getRateLimitStatusWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(status.used).toBe(5)
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 5)
expect(mockRedis.get).toHaveBeenCalled()
expect(db.select).not.toHaveBeenCalled()
})
})
describe('resetRateLimit', () => {
it('should delete rate limit record for user', async () => {
vi.mocked(db.delete).mockReturnValue({
where: vi.fn().mockResolvedValue({}),
} as any)
await rateLimiter.resetRateLimit(testUserId)
expect(db.delete).toHaveBeenCalled()
})
})
})

View File

@@ -1,8 +1,8 @@
import { db } from '@sim/db'
import { userRateLimits } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createLogger } from '@/lib/logs/console/logger'
import type Redis from 'ioredis'
import { getRedisClient } from '@/lib/core/config/redis'
import {
MANUAL_EXECUTION_LIMIT,
RATE_LIMIT_WINDOW_MS,
@@ -10,7 +10,8 @@ import {
type RateLimitCounterType,
type SubscriptionPlan,
type TriggerType,
} from '@/services/queue/types'
} from '@/lib/core/rate-limiter/types'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('RateLimiter')
@@ -88,6 +89,69 @@ export class RateLimiter {
}
}
/**
* Check rate limit using Redis (faster, single atomic operation)
* Uses fixed window algorithm with INCR + EXPIRE
*/
private async checkRateLimitRedis(
redis: Redis,
rateLimitKey: string,
counterType: RateLimitCounterType,
limit: number
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
const windowMs = RATE_LIMIT_WINDOW_MS
const windowKey = Math.floor(Date.now() / windowMs)
const key = `ratelimit:${rateLimitKey}:${counterType}:${windowKey}`
const ttlSeconds = Math.ceil(windowMs / 1000)
// Atomic increment + expire
const count = (await redis.eval(
'local c = redis.call("INCR", KEYS[1]) if c == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return c',
1,
key,
ttlSeconds
)) as number
const resetAt = new Date((windowKey + 1) * windowMs)
if (count > limit) {
logger.info(`Rate limit exceeded (Redis) - request ${count} > limit ${limit}`, {
rateLimitKey,
counterType,
limit,
count,
})
return { allowed: false, remaining: 0, resetAt }
}
return { allowed: true, remaining: limit - count, resetAt }
}
/**
* Get rate limit status using Redis (read-only, doesn't increment)
*/
private async getRateLimitStatusRedis(
redis: Redis,
rateLimitKey: string,
counterType: RateLimitCounterType,
limit: number
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
const windowMs = RATE_LIMIT_WINDOW_MS
const windowKey = Math.floor(Date.now() / windowMs)
const key = `ratelimit:${rateLimitKey}:${counterType}:${windowKey}`
const countStr = await redis.get(key)
const used = countStr ? Number.parseInt(countStr, 10) : 0
const resetAt = new Date((windowKey + 1) * windowMs)
return {
used,
limit,
remaining: Math.max(0, limit - used),
resetAt,
}
}
/**
* Check if user can execute a workflow with organization-aware rate limiting
* Manual executions bypass rate limiting entirely
@@ -114,6 +178,18 @@ export class RateLimiter {
const counterType = this.getCounterType(triggerType, isAsync)
const execLimit = this.getRateLimitForCounter(limit, counterType)
// Try Redis first for faster rate limiting
const redis = getRedisClient()
if (redis) {
try {
return await this.checkRateLimitRedis(redis, rateLimitKey, counterType, execLimit)
} catch (error) {
logger.warn('Redis rate limit check failed, falling back to DB:', { error })
// Fall through to DB implementation
}
}
// Fallback to DB implementation
const now = new Date()
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
@@ -273,21 +349,6 @@ export class RateLimiter {
}
}
/**
* Legacy method - for backward compatibility
* @deprecated Use checkRateLimitWithSubscription instead
*/
async checkRateLimit(
userId: string,
subscriptionPlan: SubscriptionPlan = 'free',
triggerType: TriggerType = 'manual',
isAsync = false
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
// For backward compatibility, fetch the subscription
const subscription = await getHighestPrioritySubscription(userId)
return this.checkRateLimitWithSubscription(userId, subscription, triggerType, isAsync)
}
/**
* Get current rate limit status with organization awareness
* Only applies to API executions
@@ -315,6 +376,18 @@ export class RateLimiter {
const counterType = this.getCounterType(triggerType, isAsync)
const execLimit = this.getRateLimitForCounter(limit, counterType)
// Try Redis first for faster status check
const redis = getRedisClient()
if (redis) {
try {
return await this.getRateLimitStatusRedis(redis, rateLimitKey, counterType, execLimit)
} catch (error) {
logger.warn('Redis rate limit status check failed, falling back to DB:', { error })
// Fall through to DB implementation
}
}
// Fallback to DB implementation
const now = new Date()
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
@@ -355,21 +428,6 @@ export class RateLimiter {
}
}
/**
* Legacy method - for backward compatibility
* @deprecated Use getRateLimitStatusWithSubscription instead
*/
async getRateLimitStatus(
userId: string,
subscriptionPlan: SubscriptionPlan = 'free',
triggerType: TriggerType = 'manual',
isAsync = false
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
// For backward compatibility, fetch the subscription
const subscription = await getHighestPrioritySubscription(userId)
return this.getRateLimitStatusWithSubscription(userId, subscription, triggerType, isAsync)
}
/**
* Reset rate limit for a user or organization
*/

View File

@@ -3,10 +3,10 @@ import { workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { RateLimiter } from '@/services/queue/RateLimiter'
const logger = createLogger('ExecutionPreprocessing')

View File

@@ -1,7 +1,6 @@
import { db } from '@sim/db'
import {
member,
organization,
userStats,
user as userTable,
workflow,
@@ -10,7 +9,11 @@ import {
import { eq, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { checkUsageStatus, maybeSendUsageThresholdEmail } from '@/lib/billing/core/usage'
import {
checkUsageStatus,
getOrgUsageLimit,
maybeSendUsageThresholdEmail,
} from '@/lib/billing/core/usage'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { redactApiKeys } from '@/lib/core/security/redaction'
@@ -386,21 +389,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
limit,
})
} else if (sub?.referenceId) {
let orgLimit = 0
const orgRows = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, sub.referenceId))
.limit(1)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(sub.plan)
const minimum = (sub.seats || 1) * basePrice
if (orgRows.length > 0 && orgRows[0].orgUsageLimit) {
const configured = Number.parseFloat(orgRows[0].orgUsageLimit)
orgLimit = Math.max(configured, minimum)
} else {
orgLimit = minimum
}
// Get org usage limit using shared helper
const { limit: orgLimit } = await getOrgUsageLimit(sub.referenceId, sub.plan, sub.seats)
const [{ sum: orgUsageBefore }] = await db
.select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` })

View File

@@ -0,0 +1,414 @@
import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { and, eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import Parser from 'rss-parser'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('RssPollingService')
const MAX_CONSECUTIVE_FAILURES = 10
const MAX_GUIDS_TO_TRACK = 100 // Track recent guids to prevent duplicates
interface RssWebhookConfig {
feedUrl: string
lastCheckedTimestamp?: string
lastSeenGuids?: string[]
etag?: string
lastModified?: string
}
interface RssItem {
title?: string
link?: string
pubDate?: string
guid?: string
description?: string
content?: string
contentSnippet?: string
author?: string
creator?: string
categories?: string[]
enclosure?: {
url: string
type?: string
length?: string | number
}
isoDate?: string
[key: string]: any
}
interface RssFeed {
title?: string
link?: string
description?: string
items: RssItem[]
}
export interface RssWebhookPayload {
item: RssItem
feed: {
title?: string
link?: string
description?: string
}
timestamp: string
}
const parser = new Parser({
timeout: 30000,
headers: {
'User-Agent': 'SimStudio/1.0 RSS Poller',
},
})
async function markWebhookFailed(webhookId: string) {
try {
const result = await db
.update(webhook)
.set({
failedCount: sql`COALESCE(${webhook.failedCount}, 0) + 1`,
lastFailedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
.returning({ failedCount: webhook.failedCount })
const newFailedCount = result[0]?.failedCount || 0
const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES
if (shouldDisable) {
await db
.update(webhook)
.set({
isActive: false,
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
logger.warn(
`Webhook ${webhookId} auto-disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`
)
}
} catch (err) {
logger.error(`Failed to mark webhook ${webhookId} as failed:`, err)
}
}
async function markWebhookSuccess(webhookId: string) {
try {
await db
.update(webhook)
.set({
failedCount: 0,
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
} catch (err) {
logger.error(`Failed to mark webhook ${webhookId} as successful:`, err)
}
}
export async function pollRssWebhooks() {
logger.info('Starting RSS webhook polling')
try {
const activeWebhooksResult = await db
.select({ webhook })
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(
and(eq(webhook.provider, 'rss'), eq(webhook.isActive, true), eq(workflow.isDeployed, true))
)
const activeWebhooks = activeWebhooksResult.map((r) => r.webhook)
if (!activeWebhooks.length) {
logger.info('No active RSS webhooks found')
return { total: 0, successful: 0, failed: 0, details: [] }
}
logger.info(`Found ${activeWebhooks.length} active RSS webhooks`)
const CONCURRENCY = 10
const running: Promise<void>[] = []
let successCount = 0
let failureCount = 0
const enqueue = async (webhookData: (typeof activeWebhooks)[number]) => {
const webhookId = webhookData.id
const requestId = nanoid()
try {
const config = webhookData.providerConfig as unknown as RssWebhookConfig
if (!config?.feedUrl) {
logger.error(`[${requestId}] Missing feedUrl for webhook ${webhookId}`)
await markWebhookFailed(webhookId)
failureCount++
return
}
const now = new Date()
const { feed, items: newItems } = await fetchNewRssItems(config, requestId)
if (!newItems.length) {
await updateWebhookConfig(webhookId, config, now.toISOString(), [])
await markWebhookSuccess(webhookId)
logger.info(`[${requestId}] No new items found for webhook ${webhookId}`)
successCount++
return
}
logger.info(`[${requestId}] Found ${newItems.length} new items for webhook ${webhookId}`)
const { processedCount, failedCount: itemFailedCount } = await processRssItems(
newItems,
feed,
webhookData,
requestId
)
// Collect guids from processed items
const newGuids = newItems
.map((item) => item.guid || item.link || '')
.filter((guid) => guid.length > 0)
await updateWebhookConfig(webhookId, config, now.toISOString(), newGuids)
if (itemFailedCount > 0 && processedCount === 0) {
await markWebhookFailed(webhookId)
failureCount++
logger.warn(
`[${requestId}] All ${itemFailedCount} items failed to process for webhook ${webhookId}`
)
} else {
await markWebhookSuccess(webhookId)
successCount++
logger.info(
`[${requestId}] Successfully processed ${processedCount} items for webhook ${webhookId}${itemFailedCount > 0 ? ` (${itemFailedCount} failed)` : ''}`
)
}
} catch (error) {
logger.error(`[${requestId}] Error processing RSS webhook ${webhookId}:`, error)
await markWebhookFailed(webhookId)
failureCount++
}
}
for (const webhookData of activeWebhooks) {
const promise = enqueue(webhookData)
.then(() => {})
.catch((err) => {
logger.error('Unexpected error in webhook processing:', err)
failureCount++
})
running.push(promise)
if (running.length >= CONCURRENCY) {
const completedIdx = await Promise.race(running.map((p, i) => p.then(() => i)))
running.splice(completedIdx, 1)
}
}
await Promise.allSettled(running)
const summary = {
total: activeWebhooks.length,
successful: successCount,
failed: failureCount,
details: [],
}
logger.info('RSS polling completed', {
total: summary.total,
successful: summary.successful,
failed: summary.failed,
})
return summary
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Error in RSS polling service:', errorMessage)
throw error
}
}
async function fetchNewRssItems(
config: RssWebhookConfig,
requestId: string
): Promise<{ feed: RssFeed; items: RssItem[] }> {
try {
logger.debug(`[${requestId}] Fetching RSS feed: ${config.feedUrl}`)
// Parse the RSS feed
const feed = await parser.parseURL(config.feedUrl)
if (!feed.items || !feed.items.length) {
logger.debug(`[${requestId}] No items in feed`)
return { feed: feed as RssFeed, items: [] }
}
// Filter new items based on timestamp and guids
const lastCheckedTime = config.lastCheckedTimestamp
? new Date(config.lastCheckedTimestamp)
: null
const lastSeenGuids = new Set(config.lastSeenGuids || [])
const newItems = feed.items.filter((item) => {
const itemGuid = item.guid || item.link || ''
// Check if we've already seen this item by guid
if (itemGuid && lastSeenGuids.has(itemGuid)) {
return false
}
// Check if the item is newer than our last check
if (lastCheckedTime && item.isoDate) {
const itemDate = new Date(item.isoDate)
if (itemDate <= lastCheckedTime) {
return false
}
}
return true
})
// Sort by date, newest first
newItems.sort((a, b) => {
const dateA = a.isoDate ? new Date(a.isoDate).getTime() : 0
const dateB = b.isoDate ? new Date(b.isoDate).getTime() : 0
return dateB - dateA
})
// Limit to 25 items per poll to prevent overwhelming the system
const limitedItems = newItems.slice(0, 25)
logger.info(
`[${requestId}] Found ${newItems.length} new items (processing ${limitedItems.length})`
)
return { feed: feed as RssFeed, items: limitedItems as RssItem[] }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Error fetching RSS feed:`, errorMessage)
throw error
}
}
async function processRssItems(
items: RssItem[],
feed: RssFeed,
webhookData: any,
requestId: string
): Promise<{ processedCount: number; failedCount: number }> {
let processedCount = 0
let failedCount = 0
for (const item of items) {
try {
const itemGuid = item.guid || item.link || `${item.title}-${item.pubDate}`
await pollingIdempotency.executeWithIdempotency(
'rss',
`${webhookData.id}:${itemGuid}`,
async () => {
const payload: RssWebhookPayload = {
item: {
title: item.title,
link: item.link,
pubDate: item.pubDate,
guid: item.guid,
description: item.description,
content: item.content,
contentSnippet: item.contentSnippet,
author: item.author || item.creator,
categories: item.categories,
enclosure: item.enclosure,
isoDate: item.isoDate,
},
feed: {
title: feed.title,
link: feed.link,
description: feed.description,
},
timestamp: new Date().toISOString(),
}
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': webhookData.secret || '',
'User-Agent': 'SimStudio/1.0',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorText = await response.text()
logger.error(
`[${requestId}] Failed to trigger webhook for item ${itemGuid}:`,
response.status,
errorText
)
throw new Error(`Webhook request failed: ${response.status} - ${errorText}`)
}
return {
itemGuid,
webhookStatus: response.status,
processed: true,
}
}
)
logger.info(
`[${requestId}] Successfully processed item ${item.title || itemGuid} for webhook ${webhookData.id}`
)
processedCount++
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Error processing item:`, errorMessage)
failedCount++
}
}
return { processedCount, failedCount }
}
async function updateWebhookConfig(
webhookId: string,
_config: RssWebhookConfig,
timestamp: string,
newGuids: string[]
) {
try {
const result = await db.select().from(webhook).where(eq(webhook.id, webhookId))
const existingConfig = (result[0]?.providerConfig as Record<string, any>) || {}
// Merge new guids with existing ones, keeping only the most recent
const existingGuids = existingConfig.lastSeenGuids || []
const allGuids = [...newGuids, ...existingGuids].slice(0, MAX_GUIDS_TO_TRACK)
await db
.update(webhook)
.set({
providerConfig: {
...existingConfig,
lastCheckedTimestamp: timestamp,
lastSeenGuids: allGuids,
} as any,
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
} catch (err) {
logger.error(`Failed to update webhook ${webhookId} config:`, err)
}
}

View File

@@ -795,6 +795,33 @@ export async function formatWebhookInput(
return body
}
if (foundWebhook.provider === 'rss') {
if (body && typeof body === 'object' && 'item' in body) {
const item = body.item as Record<string, any>
const feed = body.feed as Record<string, any>
return {
title: item?.title,
link: item?.link,
pubDate: item?.pubDate,
item,
feed,
webhook: {
data: {
provider: 'rss',
path: foundWebhook.path,
providerConfig: foundWebhook.providerConfig,
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
workflowId: foundWorkflow.id,
}
}
return body
}
if (foundWebhook.provider === 'hubspot') {
const events = Array.isArray(body) ? body : [body]
const event = events[0]
@@ -2344,6 +2371,41 @@ export async function configureOutlookPolling(
}
}
/**
* Configure RSS polling for a webhook
*/
export async function configureRssPolling(webhookData: any, requestId: string): Promise<boolean> {
const logger = createLogger('RssWebhookSetup')
logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
lastCheckedTimestamp: now.toISOString(),
lastSeenGuids: [],
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id))
logger.info(`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`)
return true
} catch (error: any) {
logger.error(`[${requestId}] Failed to configure RSS polling`, {
webhookId: webhookData.id,
error: error.message,
})
return false
}
}
export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined {
if (!twiml) {
return twiml

View File

@@ -1,189 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RateLimiter } from '@/services/queue/RateLimiter'
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS } from '@/services/queue/types'
// Mock the database module
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
// Mock drizzle-orm
vi.mock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value })),
sql: vi.fn((strings, ...values) => ({ sql: strings.join('?'), values })),
and: vi.fn((...conditions) => ({ and: conditions })),
}))
// Mock getHighestPrioritySubscription
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: vi.fn().mockResolvedValue(null),
}))
import { db } from '@sim/db'
describe('RateLimiter', () => {
const rateLimiter = new RateLimiter()
const testUserId = 'test-user-123'
beforeEach(() => {
vi.clearAllMocks()
})
describe('checkRateLimit', () => {
it('should allow unlimited requests for manual trigger type', async () => {
const result = await rateLimiter.checkRateLimit(testUserId, 'free', 'manual', false)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(result.resetAt).toBeInstanceOf(Date)
expect(db.select).not.toHaveBeenCalled()
})
it('should allow first API request for sync execution', async () => {
// Mock select to return empty array (no existing record)
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No existing record
}),
}),
} as any)
// Mock insert to return the expected structure
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimit(testUserId, 'free', 'api', false)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
expect(result.resetAt).toBeInstanceOf(Date)
})
it('should allow first API request for async execution', async () => {
// Mock select to return empty array (no existing record)
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No existing record
}),
}),
} as any)
// Mock insert to return the expected structure
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 0,
asyncApiRequests: 1,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimit(testUserId, 'free', 'api', true)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.asyncApiExecutionsPerMinute - 1)
expect(result.resetAt).toBeInstanceOf(Date)
})
it('should work for all trigger types except manual', async () => {
const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const
for (const triggerType of triggerTypes) {
// Mock select to return empty array (no existing record)
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No existing record
}),
}),
} as any)
// Mock insert to return the expected structure
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimit(testUserId, 'free', triggerType, false)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
}
})
})
describe('getRateLimitStatus', () => {
it('should return unlimited for manual trigger type', async () => {
const status = await rateLimiter.getRateLimitStatus(testUserId, 'free', 'manual', false)
expect(status.used).toBe(0)
expect(status.limit).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.resetAt).toBeInstanceOf(Date)
})
it('should return sync API limits for API trigger type', async () => {
const mockSelect = vi.fn().mockReturnThis()
const mockFrom = vi.fn().mockReturnThis()
const mockWhere = vi.fn().mockReturnThis()
const mockLimit = vi.fn().mockResolvedValue([])
vi.mocked(db.select).mockReturnValue({
from: mockFrom,
where: mockWhere,
limit: mockLimit,
} as any)
const status = await rateLimiter.getRateLimitStatus(testUserId, 'free', 'api', false)
expect(status.used).toBe(0)
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.resetAt).toBeInstanceOf(Date)
})
})
describe('resetRateLimit', () => {
it('should delete rate limit record for user', async () => {
vi.mocked(db.delete).mockReturnValue({
where: vi.fn().mockResolvedValue({}),
} as any)
await rateLimiter.resetRateLimit(testUserId)
expect(db.delete).toHaveBeenCalled()
})
})
})

View File

@@ -1,7 +0,0 @@
export { RateLimiter } from '@/services/queue/RateLimiter'
export type {
RateLimitConfig,
SubscriptionPlan,
TriggerType,
} from '@/services/queue/types'
export { RATE_LIMITS, RateLimitError } from '@/services/queue/types'

View File

@@ -16,6 +16,84 @@ import {
const logger = createLogger('Tools')
/**
* Maximum request body size in bytes before we warn/error about size limits.
* Next.js 16 has a default middleware/proxy body limit of 10MB.
*/
const MAX_REQUEST_BODY_SIZE_BYTES = 10 * 1024 * 1024 // 10MB
/**
* User-friendly error message for body size limit exceeded
*/
const BODY_SIZE_LIMIT_ERROR_MESSAGE =
'Request body size limit exceeded (10MB). The workflow data is too large to process. Try reducing the size of variables, inputs, or data being passed between blocks.'
/**
* Validates request body size and throws a user-friendly error if exceeded
* @param body - The request body string to check
* @param requestId - Request ID for logging
* @param context - Context string for logging (e.g., toolId)
* @throws Error if body size exceeds the limit
*/
function validateRequestBodySize(
body: string | undefined,
requestId: string,
context: string
): void {
if (!body) return
const bodySize = Buffer.byteLength(body, 'utf8')
if (bodySize > MAX_REQUEST_BODY_SIZE_BYTES) {
const bodySizeMB = (bodySize / (1024 * 1024)).toFixed(2)
const maxSizeMB = (MAX_REQUEST_BODY_SIZE_BYTES / (1024 * 1024)).toFixed(0)
logger.error(`[${requestId}] Request body size exceeds limit for ${context}:`, {
bodySize,
bodySizeMB: `${bodySizeMB}MB`,
maxSize: MAX_REQUEST_BODY_SIZE_BYTES,
maxSizeMB: `${maxSizeMB}MB`,
})
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
}
}
/**
* Checks if an error message indicates a body size limit issue
* @param errorMessage - The error message to check
* @returns true if the error is related to body size limits
*/
function isBodySizeLimitError(errorMessage: string): boolean {
const lowerMessage = errorMessage.toLowerCase()
return (
lowerMessage.includes('body size') ||
lowerMessage.includes('payload too large') ||
lowerMessage.includes('entity too large') ||
lowerMessage.includes('request entity too large') ||
lowerMessage.includes('body_not_allowed') ||
lowerMessage.includes('request body larger than')
)
}
/**
* Handles body size limit errors by logging and throwing a user-friendly error
* @param error - The original error
* @param requestId - Request ID for logging
* @param context - Context string for logging (e.g., toolId)
* @throws Error with user-friendly message if it's a size limit error
* @returns false if not a size limit error (caller should continue handling)
*/
function handleBodySizeLimitError(error: unknown, requestId: string, context: string): boolean {
const errorMessage = error instanceof Error ? error.message : String(error)
if (isBodySizeLimitError(errorMessage)) {
logger.error(`[${requestId}] Request body size limit exceeded for ${context}:`, {
originalError: errorMessage,
})
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
}
return false
}
/**
* System parameters that should be filtered out when extracting tool arguments
* These are internal parameters used by the execution framework, not tool inputs
@@ -537,6 +615,9 @@ async function handleInternalRequest(
const headers = new Headers(requestParams.headers)
await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId)
// Check request body size before sending to detect potential size limit issues
validateRequestBodySize(requestParams.body, requestId, toolId)
// Prepare request options
const requestOptions = {
method: requestParams.method,
@@ -548,6 +629,15 @@ async function handleInternalRequest(
// For non-OK responses, attempt JSON first; if parsing fails, fall back to text
if (!response.ok) {
// Check for 413 (Entity Too Large) - body size limit exceeded
if (response.status === 413) {
logger.error(`[${requestId}] Request body too large for ${toolId} (HTTP 413):`, {
status: response.status,
statusText: response.statusText,
})
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
}
let errorData: any
try {
errorData = await response.json()
@@ -645,6 +735,9 @@ async function handleInternalRequest(
error: undefined,
}
} catch (error: any) {
// Check if this is a body size limit error and throw user-friendly message
handleBodySizeLimitError(error, requestId, toolId)
logger.error(`[${requestId}] Internal request error for ${toolId}:`, {
error: error instanceof Error ? error.message : String(error),
})
@@ -737,13 +830,24 @@ async function handleProxyRequest(
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
await addInternalAuthIfNeeded(headers, true, requestId, `proxy:${toolId}`)
const body = JSON.stringify({ toolId, params, executionContext })
// Check request body size before sending
validateRequestBodySize(body, requestId, `proxy:${toolId}`)
const response = await fetch(proxyUrl, {
method: 'POST',
headers,
body: JSON.stringify({ toolId, params, executionContext }),
body,
})
if (!response.ok) {
// Check for 413 (Entity Too Large) - body size limit exceeded
if (response.status === 413) {
logger.error(`[${requestId}] Request body too large for proxy:${toolId} (HTTP 413)`)
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
}
const errorText = await response.text()
logger.error(`[${requestId}] Proxy request failed for ${toolId}:`, {
status: response.status,
@@ -783,6 +887,9 @@ async function handleProxyRequest(
const result = await response.json()
return result
} catch (error: any) {
// Check if this is a body size limit error and throw user-friendly message
handleBodySizeLimitError(error, requestId, `proxy:${toolId}`)
logger.error(`[${requestId}] Proxy request error for ${toolId}:`, {
error: error instanceof Error ? error.message : String(error),
})
@@ -880,6 +987,11 @@ async function executeMcpTool(
workspaceId, // Pass workspace context for scoping
}
const body = JSON.stringify(requestBody)
// Check request body size before sending
validateRequestBodySize(body, actualRequestId, `mcp:${toolId}`)
logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, {
hasWorkspaceId: !!workspaceId,
hasWorkflowId: !!workflowId,
@@ -888,7 +1000,7 @@ async function executeMcpTool(
const response = await fetch(`${baseUrl}/api/mcp/tools/execute`, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
body,
})
const endTime = new Date()
@@ -896,6 +1008,21 @@ async function executeMcpTool(
const duration = endTime.getTime() - new Date(actualStartTime).getTime()
if (!response.ok) {
// Check for 413 (Entity Too Large) - body size limit exceeded
if (response.status === 413) {
logger.error(`[${actualRequestId}] Request body too large for mcp:${toolId} (HTTP 413)`)
return {
success: false,
output: {},
error: BODY_SIZE_LIMIT_ERROR_MESSAGE,
timing: {
startTime: actualStartTime,
endTime: endTimeISO,
duration,
},
}
}
let errorMessage = `MCP tool execution failed: ${response.status} ${response.statusText}`
try {
@@ -950,6 +1077,24 @@ async function executeMcpTool(
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - new Date(actualStartTime).getTime()
// Check if this is a body size limit error
const errorMsg = error instanceof Error ? error.message : String(error)
if (isBodySizeLimitError(errorMsg)) {
logger.error(`[${actualRequestId}] Request body size limit exceeded for mcp:${toolId}:`, {
originalError: errorMsg,
})
return {
success: false,
output: {},
error: BODY_SIZE_LIMIT_ERROR_MESSAGE,
timing: {
startTime: actualStartTime,
endTime: endTimeISO,
duration,
},
}
}
logger.error(`[${actualRequestId}] Error executing MCP tool ${toolId}:`, error)
const errorMessage =

View File

@@ -72,6 +72,7 @@ import {
microsoftTeamsWebhookTrigger,
} from '@/triggers/microsoftteams'
import { outlookPollingTrigger } from '@/triggers/outlook'
import { rssPollingTrigger } from '@/triggers/rss'
import { slackWebhookTrigger } from '@/triggers/slack'
import { stripeWebhookTrigger } from '@/triggers/stripe'
import { telegramWebhookTrigger } from '@/triggers/telegram'
@@ -131,6 +132,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
microsoftteams_webhook: microsoftTeamsWebhookTrigger,
microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger,
outlook_poller: outlookPollingTrigger,
rss_poller: rssPollingTrigger,
stripe_webhook: stripeWebhookTrigger,
telegram_webhook: telegramWebhookTrigger,
typeform_webhook: typeformWebhookTrigger,

View File

@@ -0,0 +1 @@
export { rssPollingTrigger } from './poller'

View File

@@ -0,0 +1,115 @@
import { RssIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
export const rssPollingTrigger: TriggerConfig = {
id: 'rss_poller',
name: 'RSS Feed Trigger',
provider: 'rss',
description: 'Triggers when new items are published to an RSS feed',
version: '1.0.0',
icon: RssIcon,
subBlocks: [
{
id: 'feedUrl',
title: 'Feed URL',
type: 'short-input',
placeholder: 'https://example.com/feed.xml',
description: 'The URL of the RSS or Atom feed to monitor',
required: true,
mode: 'trigger',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Enter the URL of any RSS or Atom feed you want to monitor',
'The feed will be checked every minute for new items',
'When a new item is published, your workflow will be triggered with the item data',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'rss_poller',
},
],
outputs: {
item: {
title: {
type: 'string',
description: 'Item title',
},
link: {
type: 'string',
description: 'Item link/URL',
},
pubDate: {
type: 'string',
description: 'Publication date',
},
guid: {
type: 'string',
description: 'Unique identifier',
},
summary: {
type: 'string',
description: 'Item description/summary',
},
content: {
type: 'string',
description: 'Full content (content:encoded)',
},
contentSnippet: {
type: 'string',
description: 'Content snippet without HTML',
},
author: {
type: 'string',
description: 'Author name',
},
categories: {
type: 'json',
description: 'Categories/tags array',
},
enclosure: {
type: 'json',
description: 'Media attachment info (url, type, length)',
},
isoDate: {
type: 'string',
description: 'Publication date in ISO format',
},
},
feed: {
title: {
type: 'string',
description: 'Feed title',
},
link: {
type: 'string',
description: 'Feed website link',
},
feedDescription: {
type: 'string',
description: 'Feed description',
},
},
timestamp: {
type: 'string',
description: 'Event timestamp',
},
},
}

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -20,6 +21,7 @@
"onedollarstats": "0.0.10",
"postgres": "^3.4.5",
"remark-gfm": "4.0.1",
"rss-parser": "3.13.0",
"socket.io-client": "4.8.1",
"twilio": "5.9.0",
},
@@ -2893,6 +2895,8 @@
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
"run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="],
"run-exclusive": ["run-exclusive@2.2.19", "", { "dependencies": { "minimal-polyfills": "^2.2.3" } }, "sha512-K3mdoAi7tjJ/qT7Flj90L7QyPozwUaAG+CVhkdDje4HLKXUYC3N/Jzkau3flHVDLQVhiHBtcimVodMjN9egYbA=="],
@@ -2911,6 +2915,8 @@
"satori": ["satori@0.12.2", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.16", "css-to-react-native": "^3.0.0", "emoji-regex": "^10.2.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-wasm-web": "^0.3.3" } }, "sha512-3C/laIeE6UUe9A+iQ0A48ywPVCCMKCNSTU5Os101Vhgsjd3AAxGNjyq0uAA8kulMPK5n0csn8JlxPN9riXEjLA=="],
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -3291,6 +3297,8 @@
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
@@ -3777,6 +3785,8 @@
"rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
@@ -3835,6 +3845,8 @@
"xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@anthropic-ai/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],

View File

@@ -652,7 +652,16 @@ cronjobs:
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
rssWebhookPoll:
enabled: true
name: rss-webhook-poll
schedule: "*/1 * * * *"
path: "/api/webhooks/poll/rss"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
renewSubscriptions:
enabled: true
name: renew-subscriptions

View File

@@ -50,6 +50,7 @@
"onedollarstats": "0.0.10",
"postgres": "^3.4.5",
"remark-gfm": "4.0.1",
"rss-parser": "3.13.0",
"socket.io-client": "4.8.1",
"twilio": "5.9.0"
},

View File

@@ -82,7 +82,11 @@ async function generateIconMapping(): Promise<Record<string, string>> {
}
// Skip blocks that don't have documentation (same logic as generateBlockDoc)
if (blockConfig.type.includes('_trigger') || blockConfig.type.includes('_webhook')) {
if (
blockConfig.type.includes('_trigger') ||
blockConfig.type.includes('_webhook') ||
blockConfig.type.includes('rss')
) {
continue
}
@@ -95,7 +99,8 @@ async function generateIconMapping(): Promise<Record<string, string>> {
blockConfig.type === 'webhook' ||
blockConfig.type === 'schedule' ||
blockConfig.type === 'mcp' ||
blockConfig.type === 'generic_webhook'
blockConfig.type === 'generic_webhook' ||
blockConfig.type === 'rss'
) {
continue
}
@@ -910,7 +915,11 @@ async function generateBlockDoc(blockPath: string) {
return
}
if (blockConfig.type.includes('_trigger') || blockConfig.type.includes('_webhook')) {
if (
blockConfig.type.includes('_trigger') ||
blockConfig.type.includes('_webhook') ||
blockConfig.type.includes('rss')
) {
console.log(`Skipping ${blockConfig.type} - contains '_trigger'`)
return
}
@@ -924,7 +933,8 @@ async function generateBlockDoc(blockPath: string) {
blockConfig.type === 'webhook' ||
blockConfig.type === 'schedule' ||
blockConfig.type === 'mcp' ||
blockConfig.type === 'generic_webhook'
blockConfig.type === 'generic_webhook' ||
blockConfig.type === 'rss'
) {
return
}