diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 9fc1e7c3b..ee65b256c 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -243,6 +243,9 @@ export async function generateMetadata(props: { const baseUrl = 'https://docs.sim.ai' const fullUrl = `${baseUrl}${page.url}` + const description = page.data.description || '' + const ogImageUrl = `${baseUrl}/api/og?title=${encodeURIComponent(page.data.title)}&category=DOCUMENTATION${description ? `&description=${encodeURIComponent(description)}` : ''}` + return { title: page.data.title, description: @@ -272,12 +275,23 @@ export async function generateMetadata(props: { alternateLocale: ['en', 'es', 'fr', 'de', 'ja', 'zh'] .filter((lang) => lang !== params.lang) .map((lang) => (lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}`)), + images: [ + { + url: ogImageUrl, + width: 1200, + height: 630, + alt: page.data.title, + }, + ], }, twitter: { - card: 'summary', + card: 'summary_large_image', title: page.data.title, description: page.data.description || 'Sim visual workflow builder for AI applications documentation', + images: [ogImageUrl], + creator: '@simdotai', + site: '@simdotai', }, robots: { index: true, diff --git a/apps/docs/app/api/og/route.tsx b/apps/docs/app/api/og/route.tsx new file mode 100644 index 000000000..c4d8e550a --- /dev/null +++ b/apps/docs/app/api/og/route.tsx @@ -0,0 +1,173 @@ +import { ImageResponse } from 'next/og' +import type { NextRequest } from 'next/server' + +export const runtime = 'edge' + +const TITLE_FONT_SIZE = { + large: 64, + medium: 56, + small: 48, +} as const + +function getTitleFontSize(title: string): number { + if (title.length > 45) return TITLE_FONT_SIZE.small + if (title.length > 30) return TITLE_FONT_SIZE.medium + return TITLE_FONT_SIZE.large +} + +/** + * Loads a Google Font dynamically by fetching the CSS and extracting the font URL. + */ +async function loadGoogleFont(font: string, weights: string, text: string): Promise { + const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}` + const css = await (await fetch(url)).text() + const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/) + + if (resource) { + const response = await fetch(resource[1]) + if (response.status === 200) { + return await response.arrayBuffer() + } + } + + throw new Error('Failed to load font data') +} + +/** + * Generates dynamic Open Graph images for documentation pages. + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const title = searchParams.get('title') || 'Documentation' + const category = searchParams.get('category') || 'DOCUMENTATION' + const description = searchParams.get('description') || '' + + const baseUrl = new URL(request.url).origin + const backgroundImageUrl = `${baseUrl}/static/og-background.png` + + const allText = `${title}${category}${description}docs.sim.ai` + const fontData = await loadGoogleFont('Geist', '400;500;600', allText) + + return new ImageResponse( +
+ {/* Background texture */} + + + {/* Subtle purple glow from bottom right */} +
+ + {/* Content */} +
+ {/* Logo */} + sim + + {/* Category + Title + Description */} +
+ + {category} + + + {title} + + {description && ( + + {description.length > 100 ? `${description.slice(0, 100)}...` : description} + + )} +
+ + {/* Footer */} + + docs.sim.ai + +
+
, + { + width: 1200, + height: 630, + fonts: [ + { + name: 'Geist', + data: fontData, + style: 'normal', + }, + ], + } + ) +} diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 807868d78..7f982a048 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -56,6 +56,14 @@ export const metadata = { title: 'Sim Documentation - Visual Workflow Builder for AI Applications', description: 'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.', + images: [ + { + url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION', + width: 1200, + height: 630, + alt: 'Sim Documentation', + }, + ], }, twitter: { card: 'summary_large_image', @@ -64,7 +72,7 @@ export const metadata = { 'Comprehensive documentation for Sim - the visual workflow builder for AI applications.', creator: '@simdotai', site: '@simdotai', - images: ['/og-image.png'], + images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION'], }, robots: { index: true, diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index f9e690a72..12ead996f 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4206,12 +4206,20 @@ export function RssIcon(props: SVGProps) { export function SpotifyIcon(props: SVGProps) { return ( - - + - ) } diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 977138187..77b3769a9 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -119,116 +119,116 @@ import { type IconComponent = ComponentType> export const blockTypeToIconMap: Record = { - calendly: CalendlyIcon, - mailchimp: MailchimpIcon, - postgresql: PostgresIcon, - twilio_voice: TwilioIcon, - elasticsearch: ElasticsearchIcon, - rds: RDSIcon, - translate: TranslateIcon, - dynamodb: DynamoDBIcon, - wordpress: WordpressIcon, - tavily: TavilyIcon, + zoom: ZoomIcon, + zep: ZepIcon, zendesk: ZendeskIcon, youtube: YouTubeIcon, - supabase: SupabaseIcon, - vision: EyeIcon, - zoom: ZoomIcon, - confluence: ConfluenceIcon, - arxiv: ArxivIcon, - webflow: WebflowIcon, - pinecone: PineconeIcon, - apollo: ApolloIcon, - whatsapp: WhatsAppIcon, - typeform: TypeformIcon, - qdrant: QdrantIcon, - shopify: ShopifyIcon, - asana: AsanaIcon, - sqs: SQSIcon, - apify: ApifyIcon, - memory: BrainIcon, - gitlab: GitLabIcon, - polymarket: PolymarketIcon, - serper: SerperIcon, - linear: LinearIcon, - exa: ExaAIIcon, - telegram: TelegramIcon, - salesforce: SalesforceIcon, - hubspot: HubspotIcon, - hunter: HunterIOIcon, - linkup: LinkupIcon, - mongodb: MongoDBIcon, - airtable: AirtableIcon, - discord: DiscordIcon, - ahrefs: AhrefsIcon, - neo4j: Neo4jIcon, - tts: TTSIcon, - jina: JinaAIIcon, - google_docs: GoogleDocsIcon, - perplexity: PerplexityIcon, - google_search: GoogleIcon, x: xIcon, - kalshi: KalshiIcon, - google_calendar: GoogleCalendarIcon, - zep: ZepIcon, - posthog: PosthogIcon, - grafana: GrafanaIcon, - google_slides: GoogleSlidesIcon, - microsoft_planner: MicrosoftPlannerIcon, - thinking: BrainIcon, - pipedrive: PipedriveIcon, - dropbox: DropboxIcon, - stagehand: StagehandIcon, - google_forms: GoogleFormsIcon, - file: DocumentIcon, - mistral_parse: MistralIcon, - gmail: GmailIcon, - openai: OpenAIIcon, - outlook: OutlookIcon, - incidentio: IncidentioIcon, - onedrive: MicrosoftOneDriveIcon, - resend: ResendIcon, - google_vault: GoogleVaultIcon, - sharepoint: MicrosoftSharepointIcon, - huggingface: HuggingFaceIcon, - sendgrid: SendgridIcon, - video_generator: VideoIcon, - smtp: SmtpIcon, - google_groups: GoogleGroupsIcon, - mailgun: MailgunIcon, - clay: ClayIcon, - jira: JiraIcon, - search: SearchIcon, - linkedin: LinkedInIcon, - wealthbox: WealthboxIcon, - notion: NotionIcon, - elevenlabs: ElevenLabsIcon, - microsoft_teams: MicrosoftTeamsIcon, - github: GithubIcon, - sftp: SftpIcon, - ssh: SshIcon, - google_drive: GoogleDriveIcon, - sentry: SentryIcon, - reddit: RedditIcon, - parallel_ai: ParallelIcon, - spotify: SpotifyIcon, - stripe: StripeIcon, - s3: S3Icon, - trello: TrelloIcon, - mem0: Mem0Icon, - knowledge: PackageSearchIcon, - intercom: IntercomIcon, - twilio_sms: TwilioIcon, - duckduckgo: DuckDuckGoIcon, - slack: SlackIcon, - datadog: DatadogIcon, - microsoft_excel: MicrosoftExcelIcon, - image_generator: ImageIcon, - google_sheets: GoogleSheetsIcon, + wordpress: WordpressIcon, wikipedia: WikipediaIcon, - cursor: CursorIcon, - firecrawl: FirecrawlIcon, - mysql: MySQLIcon, - browser_use: BrowserUseIcon, + whatsapp: WhatsAppIcon, + webflow: WebflowIcon, + wealthbox: WealthboxIcon, + vision: EyeIcon, + video_generator: VideoIcon, + typeform: TypeformIcon, + twilio_voice: TwilioIcon, + twilio_sms: TwilioIcon, + tts: TTSIcon, + trello: TrelloIcon, + translate: TranslateIcon, + thinking: BrainIcon, + telegram: TelegramIcon, + tavily: TavilyIcon, + supabase: SupabaseIcon, stt: STTIcon, + stripe: StripeIcon, + stagehand: StagehandIcon, + ssh: SshIcon, + sqs: SQSIcon, + spotify: SpotifyIcon, + smtp: SmtpIcon, + slack: SlackIcon, + shopify: ShopifyIcon, + sharepoint: MicrosoftSharepointIcon, + sftp: SftpIcon, + serper: SerperIcon, + sentry: SentryIcon, + sendgrid: SendgridIcon, + search: SearchIcon, + salesforce: SalesforceIcon, + s3: S3Icon, + resend: ResendIcon, + reddit: RedditIcon, + rds: RDSIcon, + qdrant: QdrantIcon, + posthog: PosthogIcon, + postgresql: PostgresIcon, + polymarket: PolymarketIcon, + pipedrive: PipedriveIcon, + pinecone: PineconeIcon, + perplexity: PerplexityIcon, + parallel_ai: ParallelIcon, + outlook: OutlookIcon, + openai: OpenAIIcon, + onedrive: MicrosoftOneDriveIcon, + notion: NotionIcon, + neo4j: Neo4jIcon, + mysql: MySQLIcon, + mongodb: MongoDBIcon, + mistral_parse: MistralIcon, + microsoft_teams: MicrosoftTeamsIcon, + microsoft_planner: MicrosoftPlannerIcon, + microsoft_excel: MicrosoftExcelIcon, + memory: BrainIcon, + mem0: Mem0Icon, + mailgun: MailgunIcon, + mailchimp: MailchimpIcon, + linkup: LinkupIcon, + linkedin: LinkedInIcon, + linear: LinearIcon, + knowledge: PackageSearchIcon, + kalshi: KalshiIcon, + jira: JiraIcon, + jina: JinaAIIcon, + intercom: IntercomIcon, + incidentio: IncidentioIcon, + image_generator: ImageIcon, + hunter: HunterIOIcon, + huggingface: HuggingFaceIcon, + hubspot: HubspotIcon, + grafana: GrafanaIcon, + google_vault: GoogleVaultIcon, + google_slides: GoogleSlidesIcon, + google_sheets: GoogleSheetsIcon, + google_groups: GoogleGroupsIcon, + google_forms: GoogleFormsIcon, + google_drive: GoogleDriveIcon, + google_docs: GoogleDocsIcon, + google_calendar: GoogleCalendarIcon, + google_search: GoogleIcon, + gmail: GmailIcon, + gitlab: GitLabIcon, + github: GithubIcon, + firecrawl: FirecrawlIcon, + file: DocumentIcon, + exa: ExaAIIcon, + elevenlabs: ElevenLabsIcon, + elasticsearch: ElasticsearchIcon, + dynamodb: DynamoDBIcon, + duckduckgo: DuckDuckGoIcon, + dropbox: DropboxIcon, + discord: DiscordIcon, + datadog: DatadogIcon, + cursor: CursorIcon, + confluence: ConfluenceIcon, + clay: ClayIcon, + calendly: CalendlyIcon, + browser_use: BrowserUseIcon, + asana: AsanaIcon, + arxiv: ArxivIcon, + apollo: ApolloIcon, + apify: ApifyIcon, + airtable: AirtableIcon, + ahrefs: AhrefsIcon, } diff --git a/apps/docs/content/docs/de/tools/webflow.mdx b/apps/docs/content/docs/de/tools/webflow.mdx index 75dd6605b..4b34e13e9 100644 --- a/apps/docs/content/docs/de/tools/webflow.mdx +++ b/apps/docs/content/docs/de/tools/webflow.mdx @@ -39,9 +39,10 @@ Alle Elemente aus einer Webflow CMS-Sammlung auflisten | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | -| `offset` | number | Nein | Offset für Paginierung \(optional\) | -| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente \(optional, Standard: 100\) | +| `offset` | number | Nein | Offset für Paginierung (optional) | +| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente (optional, Standard: 100) | #### Ausgabe @@ -58,6 +59,7 @@ Ein einzelnes Element aus einer Webflow CMS-Sammlung abrufen | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | | `itemId` | string | Ja | ID des abzurufenden Elements | @@ -76,8 +78,9 @@ Ein neues Element in einer Webflow CMS-Sammlung erstellen | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | -| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Die Schlüssel sollten mit den Feldnamen der Sammlung übereinstimmen. | +| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Schlüssel sollten mit den Sammlungsfeldnamen übereinstimmen. | #### Ausgabe @@ -94,6 +97,7 @@ Ein vorhandenes Element in einer Webflow CMS-Sammlung aktualisieren | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | | `itemId` | string | Ja | ID des zu aktualisierenden Elements | | `fieldData` | json | Ja | Zu aktualisierende Felddaten als JSON-Objekt. Nur Felder einschließen, die geändert werden sollen. | @@ -113,6 +117,7 @@ Ein Element aus einer Webflow CMS-Sammlung löschen | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | | `itemId` | string | Ja | ID des zu löschenden Elements | diff --git a/apps/docs/content/docs/en/tools/webflow.mdx b/apps/docs/content/docs/en/tools/webflow.mdx index f23fd172c..168f3eaf4 100644 --- a/apps/docs/content/docs/en/tools/webflow.mdx +++ b/apps/docs/content/docs/en/tools/webflow.mdx @@ -42,6 +42,7 @@ List all items from a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `offset` | number | No | Offset for pagination \(optional\) | | `limit` | number | No | Maximum number of items to return \(optional, default: 100\) | @@ -61,6 +62,7 @@ Get a single item from a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `itemId` | string | Yes | ID of the item to retrieve | @@ -79,6 +81,7 @@ Create a new item in a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `fieldData` | json | Yes | Field data for the new item as a JSON object. Keys should match collection field names. | @@ -97,6 +100,7 @@ Update an existing item in a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `itemId` | string | Yes | ID of the item to update | | `fieldData` | json | Yes | Field data to update as a JSON object. Only include fields you want to change. | @@ -116,6 +120,7 @@ Delete an item from a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `itemId` | string | Yes | ID of the item to delete | diff --git a/apps/docs/content/docs/es/tools/webflow.mdx b/apps/docs/content/docs/es/tools/webflow.mdx index 09aec1087..e5c254874 100644 --- a/apps/docs/content/docs/es/tools/webflow.mdx +++ b/apps/docs/content/docs/es/tools/webflow.mdx @@ -39,6 +39,7 @@ Listar todos los elementos de una colección del CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `offset` | number | No | Desplazamiento para paginación \(opcional\) | | `limit` | number | No | Número máximo de elementos a devolver \(opcional, predeterminado: 100\) | @@ -58,6 +59,7 @@ Obtener un solo elemento de una colección del CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `itemId` | string | Sí | ID del elemento a recuperar | @@ -76,6 +78,7 @@ Crear un nuevo elemento en una colección del CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `fieldData` | json | Sí | Datos de campo para el nuevo elemento como objeto JSON. Las claves deben coincidir con los nombres de campo de la colección. | @@ -94,6 +97,7 @@ Actualizar un elemento existente en una colección CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `itemId` | string | Sí | ID del elemento a actualizar | | `fieldData` | json | Sí | Datos de campo para actualizar como objeto JSON. Solo incluye los campos que quieres cambiar. | @@ -113,6 +117,7 @@ Eliminar un elemento de una colección CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `itemId` | string | Sí | ID del elemento a eliminar | diff --git a/apps/docs/content/docs/fr/tools/webflow.mdx b/apps/docs/content/docs/fr/tools/webflow.mdx index 7765b0052..ef8bf0917 100644 --- a/apps/docs/content/docs/fr/tools/webflow.mdx +++ b/apps/docs/content/docs/fr/tools/webflow.mdx @@ -38,7 +38,8 @@ Lister tous les éléments d'une collection CMS Webflow #### Entrée | Paramètre | Type | Obligatoire | Description | -| --------- | ---- | -------- | ----------- | +| --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `offset` | number | Non | Décalage pour la pagination \(facultatif\) | | `limit` | number | Non | Nombre maximum d'éléments à retourner \(facultatif, par défaut : 100\) | @@ -57,7 +58,8 @@ Obtenir un seul élément d'une collection CMS Webflow #### Entrée | Paramètre | Type | Obligatoire | Description | -| --------- | ---- | -------- | ----------- | +| --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `itemId` | string | Oui | ID de l'élément à récupérer | @@ -76,8 +78,9 @@ Créer un nouvel élément dans une collection CMS Webflow | Paramètre | Type | Obligatoire | Description | | --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | -| `fieldData` | json | Oui | Données de champ pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. | +| `fieldData` | json | Oui | Données des champs pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. | #### Sortie @@ -94,9 +97,10 @@ Mettre à jour un élément existant dans une collection CMS Webflow | Paramètre | Type | Obligatoire | Description | | --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `itemId` | string | Oui | ID de l'élément à mettre à jour | -| `fieldData` | json | Oui | Données de champ à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. | +| `fieldData` | json | Oui | Données des champs à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. | #### Sortie @@ -113,6 +117,7 @@ Supprimer un élément d'une collection CMS Webflow | Paramètre | Type | Obligatoire | Description | | --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `itemId` | string | Oui | ID de l'élément à supprimer | diff --git a/apps/docs/content/docs/ja/tools/webflow.mdx b/apps/docs/content/docs/ja/tools/webflow.mdx index 83ff2c384..e44c62a95 100644 --- a/apps/docs/content/docs/ja/tools/webflow.mdx +++ b/apps/docs/content/docs/ja/tools/webflow.mdx @@ -39,9 +39,10 @@ Webflow CMSコレクションからすべてのアイテムを一覧表示する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `offset` | number | いいえ | ページネーション用のオフセット(オプション) | -| `limit` | number | いいえ | 返すアイテムの最大数(オプション、デフォルト:100) | +| `limit` | number | いいえ | 返す最大アイテム数(オプション、デフォルト:100) | #### 出力 @@ -58,6 +59,7 @@ Webflow CMSコレクションから単一のアイテムを取得する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `itemId` | string | はい | 取得するアイテムのID | @@ -76,8 +78,9 @@ Webflow CMSコレクションに新しいアイテムを作成する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | -| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト形式)。キーはコレクションのフィールド名と一致する必要があります。 | +| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト)。キーはコレクションフィールド名と一致する必要があります。 | #### 出力 @@ -94,9 +97,10 @@ Webflow CMSコレクション内の既存アイテムを更新する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `itemId` | string | はい | 更新するアイテムのID | -| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト形式)。変更したいフィールドのみを含めてください。 | +| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト)。変更したいフィールドのみを含めてください。 | #### 出力 @@ -113,6 +117,7 @@ Webflow CMSコレクションからアイテムを削除する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `itemId` | string | はい | 削除するアイテムのID | diff --git a/apps/docs/content/docs/zh/tools/webflow.mdx b/apps/docs/content/docs/zh/tools/webflow.mdx index 28f569690..ac405f673 100644 --- a/apps/docs/content/docs/zh/tools/webflow.mdx +++ b/apps/docs/content/docs/zh/tools/webflow.mdx @@ -38,9 +38,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | -| `offset` | number | 否 | 分页偏移量(可选) | -| `limit` | number | 否 | 返回的最大项目数(可选,默认值:100) | +| `offset` | number | 否 | 分页的偏移量(可选) | +| `limit` | number | 否 | 要返回的最大项目数(可选,默认值:100) | #### 输出 @@ -57,8 +58,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | -| `itemId` | string | 是 | 要检索的项目 ID | +| `itemId` | string | 是 | 要检索项目的 ID | #### 输出 @@ -75,8 +77,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | -| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名称匹配。 | +| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 | #### 输出 @@ -93,9 +96,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | | `itemId` | string | 是 | 要更新项目的 ID | -| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含您想更改的字段。 | +| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含需要更改的字段。 | #### 输出 @@ -112,6 +116,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | | `itemId` | string | 是 | 要删除项目的 ID | diff --git a/apps/docs/i18n.lock b/apps/docs/i18n.lock index 5cf9a4ed6..dca4eab3f 100644 --- a/apps/docs/i18n.lock +++ b/apps/docs/i18n.lock @@ -5973,31 +5973,31 @@ checksums: content/9: 5914baadfaf2ca26d54130a36dd5ed29 content/10: 25507380ac7d9c7f8cf9f5256c6a0dbb content/11: 371d0e46b4bd2c23f559b8bc112f6955 - content/12: e7fb612c3323c1e6b05eacfcea360d34 + content/12: e034523b05e8c7bd1723ef0ba96c5332 content/13: bcadfc362b69078beee0088e5936c98b content/14: e5f830d6049ff79a318110098e5e0130 content/15: 711e90714806b91f93923018e82ad2e9 content/16: 0f3f7d9699d7397cb3a094c3229329ee content/17: 371d0e46b4bd2c23f559b8bc112f6955 - content/18: c53b5b8f901066e63fe159ad2fa5e6e0 + content/18: 4b0c581b30f4449b0bfa3cdd4af69e02 content/19: bcadfc362b69078beee0088e5936c98b content/20: 5f2afdd49c3ac13381401c69d1eca22a content/21: cc4baa9096fafa4c6276f6136412ba66 content/22: 676f76e8a7154a576d7fa20b245cef70 content/23: 371d0e46b4bd2c23f559b8bc112f6955 - content/24: c67c387eb7e274ee7c07b7e1748afce1 + content/24: d26dd24c5398fd036d1f464ba3789002 content/25: bcadfc362b69078beee0088e5936c98b content/26: a6ffebda549ad5b903a66c7d9ac03a20 content/27: 0dadd51cde48d6ea75b29ec3ee4ade56 content/28: cdc74f6483a0b4e9933ecdd92ed7480f content/29: 371d0e46b4bd2c23f559b8bc112f6955 - content/30: 4cda10aa374e1a46d60ad14eeaa79100 + content/30: cec3953ee52d1d3c8b1a495f9684d35b content/31: bcadfc362b69078beee0088e5936c98b content/32: 5f221421953a0e760ead7388cbf66561 content/33: a3c0372590cef72d5d983dbc8dbbc2cb content/34: 1402e53c08bdd8a741f44b2d66fcd003 content/35: 371d0e46b4bd2c23f559b8bc112f6955 - content/36: 028e579a28e55def4fbc59f39f4610b7 + content/36: db921b05a9e5ddceb28a4f3f1af2a377 content/37: bcadfc362b69078beee0088e5936c98b content/38: 4fe4260da2f137679ce2fa42cffcf56a content/39: b3f310d5ef115bea5a8b75bf25d7ea9a diff --git a/apps/docs/public/static/og-background.png b/apps/docs/public/static/og-background.png new file mode 100644 index 000000000..173d38076 Binary files /dev/null and b/apps/docs/public/static/og-background.png differ diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 7c6ca476d..f8e926f88 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -4,10 +4,13 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres" # PostgreSQL Port (Optional) - defaults to 5432 if not specified # POSTGRES_PORT=5432 -# Authentication (Required) +# Authentication (Required unless DISABLE_AUTH=true) BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation BETTER_AUTH_URL=http://localhost:3000 +# Authentication Bypass (Optional - for self-hosted deployments behind private networks) +# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests. + # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx index 5651e1d5e..c7eba7af1 100644 --- a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx +++ b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx @@ -1,7 +1,7 @@ 'use server' import { env } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' export async function getOAuthProviderStatus() { const githubAvailable = !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index 91648a5e0..d0173542b 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -1,7 +1,6 @@ import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import LoginForm from '@/app/(auth)/login/login-form' -// Force dynamic rendering to avoid prerender errors with search params export const dynamic = 'force-dynamic' export default async function LoginPage() { diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index f8878e8aa..b267716e3 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,16 +1,16 @@ -import { env, isTruthy } from '@/lib/core/config/env' +import { isRegistrationDisabled } from '@/lib/core/config/feature-flags' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' export const dynamic = 'force-dynamic' export default async function SignupPage() { - const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus() - - if (isTruthy(env.DISABLE_REGISTRATION)) { + if (isRegistrationDisabled) { return
Registration is disabled, please contact your admin.
} + const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus() + return ( ({ + isDev: true, + isHosted: false, + isProd: false, +})) + describe('Chat Edit API Route', () => { const mockSelect = vi.fn() const mockFrom = vi.fn() @@ -24,7 +30,6 @@ describe('Chat Edit API Route', () => { beforeEach(() => { vi.resetModules() - // Set default return values mockLimit.mockResolvedValue([]) mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) @@ -77,10 +82,6 @@ describe('Chat Edit API Route', () => { getEmailDomain: vi.fn().mockReturnValue('localhost:3000'), })) - vi.doMock('@/lib/core/config/environment', () => ({ - isDev: true, - })) - vi.doMock('@/app/api/chat/utils', () => ({ checkChatAccess: mockCheckChatAccess, })) @@ -254,7 +255,6 @@ describe('Chat Edit API Route', () => { mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) - // Reset and reconfigure mockLimit to return the conflict mockLimit.mockReset() mockLimit.mockResolvedValue([{ id: 'other-chat-id', identifier: 'new-identifier' }]) mockWhere.mockReturnValue({ limit: mockLimit }) @@ -291,7 +291,7 @@ describe('Chat Edit API Route', () => { const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'PATCH', - body: JSON.stringify({ authType: 'password' }), // No password provided + body: JSON.stringify({ authType: 'password' }), }) const { PATCH } = await import('@/app/api/chat/manage/[id]/route') const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) @@ -316,9 +316,8 @@ describe('Chat Edit API Route', () => { workflowId: 'workflow-123', } - // User doesn't own chat but has workspace admin access mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) - mockLimit.mockResolvedValueOnce([]) // No identifier conflict + mockLimit.mockResolvedValueOnce([]) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'PATCH', @@ -399,7 +398,6 @@ describe('Chat Edit API Route', () => { }), })) - // User doesn't own chat but has workspace admin access mockCheckChatAccess.mockResolvedValue({ hasAccess: true }) mockWhere.mockResolvedValue(undefined) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index c624582e9..d7141aa2e 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { isDev } from '@/lib/core/config/environment' +import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index a2476424c..3a49f32cf 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -5,7 +5,7 @@ import type { NextRequest } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { isDev } from '@/lib/core/config/environment' +import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 9faa65b1e..188c03b11 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -44,6 +44,12 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn(), })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + isDev: true, + isHosted: false, + isProd: false, +})) + describe('Chat API Utils', () => { beforeEach(() => { vi.doMock('@/lib/logs/console/logger', () => ({ @@ -62,11 +68,6 @@ describe('Chat API Utils', () => { NODE_ENV: 'development', }, }) - - vi.doMock('@/lib/core/config/environment', () => ({ - isDev: true, - isHosted: false, - })) }) afterEach(() => { diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 1e41f9201..94cc1ec30 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,14 +1,19 @@ +import { createHash } from 'crypto' import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { eq } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' -import { isDev } from '@/lib/core/config/environment' +import { isDev } from '@/lib/core/config/feature-flags' import { decryptSecret } from '@/lib/core/security/encryption' import { createLogger } from '@/lib/logs/console/logger' import { hasAdminPermission } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ChatAuthUtils') +function hashPassword(encryptedPassword: string): string { + return createHash('sha256').update(encryptedPassword).digest('hex').substring(0, 8) +} + /** * Check if user has permission to create a chat for a specific workflow * Either the user owns the workflow directly OR has admin permission for the workflow's workspace @@ -77,14 +82,20 @@ export async function checkChatAccess( return { hasAccess: false } } -const encryptAuthToken = (chatId: string, type: string): string => { - return Buffer.from(`${chatId}:${type}:${Date.now()}`).toString('base64') +function encryptAuthToken(chatId: string, type: string, encryptedPassword?: string | null): string { + const pwHash = encryptedPassword ? hashPassword(encryptedPassword) : '' + return Buffer.from(`${chatId}:${type}:${Date.now()}:${pwHash}`).toString('base64') } -export const validateAuthToken = (token: string, chatId: string): boolean => { +export function validateAuthToken( + token: string, + chatId: string, + encryptedPassword?: string | null +): boolean { try { const decoded = Buffer.from(token, 'base64').toString() - const [storedId, _type, timestamp] = decoded.split(':') + const parts = decoded.split(':') + const [storedId, _type, timestamp, storedPwHash] = parts if (storedId !== chatId) { return false @@ -92,20 +103,32 @@ export const validateAuthToken = (token: string, chatId: string): boolean => { const createdAt = Number.parseInt(timestamp) const now = Date.now() - const expireTime = 24 * 60 * 60 * 1000 // 24 hours + const expireTime = 24 * 60 * 60 * 1000 if (now - createdAt > expireTime) { return false } + if (encryptedPassword) { + const currentPwHash = hashPassword(encryptedPassword) + if (storedPwHash !== currentPwHash) { + return false + } + } + return true } catch (_e) { return false } } -export const setChatAuthCookie = (response: NextResponse, chatId: string, type: string): void => { - const token = encryptAuthToken(chatId, type) +export function setChatAuthCookie( + response: NextResponse, + chatId: string, + type: string, + encryptedPassword?: string | null +): void { + const token = encryptAuthToken(chatId, type, encryptedPassword) response.cookies.set({ name: `chat_auth_${chatId}`, value: token, @@ -113,7 +136,7 @@ export const setChatAuthCookie = (response: NextResponse, chatId: string, type: secure: !isDev, sameSite: 'lax', path: '/', - maxAge: 60 * 60 * 24, // 24 hours + maxAge: 60 * 60 * 24, }) } @@ -145,7 +168,7 @@ export async function validateChatAuth( const cookieName = `chat_auth_${deployment.id}` const authCookie = request.cookies.get(cookieName) - if (authCookie && validateAuthToken(authCookie.value, deployment.id)) { + if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { return { authorized: true } } @@ -259,8 +282,8 @@ export async function validateChatAuth( return { authorized: false, error: 'Email not authorized for SSO access' } } - const { auth } = await import('@/lib/auth') - const session = await auth.api.getSession({ headers: request.headers }) + const { getSession } = await import('@/lib/auth') + const session = await getSession() if (!session || !session.user) { return { authorized: false, error: 'auth_required_sso' } diff --git a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts index d2e4fd075..13a2d2e9e 100644 --- a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts +++ b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { settings } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { auth } from '@/lib/auth' +import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('CopilotAutoAllowedToolsAPI') @@ -10,9 +10,9 @@ const logger = createLogger('CopilotAutoAllowedToolsAPI') /** * GET - Fetch user's auto-allowed integration tools */ -export async function GET(request: NextRequest) { +export async function GET() { try { - const session = await auth.api.getSession({ headers: request.headers }) + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -31,7 +31,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ autoAllowedTools }) } - // If no settings record exists, create one with empty array await db.insert(settings).values({ id: userId, userId, @@ -50,7 +49,7 @@ export async function GET(request: NextRequest) { */ export async function POST(request: NextRequest) { try { - const session = await auth.api.getSession({ headers: request.headers }) + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -65,13 +64,11 @@ export async function POST(request: NextRequest) { const toolId = body.toolId - // Get existing settings const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) if (existing) { const currentTools = (existing.copilotAutoAllowedTools as string[]) || [] - // Add tool if not already present if (!currentTools.includes(toolId)) { const updatedTools = [...currentTools, toolId] await db @@ -89,7 +86,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, autoAllowedTools: currentTools }) } - // Create new settings record with the tool await db.insert(settings).values({ id: userId, userId, @@ -109,7 +105,7 @@ export async function POST(request: NextRequest) { */ export async function DELETE(request: NextRequest) { try { - const session = await auth.api.getSession({ headers: request.headers }) + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -123,7 +119,6 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 }) } - // Get existing settings const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) if (existing) { diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index fcbfd471a..5708b3f60 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { auth } from '@/lib/auth' +import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/../../packages/db' import { settings } from '@/../../packages/db/schema' @@ -32,7 +32,7 @@ const DEFAULT_ENABLED_MODELS: Record = { // GET - Fetch user's enabled models export async function GET(request: NextRequest) { try { - const session = await auth.api.getSession({ headers: request.headers }) + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -40,7 +40,6 @@ export async function GET(request: NextRequest) { const userId = session.user.id - // Try to fetch existing settings record const [userSettings] = await db .select() .from(settings) @@ -50,13 +49,11 @@ export async function GET(request: NextRequest) { if (userSettings) { const userModelsMap = (userSettings.copilotEnabledModels as Record) || {} - // Merge: start with defaults, then override with user's existing preferences const mergedModels = { ...DEFAULT_ENABLED_MODELS } for (const [modelId, enabled] of Object.entries(userModelsMap)) { mergedModels[modelId] = enabled } - // If we added any new models, update the database const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some( (key) => !(key in userModelsMap) ) @@ -76,7 +73,6 @@ export async function GET(request: NextRequest) { }) } - // If no settings record exists, create one with defaults await db.insert(settings).values({ id: userId, userId, @@ -97,7 +93,7 @@ export async function GET(request: NextRequest) { // PUT - Update user's enabled models export async function PUT(request: NextRequest) { try { - const session = await auth.api.getSession({ headers: request.headers }) + const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -110,11 +106,9 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'enabledModels must be an object' }, { status: 400 }) } - // Check if settings record exists const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) if (existing) { - // Update existing record await db .update(settings) .set({ @@ -123,7 +117,6 @@ export async function PUT(request: NextRequest) { }) .where(eq(settings.userId, userId)) } else { - // Create new settings record await db.insert(settings).values({ id: userId, userId, diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 5a6e5d871..a7835c72d 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,6 +1,6 @@ import { createContext, Script } from 'vm' import { type NextRequest, NextResponse } from 'next/server' -import { env, isTruthy } from '@/lib/core/config/env' +import { isE2bEnabled } from '@/lib/core/config/feature-flags' import { validateProxyUrl } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { executeInE2B } from '@/lib/execution/e2b' @@ -701,7 +701,6 @@ export async function POST(req: NextRequest) { resolvedCode = codeResolution.resolvedCode const contextVariables = codeResolution.contextVariables - const e2bEnabled = isTruthy(env.E2B_ENABLED) const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE // Extract imports once for JavaScript code (reuse later to avoid double extraction) @@ -722,14 +721,14 @@ export async function POST(req: NextRequest) { } // Python always requires E2B - if (lang === CodeLanguage.Python && !e2bEnabled) { + if (lang === CodeLanguage.Python && !isE2bEnabled) { throw new Error( 'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.' ) } // JavaScript with imports requires E2B - if (lang === CodeLanguage.JavaScript && hasImports && !e2bEnabled) { + if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) { throw new Error( 'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.' ) @@ -740,13 +739,13 @@ export async function POST(req: NextRequest) { // - Not a custom tool AND // - (Python OR JavaScript with imports) const useE2B = - e2bEnabled && + isE2bEnabled && !isCustomTool && (lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports)) if (useE2B) { logger.info(`[${requestId}] E2B status`, { - enabled: e2bEnabled, + enabled: isE2bEnabled, hasApiKey: Boolean(process.env.E2B_API_KEY), language: lang, }) diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index ecc0ba3b1..9f877e3b3 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -6,7 +6,7 @@ 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 { isBillingEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('OrganizationSeatsAPI') diff --git a/apps/sim/app/api/proxy/route.ts b/apps/sim/app/api/proxy/route.ts index 08b9a0758..cb223aebd 100644 --- a/apps/sim/app/api/proxy/route.ts +++ b/apps/sim/app/api/proxy/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' -import { isDev } from '@/lib/core/config/environment' +import { isDev } from '@/lib/core/config/feature-flags' import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index 84c8c05b0..316c0d0a0 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -1,26 +1,81 @@ +import { db } from '@sim/db' +import { chat } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' +import { validateAuthToken } from '@/app/api/chat/utils' const logger = createLogger('ProxyTTSStreamAPI') -export async function POST(request: NextRequest) { +/** + * Validates chat-based authentication for deployed chat voice mode + * Checks if the user has a valid chat auth cookie for the given chatId + */ +async function validateChatAuth(request: NextRequest, chatId: string): Promise { try { - const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { - logger.error('Authentication failed for TTS stream proxy:', authResult.error) - return new Response('Unauthorized', { status: 401 }) + const chatResult = await db + .select({ + id: chat.id, + isActive: chat.isActive, + authType: chat.authType, + password: chat.password, + }) + .from(chat) + .where(eq(chat.id, chatId)) + .limit(1) + + if (chatResult.length === 0 || !chatResult[0].isActive) { + logger.warn('Chat not found or inactive for TTS auth:', chatId) + return false } - const body = await request.json() - const { text, voiceId, modelId = 'eleven_turbo_v2_5' } = body + const chatData = chatResult[0] + + if (chatData.authType === 'public') { + return true + } + + const cookieName = `chat_auth_${chatId}` + const authCookie = request.cookies.get(cookieName) + + if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) { + return true + } + + return false + } catch (error) { + logger.error('Error validating chat auth for TTS:', error) + return false + } +} + +export async function POST(request: NextRequest) { + try { + let body: any + try { + body = await request.json() + } catch { + return new Response('Invalid request body', { status: 400 }) + } + + const { text, voiceId, modelId = 'eleven_turbo_v2_5', chatId } = body + + if (!chatId) { + return new Response('chatId is required', { status: 400 }) + } if (!text || !voiceId) { return new Response('Missing required parameters', { status: 400 }) } + const isChatAuthed = await validateChatAuth(request, chatId) + if (!isChatAuthed) { + logger.warn('Chat authentication failed for TTS, chatId:', chatId) + return new Response('Unauthorized', { status: 401 }) + } + const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255) if (!voiceIdValidation.isValid) { logger.error(`Invalid voice ID: ${voiceIdValidation.error}`) diff --git a/apps/sim/app/api/proxy/tts/unified/route.ts b/apps/sim/app/api/proxy/tts/unified/route.ts index 9937a513a..827dfae61 100644 --- a/apps/sim/app/api/proxy/tts/unified/route.ts +++ b/apps/sim/app/api/proxy/tts/unified/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import { StorageService } from '@/lib/uploads' @@ -147,6 +148,10 @@ export async function POST(request: NextRequest) { { status: 400 } ) } + const voiceIdValidation = validateAlphanumericId(body.voiceId, 'voiceId') + if (!voiceIdValidation.isValid) { + return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 }) + } const result = await synthesizeWithElevenLabs({ text, apiKey, diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 43d5367a2..6feddfe7a 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -42,11 +42,11 @@ describe('Scheduled Workflow Execution API Route', () => { executeScheduleJob: mockExecuteScheduleJob, })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { - TRIGGER_DEV_ENABLED: false, - }, - isTruthy: vi.fn(() => false), + vi.doMock('@/lib/core/config/feature-flags', () => ({ + isTriggerDevEnabled: false, + isHosted: false, + isProd: false, + isDev: true, })) vi.doMock('drizzle-orm', () => ({ @@ -119,11 +119,11 @@ describe('Scheduled Workflow Execution API Route', () => { }, })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { - TRIGGER_DEV_ENABLED: true, - }, - isTruthy: vi.fn(() => true), + vi.doMock('@/lib/core/config/feature-flags', () => ({ + isTriggerDevEnabled: true, + isHosted: false, + isProd: false, + isDev: true, })) vi.doMock('drizzle-orm', () => ({ @@ -191,11 +191,11 @@ describe('Scheduled Workflow Execution API Route', () => { executeScheduleJob: vi.fn().mockResolvedValue(undefined), })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { - TRIGGER_DEV_ENABLED: false, - }, - isTruthy: vi.fn(() => false), + vi.doMock('@/lib/core/config/feature-flags', () => ({ + isTriggerDevEnabled: false, + isHosted: false, + isProd: false, + isDev: true, })) vi.doMock('drizzle-orm', () => ({ @@ -250,11 +250,11 @@ describe('Scheduled Workflow Execution API Route', () => { executeScheduleJob: vi.fn().mockResolvedValue(undefined), })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { - TRIGGER_DEV_ENABLED: false, - }, - isTruthy: vi.fn(() => false), + vi.doMock('@/lib/core/config/feature-flags', () => ({ + isTriggerDevEnabled: false, + isHosted: false, + isProd: false, + isDev: true, })) vi.doMock('drizzle-orm', () => ({ diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 4368ab192..5254028d6 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -3,7 +3,7 @@ import { tasks } from '@trigger.dev/sdk' import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { executeScheduleJob } from '@/background/schedule-execution' @@ -54,9 +54,7 @@ export async function GET(request: NextRequest) { logger.debug(`[${requestId}] Successfully queried schedules: ${dueSchedules.length} found`) logger.info(`[${requestId}] Processing ${dueSchedules.length} due scheduled workflows`) - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - - if (useTrigger) { + if (isTriggerDevEnabled) { const triggerPromises = dueSchedules.map(async (schedule) => { const queueTime = schedule.lastQueuedAt ?? queuedAt diff --git a/apps/sim/app/api/stars/route.ts b/apps/sim/app/api/stars/route.ts index e0e9d48ea..fb02b20d4 100644 --- a/apps/sim/app/api/stars/route.ts +++ b/apps/sim/app/api/stars/route.ts @@ -23,13 +23,13 @@ export async function GET() { if (!response.ok) { console.warn('GitHub API request failed:', response.status) - return NextResponse.json({ stars: formatStarCount(14500) }) + return NextResponse.json({ stars: formatStarCount(19400) }) } const data = await response.json() - return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 14500)) }) + return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 19400)) }) } catch (error) { console.warn('Error fetching GitHub stars:', error) - return NextResponse.json({ stars: formatStarCount(14500) }) + return NextResponse.json({ stars: formatStarCount(19400) }) } } diff --git a/apps/sim/app/api/telemetry/route.ts b/apps/sim/app/api/telemetry/route.ts index f711d3d0c..e7bc3bc89 100644 --- a/apps/sim/app/api/telemetry/route.ts +++ b/apps/sim/app/api/telemetry/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('TelemetryAPI') diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index 205149a7d..ef6df171d 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -41,6 +42,17 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = DiscordSendMessageSchema.parse(body) + const channelIdValidation = validateNumericId(validatedData.channelId, 'channelId') + if (!channelIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid channelId format`, { + error: channelIdValidation.error, + }) + return NextResponse.json( + { success: false, error: channelIdValidation.error }, + { status: 400 } + ) + } + logger.info(`[${requestId}] Sending Discord message`, { channelId: validatedData.channelId, hasFiles: !!(validatedData.files && validatedData.files.length > 0), diff --git a/apps/sim/app/api/tools/webflow/collections/route.ts b/apps/sim/app/api/tools/webflow/collections/route.ts index 79c1d4605..31ec54061 100644 --- a/apps/sim/app/api/tools/webflow/collections/route.ts +++ b/apps/sim/app/api/tools/webflow/collections/route.ts @@ -1,32 +1,55 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { getOAuthToken } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebflowCollectionsAPI') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function POST(request: Request) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const requestId = generateRequestId() + const body = await request.json() + const { credential, workflowId, siteId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - const { searchParams } = new URL(request.url) - const siteId = searchParams.get('siteId') - - if (!siteId) { - return NextResponse.json({ error: 'Missing siteId parameter' }, { status: 400 }) + const siteIdValidation = validateAlphanumericId(siteId, 'siteId') + if (!siteIdValidation.isValid) { + logger.error('Invalid siteId', { error: siteIdValidation.error }) + return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) } - const accessToken = await getOAuthToken(session.user.id, 'webflow') + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) return NextResponse.json( - { error: 'No Webflow access token found. Please connect your Webflow account.' }, - { status: 404 } + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } ) } @@ -58,11 +81,11 @@ export async function GET(request: NextRequest) { name: collection.displayName || collection.slug || collection.id, })) - return NextResponse.json({ collections: formattedCollections }, { status: 200 }) - } catch (error: any) { - logger.error('Error fetching Webflow collections', error) + return NextResponse.json({ collections: formattedCollections }) + } catch (error) { + logger.error('Error processing Webflow collections request:', error) return NextResponse.json( - { error: 'Internal server error', details: error.message }, + { error: 'Failed to retrieve Webflow collections', details: (error as Error).message }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts new file mode 100644 index 000000000..95acc644d --- /dev/null +++ b/apps/sim/app/api/tools/webflow/items/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebflowItemsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + try { + const requestId = generateRequestId() + const body = await request.json() + const { credential, workflowId, collectionId, search } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const collectionIdValidation = validateAlphanumericId(collectionId, 'collectionId') + if (!collectionIdValidation.isValid) { + logger.error('Invalid collectionId', { error: collectionIdValidation.error }) + return NextResponse.json({ error: collectionIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } + ) + } + + const response = await fetch( + `https://api.webflow.com/v2/collections/${collectionId}/items?limit=100`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Webflow items', { + status: response.status, + error: errorData, + collectionId, + }) + return NextResponse.json( + { error: 'Failed to fetch Webflow items', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const items = data.items || [] + + let formattedItems = items.map((item: any) => { + const fieldData = item.fieldData || {} + const name = fieldData.name || fieldData.title || fieldData.slug || item.id + return { + id: item.id, + name, + } + }) + + if (search) { + const searchLower = search.toLowerCase() + formattedItems = formattedItems.filter((item: { id: string; name: string }) => + item.name.toLowerCase().includes(searchLower) + ) + } + + return NextResponse.json({ items: formattedItems }) + } catch (error) { + logger.error('Error processing Webflow items request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Webflow items', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/webflow/sites/route.ts b/apps/sim/app/api/tools/webflow/sites/route.ts index f94c3e340..2cfc4698a 100644 --- a/apps/sim/app/api/tools/webflow/sites/route.ts +++ b/apps/sim/app/api/tools/webflow/sites/route.ts @@ -1,25 +1,48 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { getOAuthToken } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebflowSitesAPI') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function POST(request: Request) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const requestId = generateRequestId() + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - const accessToken = await getOAuthToken(session.user.id, 'webflow') + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) return NextResponse.json( - { error: 'No Webflow access token found. Please connect your Webflow account.' }, - { status: 404 } + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } ) } @@ -50,11 +73,11 @@ export async function GET(request: NextRequest) { name: site.displayName || site.shortName || site.id, })) - return NextResponse.json({ sites: formattedSites }, { status: 200 }) - } catch (error: any) { - logger.error('Error fetching Webflow sites', error) + return NextResponse.json({ sites: formattedSites }) + } catch (error) { + logger.error('Error processing Webflow sites request:', error) return NextResponse.json( - { error: 'Internal server error', details: error.message }, + { error: 'Failed to retrieve Webflow sites', details: (error as Error).message }, { status: 500 } ) } diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index d517c8d8c..bba1e428c 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -35,19 +35,18 @@ * GET /api/v1/admin/organizations/:id - Get organization details * PATCH /api/v1/admin/organizations/:id - Update organization * GET /api/v1/admin/organizations/:id/members - List organization members - * POST /api/v1/admin/organizations/:id/members - Add/update member in organization + * POST /api/v1/admin/organizations/:id/members - Add/update member (validates seat availability) * GET /api/v1/admin/organizations/:id/members/:mid - Get member details * PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role * DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member * GET /api/v1/admin/organizations/:id/billing - Get org billing summary * PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit * GET /api/v1/admin/organizations/:id/seats - Get seat analytics - * PATCH /api/v1/admin/organizations/:id/seats - Update seat count * * Subscriptions: * GET /api/v1/admin/subscriptions - List all subscriptions * GET /api/v1/admin/subscriptions/:id - Get subscription details - * PATCH /api/v1/admin/subscriptions/:id - Update subscription + * DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled) */ export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth' diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 6bd6115eb..a3c07e02e 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -12,6 +12,9 @@ * POST /api/v1/admin/organizations/[id]/members * * Add a user to an organization with full billing logic. + * Validates seat availability before adding (uses same logic as invitation flow): + * - Team plans: checks seats column + * - Enterprise plans: checks metadata.seats * Handles Pro usage snapshot and subscription cancellation like the invitation flow. * If user is already a member, updates their role if different. * @@ -29,6 +32,7 @@ import { db } from '@sim/db' import { member, organization, user, userStats } from '@sim/db/schema' import { count, eq } from 'drizzle-orm' import { addUserToOrganization } from '@/lib/billing/organizations/membership' +import { requireStripeClient } from '@/lib/billing/stripe-client' import { createLogger } from '@/lib/logs/console/logger' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -223,6 +227,29 @@ export const POST = withAdminAuthParams(async (request, context) => return badRequestResponse(result.error || 'Failed to add member') } + // Sync Pro subscription cancellation with Stripe (same as invitation flow) + if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { + try { + const stripe = requireStripeClient() + await stripe.subscriptions.update( + result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + { cancel_at_period_end: true } + ) + logger.info('Admin API: Synced Pro cancellation with Stripe', { + userId: body.userId, + subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, + stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + }) + } catch (stripeError) { + logger.error('Admin API: Failed to sync Pro cancellation with Stripe', { + userId: body.userId, + subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, + stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + error: stripeError, + }) + } + } + const data: AdminMember = { id: result.memberId!, userId: body.userId, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts index 388570fc9..0cfe0c8d9 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts @@ -4,26 +4,12 @@ * Get organization seat analytics including member activity. * * Response: AdminSingleResponse - * - * PATCH /api/v1/admin/organizations/[id]/seats - * - * Update organization seat count with Stripe sync (matches user flow). - * - * Body: - * - seats: number - New seat count (positive integer) - * - * Response: AdminSingleResponse<{ success: true, seats: number, plan: string, stripeUpdated?: boolean }> */ -import { db } from '@sim/db' -import { organization, subscription } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { requireStripeClient } from '@/lib/billing/stripe-client' import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management' import { createLogger } from '@/lib/logs/console/logger' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { - badRequestResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -75,122 +61,3 @@ export const GET = withAdminAuthParams(async (_, context) => { return internalErrorResponse('Failed to get organization seats') } }) - -export const PATCH = withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params - - try { - const body = await request.json() - - if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) { - return badRequestResponse('seats must be a positive integer') - } - - const [orgData] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) - - if (!orgData) { - return notFoundResponse('Organization') - } - - const [subData] = await db - .select() - .from(subscription) - .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active'))) - .limit(1) - - if (!subData) { - return notFoundResponse('Subscription') - } - - const newSeatCount = body.seats - let stripeUpdated = false - - if (subData.plan === 'enterprise') { - const currentMetadata = (subData.metadata as Record) || {} - const newMetadata = { - ...currentMetadata, - seats: newSeatCount, - } - - await db - .update(subscription) - .set({ metadata: newMetadata }) - .where(eq(subscription.id, subData.id)) - - logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, { - seats: newSeatCount, - }) - } else if (subData.plan === 'team') { - if (subData.stripeSubscriptionId) { - const stripe = requireStripeClient() - - const stripeSubscription = await stripe.subscriptions.retrieve(subData.stripeSubscriptionId) - - if (stripeSubscription.status !== 'active') { - return badRequestResponse('Stripe subscription is not active') - } - - const subscriptionItem = stripeSubscription.items.data[0] - if (!subscriptionItem) { - return internalErrorResponse('No subscription item found in Stripe subscription') - } - - const currentSeats = subData.seats || 1 - - logger.info('Admin API: Updating Stripe subscription quantity', { - organizationId, - stripeSubscriptionId: subData.stripeSubscriptionId, - subscriptionItemId: subscriptionItem.id, - currentSeats, - newSeatCount, - }) - - await stripe.subscriptions.update(subData.stripeSubscriptionId, { - items: [ - { - id: subscriptionItem.id, - quantity: newSeatCount, - }, - ], - proration_behavior: 'create_prorations', - }) - - stripeUpdated = true - } - - await db - .update(subscription) - .set({ seats: newSeatCount }) - .where(eq(subscription.id, subData.id)) - - logger.info(`Admin API: Updated team seats for organization ${organizationId}`, { - seats: newSeatCount, - stripeUpdated, - }) - } else { - await db - .update(subscription) - .set({ seats: newSeatCount }) - .where(eq(subscription.id, subData.id)) - - logger.info(`Admin API: Updated seats for organization ${organizationId}`, { - seats: newSeatCount, - plan: subData.plan, - }) - } - - return singleResponse({ - success: true, - seats: newSeatCount, - plan: subData.plan, - stripeUpdated, - }) - } catch (error) { - logger.error('Admin API: Failed to update organization seats', { error, organizationId }) - return internalErrorResponse('Failed to update organization seats') - } -}) diff --git a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts index d5e3f9522..dac1dde89 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -5,28 +5,28 @@ * * Response: AdminSingleResponse * - * PATCH /api/v1/admin/subscriptions/[id] + * DELETE /api/v1/admin/subscriptions/[id] * - * Update subscription details with optional side effects. + * Cancel a subscription by triggering Stripe cancellation. + * The Stripe webhook handles all cleanup (same as platform cancellation): + * - Updates subscription status to canceled + * - Bills final period overages + * - Resets usage + * - Restores member Pro subscriptions (for team/enterprise) + * - Deletes organization (for team/enterprise) + * - Syncs usage limits to free tier * - * Body: - * - plan?: string - New plan (free, pro, team, enterprise) - * - status?: string - New status (active, canceled, etc.) - * - seats?: number - Seat count (for team plans) - * - metadata?: object - Subscription metadata (for enterprise) - * - periodStart?: string - Period start (ISO date) - * - periodEnd?: string - Period end (ISO date) - * - cancelAtPeriodEnd?: boolean - Cancel at period end flag - * - syncLimits?: boolean - Sync usage limits for affected users (default: false) - * - reason?: string - Reason for the change (for audit logging) + * Query Parameters: + * - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false) + * - reason?: string - Reason for cancellation (for audit logging) * - * Response: AdminSingleResponse + * Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean } */ import { db } from '@sim/db' -import { member, subscription } from '@sim/db/schema' +import { subscription } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { requireStripeClient } from '@/lib/billing/stripe-client' import { createLogger } from '@/lib/logs/console/logger' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -43,9 +43,6 @@ interface RouteParams { id: string } -const VALID_PLANS = ['free', 'pro', 'team', 'enterprise'] -const VALID_STATUSES = ['active', 'canceled', 'past_due', 'unpaid', 'trialing', 'incomplete'] - export const GET = withAdminAuthParams(async (_, context) => { const { id: subscriptionId } = await context.params @@ -69,14 +66,13 @@ export const GET = withAdminAuthParams(async (_, context) => { } }) -export const PATCH = withAdminAuthParams(async (request, context) => { +export const DELETE = withAdminAuthParams(async (request, context) => { const { id: subscriptionId } = await context.params + const url = new URL(request.url) + const atPeriodEnd = url.searchParams.get('atPeriodEnd') === 'true' + const reason = url.searchParams.get('reason') || 'Admin cancellation (no reason provided)' try { - const body = await request.json() - const syncLimits = body.syncLimits === true - const reason = body.reason || 'Admin update (no reason provided)' - const [existing] = await db .select() .from(subscription) @@ -87,150 +83,70 @@ export const PATCH = withAdminAuthParams(async (request, context) = return notFoundResponse('Subscription') } - const updateData: Record = {} - const warnings: string[] = [] - - if (body.plan !== undefined) { - if (!VALID_PLANS.includes(body.plan)) { - return badRequestResponse(`plan must be one of: ${VALID_PLANS.join(', ')}`) - } - if (body.plan !== existing.plan) { - warnings.push( - `Plan change from ${existing.plan} to ${body.plan}. This does NOT update Stripe - manual sync required.` - ) - } - updateData.plan = body.plan + if (existing.status === 'canceled') { + return badRequestResponse('Subscription is already canceled') } - if (body.status !== undefined) { - if (!VALID_STATUSES.includes(body.status)) { - return badRequestResponse(`status must be one of: ${VALID_STATUSES.join(', ')}`) - } - if (body.status !== existing.status) { - warnings.push( - `Status change from ${existing.status} to ${body.status}. This does NOT update Stripe - manual sync required.` - ) - } - updateData.status = body.status + if (!existing.stripeSubscriptionId) { + return badRequestResponse('Subscription has no Stripe subscription ID') } - if (body.seats !== undefined) { - if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) { - return badRequestResponse('seats must be a positive integer') - } - updateData.seats = body.seats + const stripe = requireStripeClient() + + if (atPeriodEnd) { + // Schedule cancellation at period end + await stripe.subscriptions.update(existing.stripeSubscriptionId, { + cancel_at_period_end: true, + }) + + // Update DB (webhooks don't sync cancelAtPeriodEnd) + await db + .update(subscription) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Admin API: Scheduled subscription cancellation at period end', { + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + plan: existing.plan, + referenceId: existing.referenceId, + periodEnd: existing.periodEnd, + reason, + }) + + return singleResponse({ + success: true, + message: 'Subscription scheduled to cancel at period end.', + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + atPeriodEnd: true, + periodEnd: existing.periodEnd?.toISOString() ?? null, + }) } - if (body.metadata !== undefined) { - if (typeof body.metadata !== 'object' || body.metadata === null) { - return badRequestResponse('metadata must be an object') - } - updateData.metadata = { - ...((existing.metadata as Record) || {}), - ...body.metadata, - } - } + // Immediate cancellation + await stripe.subscriptions.cancel(existing.stripeSubscriptionId, { + prorate: true, + invoice_now: true, + }) - if (body.periodStart !== undefined) { - const date = new Date(body.periodStart) - if (Number.isNaN(date.getTime())) { - return badRequestResponse('periodStart must be a valid ISO date') - } - updateData.periodStart = date - } - - if (body.periodEnd !== undefined) { - const date = new Date(body.periodEnd) - if (Number.isNaN(date.getTime())) { - return badRequestResponse('periodEnd must be a valid ISO date') - } - updateData.periodEnd = date - } - - if (body.cancelAtPeriodEnd !== undefined) { - if (typeof body.cancelAtPeriodEnd !== 'boolean') { - return badRequestResponse('cancelAtPeriodEnd must be a boolean') - } - updateData.cancelAtPeriodEnd = body.cancelAtPeriodEnd - } - - if (Object.keys(updateData).length === 0) { - return badRequestResponse('No valid fields to update') - } - - const [updated] = await db - .update(subscription) - .set(updateData) - .where(eq(subscription.id, subscriptionId)) - .returning() - - const sideEffects: { - limitsSynced: boolean - usersAffected: string[] - errors: string[] - } = { - limitsSynced: false, - usersAffected: [], - errors: [], - } - - if (syncLimits) { - try { - const referenceId = updated.referenceId - - if (['free', 'pro'].includes(updated.plan)) { - await syncUsageLimitsFromSubscription(referenceId) - sideEffects.usersAffected.push(referenceId) - sideEffects.limitsSynced = true - } else if (['team', 'enterprise'].includes(updated.plan)) { - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, referenceId)) - - for (const m of members) { - try { - await syncUsageLimitsFromSubscription(m.userId) - sideEffects.usersAffected.push(m.userId) - } catch (memberError) { - sideEffects.errors.push(`Failed to sync limits for user ${m.userId}`) - logger.error('Admin API: Failed to sync limits for member', { - userId: m.userId, - error: memberError, - }) - } - } - sideEffects.limitsSynced = members.length > 0 - } - - logger.info('Admin API: Synced usage limits after subscription update', { - subscriptionId, - usersAffected: sideEffects.usersAffected.length, - }) - } catch (syncError) { - sideEffects.errors.push('Failed to sync usage limits') - logger.error('Admin API: Failed to sync usage limits', { - subscriptionId, - error: syncError, - }) - } - } - - logger.info(`Admin API: Updated subscription ${subscriptionId}`, { - fields: Object.keys(updateData), - previousPlan: existing.plan, - previousStatus: existing.status, - syncLimits, + logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', { + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + plan: existing.plan, + referenceId: existing.referenceId, reason, }) return singleResponse({ - ...toAdminSubscription(updated), - sideEffects, - warnings, + success: true, + message: 'Subscription cancellation triggered. Webhook will complete cleanup.', + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + atPeriodEnd: false, }) } catch (error) { - logger.error('Admin API: Failed to update subscription', { error, subscriptionId }) - return internalErrorResponse('Failed to update subscription') + logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId }) + return internalErrorResponse('Failed to cancel subscription') } }) diff --git a/apps/sim/app/api/v1/auth.ts b/apps/sim/app/api/v1/auth.ts index b9e5b59c2..30bf8d8e5 100644 --- a/apps/sim/app/api/v1/auth.ts +++ b/apps/sim/app/api/v1/auth.ts @@ -1,5 +1,7 @@ import type { NextRequest } from 'next/server' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { isAuthDisabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('V1Auth') @@ -13,6 +15,14 @@ export interface AuthResult { } export async function authenticateV1Request(request: NextRequest): Promise { + if (isAuthDisabled) { + return { + authenticated: true, + userId: ANONYMOUS_USER_ID, + keyType: 'personal', + } + } + const apiKey = request.headers.get('x-api-key') if (!apiKey) { diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 48f0dd782..b3925663d 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -5,7 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import OpenAI, { AzureOpenAI } from 'openai' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { env } from '@/lib/core/config/env' -import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/environment' +import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { getModelPricing } from '@/providers/utils' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index a330dd624..2b9cd8bea 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { validate as uuidValidate, v4 as uuidv4 } from 'uuid' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -236,9 +236,8 @@ type AsyncExecutionParams = { */ async function handleAsyncExecution(params: AsyncExecutionParams): Promise { const { requestId, workflowId, userId, input, triggerType } = params - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - if (!useTrigger) { + if (!isTriggerDevEnabled) { logger.warn(`[${requestId}] Async mode requested but TRIGGER_DEV_ENABLED is false`) return NextResponse.json( { error: 'Async execution is not enabled. Set TRIGGER_DEV_ENABLED=true to use async mode.' }, diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index fe63fbf18..cb70cbbb9 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -39,6 +39,7 @@ interface ChatConfig { interface AudioStreamingOptions { voiceId: string + chatId?: string onError: (error: Error) => void } @@ -62,16 +63,19 @@ function fileToBase64(file: File): Promise { * Creates an audio stream handler for text-to-speech conversion * @param streamTextToAudio - Function to stream text to audio * @param voiceId - The voice ID to use for TTS + * @param chatId - Optional chat ID for deployed chat authentication * @returns Audio stream handler function or undefined */ function createAudioStreamHandler( streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise, - voiceId: string + voiceId: string, + chatId?: string ) { return async (text: string) => { try { await streamTextToAudio(text, { voiceId, + chatId, onError: (error: Error) => { logger.error('Audio streaming error:', error) }, @@ -113,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { const [error, setError] = useState(null) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) - const [starCount, setStarCount] = useState('3.4k') + const [starCount, setStarCount] = useState('19.4k') const [conversationId, setConversationId] = useState('') const [showScrollButton, setShowScrollButton] = useState(false) @@ -391,7 +395,11 @@ export default function ChatClient({ identifier }: { identifier: string }) { // Use the streaming hook with audio support const shouldPlayAudio = isVoiceInput || isVoiceFirstMode const audioHandler = shouldPlayAudio - ? createAudioStreamHandler(streamTextToAudio, DEFAULT_VOICE_SETTINGS.voiceId) + ? createAudioStreamHandler( + streamTextToAudio, + DEFAULT_VOICE_SETTINGS.voiceId, + chatConfig?.id + ) : undefined logger.info('Starting to handle streamed response:', { shouldPlayAudio }) diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx index a4f2ad095..d4dc002ff 100644 --- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx @@ -68,7 +68,6 @@ export function VoiceInterface({ messages = [], className, }: VoiceInterfaceProps) { - // Simple state machine const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle') const [isInitialized, setIsInitialized] = useState(false) const [isMuted, setIsMuted] = useState(false) @@ -76,12 +75,10 @@ export function VoiceInterface({ const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>( 'prompt' ) - - // Current turn transcript (subtitle) const [currentTranscript, setCurrentTranscript] = useState('') - // State tracking const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle') + const isCallEndedRef = useRef(false) useEffect(() => { currentStateRef.current = state @@ -98,12 +95,10 @@ export function VoiceInterface({ const isSupported = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition) - // Update muted ref useEffect(() => { isMutedRef.current = isMuted }, [isMuted]) - // Timeout to handle cases where agent doesn't provide audio response const setResponseTimeout = useCallback(() => { if (responseTimeoutRef.current) { clearTimeout(responseTimeoutRef.current) @@ -113,7 +108,7 @@ export function VoiceInterface({ if (currentStateRef.current === 'listening') { setState('idle') } - }, 5000) // 5 second timeout (increased from 3) + }, 5000) }, []) const clearResponseTimeout = useCallback(() => { @@ -123,14 +118,12 @@ export function VoiceInterface({ } }, []) - // Sync with external state useEffect(() => { if (isPlayingAudio && state !== 'agent_speaking') { - clearResponseTimeout() // Clear timeout since agent is responding + clearResponseTimeout() setState('agent_speaking') setCurrentTranscript('') - // Mute microphone immediately setIsMuted(true) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -138,7 +131,6 @@ export function VoiceInterface({ }) } - // Stop speech recognition completely if (recognitionRef.current) { try { recognitionRef.current.abort() @@ -150,7 +142,6 @@ export function VoiceInterface({ setState('idle') setCurrentTranscript('') - // Re-enable microphone setIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -160,7 +151,6 @@ export function VoiceInterface({ } }, [isPlayingAudio, state, clearResponseTimeout]) - // Audio setup const setupAudio = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ @@ -175,7 +165,6 @@ export function VoiceInterface({ setPermissionStatus('granted') mediaStreamRef.current = stream - // Setup audio context for visualization if (!audioContextRef.current) { const AudioContext = window.AudioContext || window.webkitAudioContext audioContextRef.current = new AudioContext() @@ -194,7 +183,6 @@ export function VoiceInterface({ source.connect(analyser) analyserRef.current = analyser - // Start visualization const updateVisualization = () => { if (!analyserRef.current) return @@ -223,7 +211,6 @@ export function VoiceInterface({ } }, []) - // Speech recognition setup const setupSpeechRecognition = useCallback(() => { if (!isSupported) return @@ -259,14 +246,11 @@ export function VoiceInterface({ } } - // Update live transcript setCurrentTranscript(interimTranscript || finalTranscript) - // Send final transcript (but keep listening state until agent responds) if (finalTranscript.trim()) { - setCurrentTranscript('') // Clear transcript + setCurrentTranscript('') - // Stop recognition to avoid interference while waiting for response if (recognitionRef.current) { try { recognitionRef.current.stop() @@ -275,7 +259,6 @@ export function VoiceInterface({ } } - // Start timeout in case agent doesn't provide audio response setResponseTimeout() onVoiceTranscript?.(finalTranscript) @@ -283,13 +266,14 @@ export function VoiceInterface({ } recognition.onend = () => { + if (isCallEndedRef.current) return + const currentState = currentStateRef.current - // Only restart recognition if we're in listening state and not muted if (currentState === 'listening' && !isMutedRef.current) { - // Add a delay to avoid immediate restart after sending transcript setTimeout(() => { - // Double-check state hasn't changed during delay + if (isCallEndedRef.current) return + if ( recognitionRef.current && currentStateRef.current === 'listening' && @@ -301,14 +285,12 @@ export function VoiceInterface({ logger.debug('Error restarting speech recognition:', error) } } - }, 1000) // Longer delay to give agent time to respond + }, 1000) } } recognition.onerror = (event: SpeechRecognitionErrorEvent) => { - // Filter out "aborted" errors - these are expected when we intentionally stop recognition if (event.error === 'aborted') { - // Ignore return } @@ -320,7 +302,6 @@ export function VoiceInterface({ recognitionRef.current = recognition }, [isSupported, onVoiceTranscript, setResponseTimeout]) - // Start/stop listening const startListening = useCallback(() => { if (!isInitialized || isMuted || state !== 'idle') { return @@ -351,17 +332,12 @@ export function VoiceInterface({ } }, []) - // Handle interrupt const handleInterrupt = useCallback(() => { if (state === 'agent_speaking') { - // Clear any subtitle timeouts and text - // (No longer needed after removing subtitle system) - onInterrupt?.() setState('listening') setCurrentTranscript('') - // Unmute microphone for user input setIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -369,7 +345,6 @@ export function VoiceInterface({ }) } - // Start listening immediately if (recognitionRef.current) { try { recognitionRef.current.start() @@ -380,14 +355,13 @@ export function VoiceInterface({ } }, [state, onInterrupt]) - // Handle call end with proper cleanup const handleCallEnd = useCallback(() => { - // Stop everything immediately + isCallEndedRef.current = true + setState('idle') setCurrentTranscript('') setIsMuted(false) - // Stop speech recognition if (recognitionRef.current) { try { recognitionRef.current.abort() @@ -396,17 +370,11 @@ export function VoiceInterface({ } } - // Clear timeouts clearResponseTimeout() - - // Stop audio playback and streaming immediately onInterrupt?.() - - // Call the original onCallEnd onCallEnd?.() }, [onCallEnd, onInterrupt, clearResponseTimeout]) - // Keyboard handler useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code === 'Space') { @@ -419,7 +387,6 @@ export function VoiceInterface({ return () => document.removeEventListener('keydown', handleKeyDown) }, [handleInterrupt]) - // Mute toggle const toggleMute = useCallback(() => { if (state === 'agent_speaking') { handleInterrupt() @@ -442,7 +409,6 @@ export function VoiceInterface({ } }, [isMuted, state, handleInterrupt, stopListening, startListening]) - // Initialize useEffect(() => { if (isSupported) { setupSpeechRecognition() @@ -450,47 +416,40 @@ export function VoiceInterface({ } }, [isSupported, setupSpeechRecognition, setupAudio]) - // Auto-start listening when ready useEffect(() => { if (isInitialized && !isMuted && state === 'idle') { startListening() } }, [isInitialized, isMuted, state, startListening]) - // Cleanup when call ends or component unmounts useEffect(() => { return () => { - // Stop speech recognition + isCallEndedRef.current = true + if (recognitionRef.current) { try { recognitionRef.current.abort() - } catch (error) { + } catch (_e) { // Ignore } recognitionRef.current = null } - // Stop media stream if (mediaStreamRef.current) { - mediaStreamRef.current.getTracks().forEach((track) => { - track.stop() - }) + mediaStreamRef.current.getTracks().forEach((track) => track.stop()) mediaStreamRef.current = null } - // Stop audio context if (audioContextRef.current) { audioContextRef.current.close() audioContextRef.current = null } - // Cancel animation frame if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current) animationFrameRef.current = null } - // Clear timeouts if (responseTimeoutRef.current) { clearTimeout(responseTimeoutRef.current) responseTimeoutRef.current = null @@ -498,7 +457,6 @@ export function VoiceInterface({ } }, []) - // Get status text const getStatusText = () => { switch (state) { case 'listening': @@ -510,7 +468,6 @@ export function VoiceInterface({ } } - // Get button content const getButtonContent = () => { if (state === 'agent_speaking') { return ( @@ -524,9 +481,7 @@ export function VoiceInterface({ return (
- {/* Main content */}
- {/* Voice visualization */}
- {/* Live transcript - subtitle style */}
{currentTranscript && (
@@ -549,17 +503,14 @@ export function VoiceInterface({ )}
- {/* Status */}

{getStatusText()} {isMuted && (Muted)}

- {/* Controls */}
- {/* End call */} - {/* Mic/Stop button */}
)} -
- - -
+ {!isAuthDisabled && ( +
+ + +
+ )} {/* Password Reset Confirmation Modal */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx index 6bb09130a..d01fb51d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx @@ -6,7 +6,7 @@ import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn' import { Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client/utils' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 1d1997c66..d1debd8d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -26,7 +26,7 @@ import { McpIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { getUserRole } from '@/lib/workspaces/organization' import { ApiKeys, diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index d57b7d302..3e321d2cd 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -1,5 +1,5 @@ import { AgentIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index fa291521c..e809ed047 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -1,5 +1,5 @@ import { ChartBarIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig, ParamType } from '@/blocks/types' import type { ProviderId } from '@/providers/types' diff --git a/apps/sim/blocks/blocks/guardrails.ts b/apps/sim/blocks/blocks/guardrails.ts index 5af165f3e..af9f7a7d4 100644 --- a/apps/sim/blocks/blocks/guardrails.ts +++ b/apps/sim/blocks/blocks/guardrails.ts @@ -1,5 +1,5 @@ import { ShieldCheckIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import type { BlockConfig } from '@/blocks/types' import { getHostedModels, getProviderIcon } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 31daa27e2..744aa5395 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -1,5 +1,5 @@ import { ConnectIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ProviderId } from '@/providers/types' import { diff --git a/apps/sim/blocks/blocks/spotify.ts b/apps/sim/blocks/blocks/spotify.ts index 3987dc1b2..ac810fdc5 100644 --- a/apps/sim/blocks/blocks/spotify.ts +++ b/apps/sim/blocks/blocks/spotify.ts @@ -153,6 +153,14 @@ export const SpotifyBlock: BlockConfig = { value: () => 'spotify_search', }, + { + id: 'credential', + title: 'Spotify Account', + type: 'oauth-input', + serviceId: 'spotify', + required: true, + }, + // === SEARCH === { id: 'query', @@ -647,15 +655,6 @@ export const SpotifyBlock: BlockConfig = { ], }, }, - - // === OAUTH CREDENTIAL === - { - id: 'credential', - title: 'Spotify Account', - type: 'oauth-input', - serviceId: 'spotify', - required: true, - }, ], tools: { access: [ diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts index 5f7c954a1..bd984b860 100644 --- a/apps/sim/blocks/blocks/translate.ts +++ b/apps/sim/blocks/blocks/translate.ts @@ -1,5 +1,5 @@ import { TranslateIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { AuthMode, type BlockConfig } from '@/blocks/types' import { getHostedModels, getProviderIcon, providers } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' diff --git a/apps/sim/blocks/blocks/webflow.ts b/apps/sim/blocks/blocks/webflow.ts index 21b3c6cd2..cdb55df29 100644 --- a/apps/sim/blocks/blocks/webflow.ts +++ b/apps/sim/blocks/blocks/webflow.ts @@ -39,19 +39,65 @@ export const WebflowBlock: BlockConfig = { placeholder: 'Select Webflow account', required: true, }, + { + id: 'siteId', + title: 'Site', + type: 'project-selector', + canonicalParamId: 'siteId', + serviceId: 'webflow', + placeholder: 'Select Webflow site', + dependsOn: ['credential'], + mode: 'basic', + required: true, + }, + { + id: 'manualSiteId', + title: 'Site ID', + type: 'short-input', + canonicalParamId: 'siteId', + placeholder: 'Enter site ID', + mode: 'advanced', + required: true, + }, { id: 'collectionId', + title: 'Collection', + type: 'file-selector', + canonicalParamId: 'collectionId', + serviceId: 'webflow', + placeholder: 'Select collection', + dependsOn: ['credential', 'siteId'], + mode: 'basic', + required: true, + }, + { + id: 'manualCollectionId', title: 'Collection ID', type: 'short-input', + canonicalParamId: 'collectionId', placeholder: 'Enter collection ID', - dependsOn: ['credential'], + mode: 'advanced', required: true, }, { id: 'itemId', + title: 'Item', + type: 'file-selector', + canonicalParamId: 'itemId', + serviceId: 'webflow', + placeholder: 'Select item', + dependsOn: ['credential', 'collectionId'], + mode: 'basic', + condition: { field: 'operation', value: ['get', 'update', 'delete'] }, + required: true, + }, + { + id: 'manualItemId', title: 'Item ID', type: 'short-input', - placeholder: 'ID of the item', + canonicalParamId: 'itemId', + placeholder: 'Enter item ID', + mode: 'advanced', condition: { field: 'operation', value: ['get', 'update', 'delete'] }, required: true, }, @@ -108,7 +154,17 @@ export const WebflowBlock: BlockConfig = { } }, params: (params) => { - const { credential, fieldData, ...rest } = params + const { + credential, + fieldData, + siteId, + manualSiteId, + collectionId, + manualCollectionId, + itemId, + manualItemId, + ...rest + } = params let parsedFieldData: any | undefined try { @@ -119,15 +175,46 @@ export const WebflowBlock: BlockConfig = { throw new Error(`Invalid JSON input for ${params.operation} operation: ${error.message}`) } + const effectiveSiteId = ((siteId as string) || (manualSiteId as string) || '').trim() + const effectiveCollectionId = ( + (collectionId as string) || + (manualCollectionId as string) || + '' + ).trim() + const effectiveItemId = ((itemId as string) || (manualItemId as string) || '').trim() + + if (!effectiveSiteId) { + throw new Error('Site ID is required') + } + + if (!effectiveCollectionId) { + throw new Error('Collection ID is required') + } + const baseParams = { credential, + siteId: effectiveSiteId, + collectionId: effectiveCollectionId, ...rest, } switch (params.operation) { case 'create': case 'update': - return { ...baseParams, fieldData: parsedFieldData } + if (params.operation === 'update' && !effectiveItemId) { + throw new Error('Item ID is required for update operation') + } + return { + ...baseParams, + itemId: effectiveItemId || undefined, + fieldData: parsedFieldData, + } + case 'get': + case 'delete': + if (!effectiveItemId) { + throw new Error(`Item ID is required for ${params.operation} operation`) + } + return { ...baseParams, itemId: effectiveItemId } default: return baseParams } @@ -137,12 +224,15 @@ export const WebflowBlock: BlockConfig = { inputs: { operation: { type: 'string', description: 'Operation to perform' }, credential: { type: 'string', description: 'Webflow OAuth access token' }, + siteId: { type: 'string', description: 'Webflow site identifier' }, + manualSiteId: { type: 'string', description: 'Manual site identifier' }, collectionId: { type: 'string', description: 'Webflow collection identifier' }, - // Conditional inputs - itemId: { type: 'string', description: 'Item identifier' }, // Required for get/update/delete - offset: { type: 'number', description: 'Pagination offset' }, // Optional for list - limit: { type: 'number', description: 'Maximum items to return' }, // Optional for list - fieldData: { type: 'json', description: 'Item field data' }, // Required for create/update + manualCollectionId: { type: 'string', description: 'Manual collection identifier' }, + itemId: { type: 'string', description: 'Item identifier' }, + manualItemId: { type: 'string', description: 'Manual item identifier' }, + offset: { type: 'number', description: 'Pagination offset' }, + limit: { type: 'number', description: 'Maximum items to return' }, + fieldData: { type: 'json', description: 'Item field data' }, }, outputs: { items: { type: 'json', description: 'Array of items (list operation)' }, diff --git a/apps/sim/components/emails/footer.tsx b/apps/sim/components/emails/footer.tsx index 6b45e7e01..f6eb4044d 100644 --- a/apps/sim/components/emails/footer.tsx +++ b/apps/sim/components/emails/footer.tsx @@ -1,6 +1,6 @@ import { Container, Img, Link, Section, Text } from '@react-email/components' import { getBrandConfig } from '@/lib/branding/branding' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' interface UnsubscribeOptions { diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index f9e690a72..12ead996f 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4206,12 +4206,20 @@ export function RssIcon(props: SVGProps) { export function SpotifyIcon(props: SVGProps) { return ( - - + - ) } diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 1e366c401..449dd8244 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' -import { isHosted } from '@/lib/core/config/environment' import { getAllBlocks } from '@/blocks' import { BlockType } from '@/executor/constants' import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' @@ -11,11 +10,11 @@ import { executeTool } from '@/tools' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' -vi.mock('@/lib/core/config/environment', () => ({ - isHosted: vi.fn().mockReturnValue(false), - isProd: vi.fn().mockReturnValue(false), - isDev: vi.fn().mockReturnValue(true), - isTest: vi.fn().mockReturnValue(false), +vi.mock('@/lib/core/config/feature-flags', () => ({ + isHosted: false, + isProd: false, + isDev: true, + isTest: false, getCostMultiplier: vi.fn().mockReturnValue(1), isEmailVerificationEnabled: false, isBillingEnabled: false, @@ -65,7 +64,6 @@ global.fetch = Object.assign(vi.fn(), { preconnect: vi.fn() }) as typeof fetch const mockGetAllBlocks = getAllBlocks as Mock const mockExecuteTool = executeTool as Mock -const mockIsHosted = isHosted as unknown as Mock const mockGetProviderFromModel = getProviderFromModel as Mock const mockTransformBlockTool = transformBlockTool as Mock const mockFetch = global.fetch as unknown as Mock @@ -120,7 +118,6 @@ describe('AgentBlockHandler', () => { loops: {}, } as SerializedWorkflow, } - mockIsHosted.mockReturnValue(false) mockGetProviderFromModel.mockReturnValue('mock-provider') mockFetch.mockImplementation(() => { @@ -552,8 +549,6 @@ describe('AgentBlockHandler', () => { }) it('should not require API key for gpt-4o on hosted version', async () => { - mockIsHosted.mockReturnValue(true) - const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index ada3c8480..3354a0f70 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -1,5 +1,5 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('CopilotKeysQuery') diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 87662747a..4137e3065 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -673,6 +673,99 @@ const registry: Record = { return { id: doc.id, label: doc.filename } }, }, + 'webflow.sites': { + key: 'webflow.sites', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'webflow.sites', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'webflow.sites') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ sites: { id: string; name: string }[] }>( + '/api/tools/webflow/sites', + { + method: 'POST', + body, + } + ) + return (data.sites || []).map((site) => ({ + id: site.id, + label: site.name, + })) + }, + }, + 'webflow.collections': { + key: 'webflow.collections', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'webflow.collections', + context.credentialId ?? 'none', + context.siteId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.siteId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'webflow.collections') + if (!context.siteId) { + throw new Error('Missing site ID for webflow.collections selector') + } + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + }) + const data = await fetchJson<{ collections: { id: string; name: string }[] }>( + '/api/tools/webflow/collections', + { + method: 'POST', + body, + } + ) + return (data.collections || []).map((collection) => ({ + id: collection.id, + label: collection.name, + })) + }, + }, + 'webflow.items': { + key: 'webflow.items', + staleTime: 15 * 1000, + getQueryKey: ({ context, search }: SelectorQueryArgs) => [ + 'selectors', + 'webflow.items', + context.credentialId ?? 'none', + context.collectionId ?? 'none', + search ?? '', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.collectionId), + fetchList: async ({ context, search }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'webflow.items') + if (!context.collectionId) { + throw new Error('Missing collection ID for webflow.items selector') + } + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + collectionId: context.collectionId, + search, + }) + const data = await fetchJson<{ items: { id: string; name: string }[] }>( + '/api/tools/webflow/items', + { + method: 'POST', + body, + } + ) + return (data.items || []).map((item) => ({ + id: item.id, + label: item.name, + })) + }, + }, } export function getSelectorDefinition(key: SelectorKey): SelectorDefinition { diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts index fcb6747d2..76e3f2117 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -15,6 +15,8 @@ export interface SelectorResolutionArgs { planId?: string teamId?: string knowledgeBaseId?: string + siteId?: string + collectionId?: string } const defaultContext: SelectorContext = {} @@ -52,6 +54,8 @@ function buildBaseContext( planId: args.planId, teamId: args.teamId, knowledgeBaseId: args.knowledgeBaseId, + siteId: args.siteId, + collectionId: args.collectionId, ...extra, } } @@ -106,6 +110,14 @@ function resolveFileSelector( } case 'sharepoint': return { key: 'sharepoint.sites', context, allowSearch: true } + case 'webflow': + if (subBlock.id === 'collectionId') { + return { key: 'webflow.collections', context, allowSearch: false } + } + if (subBlock.id === 'itemId') { + return { key: 'webflow.items', context, allowSearch: true } + } + return { key: null, context, allowSearch: true } default: return { key: null, context, allowSearch: true } } @@ -159,6 +171,8 @@ function resolveProjectSelector( } case 'jira': return { key: 'jira.projects', context, allowSearch: true } + case 'webflow': + return { key: 'webflow.sites', context, allowSearch: false } default: return { key: null, context, allowSearch: true } } diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index d83a7816a..d186c4d50 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -23,6 +23,9 @@ export type SelectorKey = | 'microsoft.planner' | 'google.drive' | 'knowledge.documents' + | 'webflow.sites' + | 'webflow.collections' + | 'webflow.items' export interface SelectorOption { id: string @@ -43,6 +46,8 @@ export interface SelectorContext { planId?: string mimeType?: string fileId?: string + siteId?: string + collectionId?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/lib/auth/anonymous.ts b/apps/sim/lib/auth/anonymous.ts new file mode 100644 index 000000000..30ee4e94a --- /dev/null +++ b/apps/sim/lib/auth/anonymous.ts @@ -0,0 +1,104 @@ +import { db } from '@sim/db' +import * as schema from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console/logger' +import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants' + +const logger = createLogger('AnonymousAuth') + +let anonymousUserEnsured = false + +/** + * Ensures the anonymous user and their stats record exist in the database. + * Called when DISABLE_AUTH is enabled to ensure DB operations work. + */ +export async function ensureAnonymousUserExists(): Promise { + if (anonymousUserEnsured) return + + try { + const existingUser = await db.query.user.findFirst({ + where: eq(schema.user.id, ANONYMOUS_USER_ID), + }) + + if (!existingUser) { + const now = new Date() + await db.insert(schema.user).values({ + ...ANONYMOUS_USER, + createdAt: now, + updatedAt: now, + }) + logger.info('Created anonymous user for DISABLE_AUTH mode') + } + + const existingStats = await db.query.userStats.findFirst({ + where: eq(schema.userStats.userId, ANONYMOUS_USER_ID), + }) + + if (!existingStats) { + await db.insert(schema.userStats).values({ + id: crypto.randomUUID(), + userId: ANONYMOUS_USER_ID, + currentUsageLimit: '10000000000', + }) + logger.info('Created anonymous user stats for DISABLE_AUTH mode') + } + + anonymousUserEnsured = true + } catch (error) { + if ( + error instanceof Error && + (error.message.includes('unique') || error.message.includes('duplicate')) + ) { + anonymousUserEnsured = true + return + } + logger.error('Failed to ensure anonymous user exists', { error }) + throw error + } +} + +export interface AnonymousSession { + user: { + id: string + name: string + email: string + emailVerified: boolean + image: null + createdAt: Date + updatedAt: Date + } + session: { + id: string + userId: string + expiresAt: Date + createdAt: Date + updatedAt: Date + token: string + ipAddress: null + userAgent: null + } +} + +/** + * Creates an anonymous session for when auth is disabled. + */ +export function createAnonymousSession(): AnonymousSession { + const now = new Date() + return { + user: { + ...ANONYMOUS_USER, + createdAt: now, + updatedAt: now, + }, + session: { + id: 'anonymous-session', + userId: ANONYMOUS_USER_ID, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year + createdAt: now, + updatedAt: now, + token: 'anonymous-token', + ipAddress: null, + userAgent: null, + }, + } +} diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index 817902a0b..c5b841afe 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -10,7 +10,7 @@ import { import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' import { env } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider' @@ -25,9 +25,9 @@ export const client = createAuthClient({ stripeClient({ subscription: true, // Enable subscription management }), + organizationClient(), ] : []), - organizationClient(), ...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []), ], }) @@ -42,7 +42,9 @@ export function useSession(): SessionHookResult { return ctx } -export const { useActiveOrganization } = client +export const useActiveOrganization = isBillingEnabled + ? client.useActiveOrganization + : () => ({ data: undefined, isPending: false, error: null }) export const useSubscription = () => { return { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 6123656f7..fb9287e90 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -38,13 +38,19 @@ import { handleSubscriptionCreated, handleSubscriptionDeleted, } from '@/lib/billing/webhooks/subscription' -import { env, isTruthy } from '@/lib/core/config/env' -import { isBillingEnabled, isEmailVerificationEnabled } from '@/lib/core/config/environment' +import { env } from '@/lib/core/config/env' +import { + isAuthDisabled, + isBillingEnabled, + isEmailVerificationEnabled, + isRegistrationDisabled, +} from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { SSO_TRUSTED_PROVIDERS } from './sso/constants' const logger = createLogger('Auth') @@ -270,7 +276,7 @@ export const auth = betterAuth({ }, hooks: { before: createAuthMiddleware(async (ctx) => { - if (ctx.path.startsWith('/sign-up') && isTruthy(env.DISABLE_REGISTRATION)) + if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled) throw new Error('Registration is disabled, please contact your admin.') if ( @@ -2087,14 +2093,6 @@ export const auth = betterAuth({ try { await handleSubscriptionDeleted(subscription) - - // Reset usage limits to free tier - await syncSubscriptionUsageLimits(subscription) - - logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', { - subscriptionId: subscription.id, - referenceId: subscription.referenceId, - }) } catch (error) { logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', { subscriptionId: subscription.id, @@ -2193,6 +2191,11 @@ export const auth = betterAuth({ }) export async function getSession() { + if (isAuthDisabled) { + await ensureAnonymousUserExists() + return createAnonymousSession() + } + const hdrs = await headers() return await auth.api.getSession({ headers: hdrs, diff --git a/apps/sim/lib/auth/constants.ts b/apps/sim/lib/auth/constants.ts new file mode 100644 index 000000000..46eca038c --- /dev/null +++ b/apps/sim/lib/auth/constants.ts @@ -0,0 +1,10 @@ +/** Anonymous user ID used when DISABLE_AUTH is enabled */ +export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000' + +export const ANONYMOUS_USER = { + id: ANONYMOUS_USER_ID, + name: 'Anonymous', + email: 'anonymous@localhost', + emailVerified: true, + image: null, +} as const diff --git a/apps/sim/lib/auth/index.ts b/apps/sim/lib/auth/index.ts index 84c42e215..d997017e1 100644 --- a/apps/sim/lib/auth/index.ts +++ b/apps/sim/lib/auth/index.ts @@ -1 +1,4 @@ +export type { AnonymousSession } from './anonymous' +export { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' export { auth, getSession, signIn, signUp } from './auth' +export { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants' diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 66cf8fbe4..219f9e2f3 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { member, organization, userStats } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import { getUserUsageLimit } from '@/lib/billing/core/usage' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('UsageMonitor') diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 4aa977abe..74a2fa9ee 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -9,7 +9,7 @@ import { getPerUserMinimumLimit, } from '@/lib/billing/subscriptions/utils' import type { UserSubscriptionState } from '@/lib/billing/types' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index f784151cf..f32ac38bf 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -14,7 +14,7 @@ import { getPlanPricing, } from '@/lib/billing/subscriptions/utils' import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import { sendEmail } from '@/lib/messaging/email/mailer' diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 759948375..ae7e86b7f 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -21,6 +21,131 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('OrganizationMembership') +export interface RestoreProResult { + restored: boolean + usageRestored: boolean + subscriptionId?: string +} + +/** + * Restore a user's personal Pro subscription if it was paused (cancelAtPeriodEnd=true). + * Also restores any snapshotted Pro usage from when they joined a team. + * + * Called when: + * - A member leaves a team (via removeUserFromOrganization) + * - A team subscription ends (members stay but get Pro restored) + */ +export async function restoreUserProSubscription(userId: string): Promise { + const result: RestoreProResult = { + restored: false, + usageRestored: false, + } + + try { + const [personalPro] = await db + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, userId), + eq(subscriptionTable.status, 'active'), + eq(subscriptionTable.plan, 'pro') + ) + ) + .limit(1) + + if (!personalPro?.cancelAtPeriodEnd || !personalPro.stripeSubscriptionId) { + return result + } + + result.subscriptionId = personalPro.id + + try { + const stripe = requireStripeClient() + await stripe.subscriptions.update(personalPro.stripeSubscriptionId, { + cancel_at_period_end: false, + }) + } catch (stripeError) { + logger.error('Stripe restore cancel_at_period_end failed for personal Pro', { + userId, + stripeSubscriptionId: personalPro.stripeSubscriptionId, + error: stripeError, + }) + } + + try { + await db + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: false }) + .where(eq(subscriptionTable.id, personalPro.id)) + + result.restored = true + + logger.info('Restored personal Pro subscription', { + userId, + subscriptionId: personalPro.id, + }) + } catch (dbError) { + logger.error('DB update failed when restoring personal Pro', { + userId, + subscriptionId: personalPro.id, + error: dbError, + }) + } + + try { + const [stats] = await db + .select({ + currentPeriodCost: userStats.currentPeriodCost, + proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, + }) + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) + + if (stats) { + const currentUsage = stats.currentPeriodCost || '0' + const snapshotUsage = stats.proPeriodCostSnapshot || '0' + const snapshotNum = Number.parseFloat(snapshotUsage) + + if (snapshotNum > 0) { + const currentNum = Number.parseFloat(currentUsage) + const restoredUsage = (currentNum + snapshotNum).toString() + + await db + .update(userStats) + .set({ + currentPeriodCost: restoredUsage, + proPeriodCostSnapshot: '0', + }) + .where(eq(userStats.userId, userId)) + + result.usageRestored = true + + logger.info('Restored Pro usage snapshot', { + userId, + previousUsage: currentUsage, + snapshotUsage, + restoredUsage, + }) + } + } + } catch (usageRestoreError) { + logger.error('Failed to restore Pro usage snapshot', { + userId, + error: usageRestoreError, + }) + } + } catch (error) { + logger.error('Failed to restore user Pro subscription', { + userId, + error, + }) + } + + return result +} + export interface AddMemberParams { userId: string organizationId: string @@ -409,7 +534,6 @@ export async function removeUserFromOrganization( // STEP 3: Restore personal Pro if user has no remaining paid team memberships if (!skipBillingLogic) { try { - // Check for remaining paid team memberships const remainingPaidTeams = await db .select({ orgId: member.organizationId }) .from(member) @@ -428,104 +552,10 @@ export async function removeUserFromOrganization( ) } - // If no remaining paid teams, try to restore personal Pro if (!hasAnyPaidTeam) { - const [personalPro] = await db - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.referenceId, userId), - eq(subscriptionTable.status, 'active'), - eq(subscriptionTable.plan, 'pro') - ) - ) - .limit(1) - - // Only restore if cancelAtPeriodEnd is true AND stripeSubscriptionId exists - if ( - personalPro && - personalPro.cancelAtPeriodEnd === true && - personalPro.stripeSubscriptionId - ) { - // Call Stripe API first (separate try/catch so failure doesn't prevent DB update) - try { - const stripe = requireStripeClient() - await stripe.subscriptions.update(personalPro.stripeSubscriptionId, { - cancel_at_period_end: false, - }) - } catch (stripeError) { - logger.error('Stripe restore cancel_at_period_end failed for personal Pro', { - userId, - stripeSubscriptionId: personalPro.stripeSubscriptionId, - error: stripeError, - }) - } - - // Update DB (separate try/catch) - try { - await db - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: false }) - .where(eq(subscriptionTable.id, personalPro.id)) - - billingActions.proRestored = true - - logger.info('Restored personal Pro after leaving last paid team', { - userId, - personalSubscriptionId: personalPro.id, - }) - } catch (dbError) { - logger.error('DB update failed when restoring personal Pro', { - userId, - subscriptionId: personalPro.id, - error: dbError, - }) - } - - // Restore snapshotted Pro usage (separate try/catch) - try { - const [stats] = await db - .select({ - currentPeriodCost: userStats.currentPeriodCost, - proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, - }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - if (stats) { - const currentUsage = stats.currentPeriodCost || '0' - const snapshotUsage = stats.proPeriodCostSnapshot || '0' - - const currentNum = Number.parseFloat(currentUsage) - const snapshotNum = Number.parseFloat(snapshotUsage) - const restoredUsage = (currentNum + snapshotNum).toString() - - await db - .update(userStats) - .set({ - currentPeriodCost: restoredUsage, - proPeriodCostSnapshot: '0', - }) - .where(eq(userStats.userId, userId)) - - billingActions.usageRestored = true - - logger.info('Restored Pro usage after leaving team', { - userId, - previousUsage: currentUsage, - snapshotUsage: snapshotUsage, - restoredUsage: restoredUsage, - }) - } - } catch (usageRestoreError) { - logger.error('Failed to restore Pro usage after leaving team', { - userId, - error: usageRestoreError, - }) - } - } + const restoreResult = await restoreUserProSubscription(userId) + billingActions.proRestored = restoreResult.restored + billingActions.usageRestored = restoreResult.usageRestored } } catch (postRemoveError) { logger.error('Post-removal personal Pro restore check failed', { diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 8f15ec223..9e7dd5efc 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -13,7 +13,7 @@ import { import { organization, subscription, userStats } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { getEnv } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('StorageLimits') diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index b094769e7..704a4ae6a 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -7,7 +7,7 @@ import { db } from '@sim/db' import { organization, userStats } from '@sim/db/schema' import { eq, sql } from 'drizzle-orm' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('StorageTracking') diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 0e06b7519..5a55e59cb 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -1,7 +1,9 @@ import { db } from '@sim/db' -import { subscription } from '@sim/db/schema' +import { member, organization, subscription } from '@sim/db/schema' import { and, eq, ne } from 'drizzle-orm' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' +import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { requireStripeClient } from '@/lib/billing/stripe-client' import { getBilledOverageForSubscription, @@ -11,6 +13,71 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('StripeSubscriptionWebhooks') +/** + * Restore personal Pro subscriptions for all members of an organization + * when the team/enterprise subscription ends. + */ +async function restoreMemberProSubscriptions(organizationId: string): Promise { + let restoredCount = 0 + + try { + const members = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + for (const m of members) { + const result = await restoreUserProSubscription(m.userId) + if (result.restored) { + restoredCount++ + } + } + + if (restoredCount > 0) { + logger.info('Restored Pro subscriptions for team members', { + organizationId, + restoredCount, + totalMembers: members.length, + }) + } + } catch (error) { + logger.error('Failed to restore member Pro subscriptions', { + organizationId, + error, + }) + } + + return restoredCount +} + +/** + * Cleanup organization when team/enterprise subscription is deleted. + * - Restores member Pro subscriptions + * - Deletes the organization + * - Syncs usage limits for former members (resets to free or Pro tier) + */ +async function cleanupOrganizationSubscription(organizationId: string): Promise<{ + restoredProCount: number + membersSynced: number +}> { + // Get member userIds before deletion (needed for limit syncing after org deletion) + const memberUserIds = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + const restoredProCount = await restoreMemberProSubscriptions(organizationId) + + await db.delete(organization).where(eq(organization.id, organizationId)) + + // Sync usage limits for former members (now free or Pro tier) + for (const m of memberUserIds) { + await syncUsageLimitsFromSubscription(m.userId) + } + + return { restoredProCount, membersSynced: memberUserIds.length } +} + /** * Handle new subscription creation - reset usage if transitioning from free to paid */ @@ -98,12 +165,24 @@ export async function handleSubscriptionDeleted(subscription: { const totalOverage = await calculateSubscriptionOverage(subscription) const stripe = requireStripeClient() - // Enterprise plans have no overages - just reset usage + // Enterprise plans have no overages - reset usage and cleanup org if (subscription.plan === 'enterprise') { await resetUsageForSubscription({ plan: subscription.plan, referenceId: subscription.referenceId, }) + + const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription( + subscription.referenceId + ) + + logger.info('Successfully processed enterprise subscription cancellation', { + subscriptionId: subscription.id, + stripeSubscriptionId, + restoredProCount, + organizationDeleted: true, + membersSynced, + }) return } @@ -209,13 +288,32 @@ export async function handleSubscriptionDeleted(subscription: { referenceId: subscription.referenceId, }) + // Plan-specific cleanup after billing + let restoredProCount = 0 + let organizationDeleted = false + let membersSynced = 0 + + if (subscription.plan === 'team') { + const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) + restoredProCount = cleanup.restoredProCount + membersSynced = cleanup.membersSynced + organizationDeleted = true + } else if (subscription.plan === 'pro') { + await syncUsageLimitsFromSubscription(subscription.referenceId) + membersSynced = 1 + } + // Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler - // We only need to handle overage billing and usage reset + // We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup logger.info('Successfully processed subscription cancellation', { subscriptionId: subscription.id, stripeSubscriptionId, + plan: subscription.plan, totalOverage, + restoredProCount, + organizationDeleted, + membersSynced, }) } catch (error) { logger.error('Failed to handle subscription deletion', { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ab289468e..2fc2ad1a0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -20,6 +20,7 @@ export const env = createEnv({ BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration + DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session) ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data diff --git a/apps/sim/lib/core/config/environment.ts b/apps/sim/lib/core/config/feature-flags.ts similarity index 63% rename from apps/sim/lib/core/config/environment.ts rename to apps/sim/lib/core/config/feature-flags.ts index 835f54c8b..cac10d296 100644 --- a/apps/sim/lib/core/config/environment.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -35,6 +35,31 @@ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) */ export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED) +/** + * Is authentication disabled (for self-hosted deployments behind private networks) + */ +export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) + +/** + * Is user registration disabled + */ +export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION) + +/** + * Is Trigger.dev enabled for async job processing + */ +export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) + +/** + * Is SSO enabled for enterprise authentication + */ +export const isSsoEnabled = isTruthy(env.SSO_ENABLED) + +/** + * Is E2B enabled for remote code execution + */ +export const isE2bEnabled = isTruthy(env.E2B_ENABLED) + /** * Get cost multiplier based on environment */ diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index e740ac52f..22e164cf1 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -1,5 +1,5 @@ import { getEnv } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' /** * Returns the base URL of the application from NEXT_PUBLIC_APP_URL diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts index a1b0cc202..14e2ceee8 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -6,7 +6,7 @@ import { } from '@sim/db/schema' import { and, eq, or, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { WorkflowExecutionLog } from '@/lib/logs/types' import { @@ -140,9 +140,7 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): alertConfig: alertConfig || undefined, } - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - - if (useTrigger) { + if (isTriggerDevEnabled) { await workspaceNotificationDeliveryTask.trigger(payload) logger.info( `Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev` diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 3bf849631..390a60456 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -15,7 +15,7 @@ import { maybeSendUsageThresholdEmail, } from '@/lib/billing/core/usage' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { filterForDisplay } from '@/lib/core/utils/display-filters' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index cc04f2e6d..8d1483f52 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -5,7 +5,7 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import { isTest } from '@/lib/core/config/environment' +import { isTest } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/lib/notifications/inactivity-polling.ts b/apps/sim/lib/notifications/inactivity-polling.ts index f5558088f..4d4395faa 100644 --- a/apps/sim/lib/notifications/inactivity-polling.ts +++ b/apps/sim/lib/notifications/inactivity-polling.ts @@ -7,7 +7,7 @@ import { } from '@sim/db/schema' import { and, eq, gte, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import { executeNotificationDelivery, @@ -118,9 +118,7 @@ async function checkWorkflowInactivity( alertConfig, } - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - - if (useTrigger) { + if (isTriggerDevEnabled) { await workspaceNotificationDeliveryTask.trigger(payload) } else { void executeNotificationDelivery(payload).catch((error) => { diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 8f0fd8cad..d7cb0f65f 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -3,7 +3,7 @@ import { tasks } from '@trigger.dev/sdk' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { preprocessExecution } from '@/lib/execution/preprocessing' import { createLogger } from '@/lib/logs/console/logger' import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' @@ -707,9 +707,7 @@ export async function queueWebhookExecution( ...(credentialId ? { credentialId } : {}), } - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - - if (useTrigger) { + if (isTriggerDevEnabled) { const handle = await tasks.trigger('webhook-execution', payload) logger.info( `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${ diff --git a/apps/sim/lib/workflows/autolayout/constants.ts b/apps/sim/lib/workflows/autolayout/constants.ts index 7616fb094..838d8315c 100644 --- a/apps/sim/lib/workflows/autolayout/constants.ts +++ b/apps/sim/lib/workflows/autolayout/constants.ts @@ -11,7 +11,7 @@ export { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b /** * Horizontal spacing between layers (columns) */ -export const DEFAULT_HORIZONTAL_SPACING = 350 +export const DEFAULT_HORIZONTAL_SPACING = 250 /** * Vertical spacing between blocks in the same layer diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 3e3e22bc8..a007db01b 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -1,6 +1,6 @@ import type { NextConfig } from 'next' import { env, getEnv, isTruthy } from './lib/core/config/env' -import { isDev, isHosted } from './lib/core/config/environment' +import { isDev, isHosted } from './lib/core/config/feature-flags' import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/core/security/csp' const nextConfig: NextConfig = { diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 0b6d7b1ac..72d1423e1 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,4 +1,4 @@ -import { getCostMultiplier } from '@/lib/core/config/environment' +import { getCostMultiplier } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { StreamingExecution } from '@/executor/types' import type { ProviderRequest, ProviderResponse } from '@/providers/types' diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 13977f37d..003119c86 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as environmentModule from '@/lib/core/config/environment' +import * as environmentModule from '@/lib/core/config/feature-flags' import { calculateCost, extractAndParseJSON, diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ca0b6db77..972cdf815 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1,5 +1,5 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import { anthropicProvider } from '@/providers/anthropic' import { azureOpenAIProvider } from '@/providers/azure-openai' diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 0d0c2cb64..bb1b23bfa 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -1,6 +1,6 @@ import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' -import { isHosted } from './lib/core/config/environment' +import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags' import { generateRuntimeCSP } from './lib/core/security/csp' import { createLogger } from './lib/logs/console/logger' @@ -135,7 +135,7 @@ export async function proxy(request: NextRequest) { const url = request.nextUrl const sessionCookie = getSessionCookie(request) - const hasActiveSession = !!sessionCookie + const hasActiveSession = isAuthDisabled || !!sessionCookie const redirect = handleRootPathRedirects(request, hasActiveSession) if (redirect) return redirect diff --git a/apps/sim/scripts/process-docs.ts b/apps/sim/scripts/process-docs.ts index 2a53e78fc..8657e3b6f 100644 --- a/apps/sim/scripts/process-docs.ts +++ b/apps/sim/scripts/process-docs.ts @@ -5,7 +5,7 @@ import { db } from '@sim/db' import { docsEmbeddings } from '@sim/db/schema' import { sql } from 'drizzle-orm' import { type DocChunk, DocsChunker } from '@/lib/chunkers' -import { isDev } from '@/lib/core/config/environment' +import { isDev } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ProcessDocs') diff --git a/apps/sim/socket-server/config/socket.ts b/apps/sim/socket-server/config/socket.ts index 2eab72f58..6015dc971 100644 --- a/apps/sim/socket-server/config/socket.ts +++ b/apps/sim/socket-server/config/socket.ts @@ -1,7 +1,7 @@ import type { Server as HttpServer } from 'http' import { Server } from 'socket.io' import { env } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/socket-server/middleware/auth.ts b/apps/sim/socket-server/middleware/auth.ts index 56de4676c..3b3a6cc54 100644 --- a/apps/sim/socket-server/middleware/auth.ts +++ b/apps/sim/socket-server/middleware/auth.ts @@ -1,10 +1,14 @@ import type { Socket } from 'socket.io' import { auth } from '@/lib/auth' +import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { isAuthDisabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('SocketAuth') -// Extend Socket interface to include user data +/** + * Authenticated socket with user data attached. + */ export interface AuthenticatedSocket extends Socket { userId?: string userName?: string @@ -13,9 +17,21 @@ export interface AuthenticatedSocket extends Socket { userImage?: string | null } -// Enhanced authentication middleware +/** + * Socket.IO authentication middleware. + * Handles both anonymous mode (DISABLE_AUTH=true) and normal token-based auth. + */ export async function authenticateSocket(socket: AuthenticatedSocket, next: any) { try { + if (isAuthDisabled) { + socket.userId = ANONYMOUS_USER_ID + socket.userName = ANONYMOUS_USER.name + socket.userEmail = ANONYMOUS_USER.email + socket.userImage = ANONYMOUS_USER.image + logger.debug(`Socket ${socket.id} authenticated as anonymous`) + return next() + } + // Extract authentication data from socket handshake const token = socket.handshake.auth?.token const origin = socket.handshake.headers.origin diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index 1c99a162d..9e8248d3e 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,4 +1,4 @@ -import { isTest } from '@/lib/core/config/environment' +import { isTest } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import type { TableRow } from '@/tools/types' diff --git a/apps/sim/tools/webflow/create_item.ts b/apps/sim/tools/webflow/create_item.ts index 7dd736b23..516bab931 100644 --- a/apps/sim/tools/webflow/create_item.ts +++ b/apps/sim/tools/webflow/create_item.ts @@ -20,6 +20,12 @@ export const webflowCreateItemTool: ToolConfig