Compare commits

..

5 Commits

Author SHA1 Message Date
Siddharth Ganesan
f67caf0798 Fix tests 2025-12-15 10:56:33 -08:00
Siddharth Ganesan
0b853a7d95 Fixes 2025-12-15 10:33:41 -08:00
Vikhyath Mondreti
4f31560a0e use isCallEndRef correctly 2025-12-13 12:50:24 -08:00
Vikhyath Mondreti
8b5027f2a6 Merge branch 'staging' into fix/chat-tools 2025-12-13 12:34:12 -08:00
Siddharth Ganesan
c6c658a6e1 Fix chat tools 2025-12-13 11:56:56 -08:00
119 changed files with 1125 additions and 1491 deletions

View File

@@ -243,9 +243,6 @@ 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:
@@ -275,23 +272,12 @@ 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_large_image',
card: 'summary',
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,

View File

@@ -1,173 +0,0 @@
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<ArrayBuffer> {
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(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(315deg, #1e1e3f 0%, #1a1a2e 40%, #0f0f0f 100%)',
position: 'relative',
fontFamily: 'Geist',
}}
>
{/* Background texture */}
<img
src={backgroundImageUrl}
alt=''
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: 0.04,
}}
/>
{/* Subtle purple glow from bottom right */}
<div
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '50%',
height: '100%',
background:
'radial-gradient(ellipse at bottom right, rgba(112, 31, 252, 0.1) 0%, transparent 50%)',
display: 'flex',
}}
/>
{/* Content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '56px 72px',
height: '100%',
justifyContent: 'space-between',
}}
>
{/* Logo */}
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
{/* Category + Title + Description */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<span
style={{
fontSize: 15,
fontWeight: 600,
color: '#802fff',
letterSpacing: '0.02em',
}}
>
{category}
</span>
<span
style={{
fontSize: getTitleFontSize(title),
fontWeight: 600,
color: '#ffffff',
lineHeight: 1.1,
letterSpacing: '-0.02em',
}}
>
{title}
</span>
{description && (
<span
style={{
fontSize: 18,
fontWeight: 400,
color: '#a1a1aa',
lineHeight: 1.4,
marginTop: 4,
}}
>
{description.length > 100 ? `${description.slice(0, 100)}...` : description}
</span>
)}
</div>
{/* Footer */}
<span
style={{
fontSize: 15,
fontWeight: 500,
color: '#52525b',
}}
>
docs.sim.ai
</span>
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: 'Geist',
data: fontData,
style: 'normal',
},
],
}
)
}

View File

@@ -56,14 +56,6 @@ 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',
@@ -72,7 +64,7 @@ export const metadata = {
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
creator: '@simdotai',
site: '@simdotai',
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION'],
images: ['/og-image.png'],
},
robots: {
index: true,

View File

@@ -39,10 +39,9 @@ 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
@@ -59,7 +58,6 @@ 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 |
@@ -78,9 +76,8 @@ 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. Schlüssel sollten mit den Sammlungsfeldnamen übereinstimmen. |
| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Die Schlüssel sollten mit den Feldnamen der Sammlung übereinstimmen. |
#### Ausgabe
@@ -97,7 +94,6 @@ 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. |
@@ -117,7 +113,6 @@ 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 |

View File

@@ -42,7 +42,6 @@ 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\) |
@@ -62,7 +61,6 @@ 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 |
@@ -81,7 +79,6 @@ 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. |
@@ -100,7 +97,6 @@ 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. |
@@ -120,7 +116,6 @@ 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 |

View File

@@ -39,7 +39,6 @@ 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\) |
@@ -59,7 +58,6 @@ 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 |
@@ -78,7 +76,6 @@ 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. |
@@ -97,7 +94,6 @@ 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. |
@@ -117,7 +113,6 @@ 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 |

View File

@@ -38,8 +38,7 @@ 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\) |
@@ -58,8 +57,7 @@ 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 |
@@ -78,9 +76,8 @@ 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 des champs 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 de champ pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. |
#### Sortie
@@ -97,10 +94,9 @@ 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 des champs à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
| `fieldData` | json | Oui | Données de champ à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
#### Sortie
@@ -117,7 +113,6 @@ 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 |

View File

@@ -39,10 +39,9 @@ Webflow CMSコレクションからすべてのアイテムを一覧表示する
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | はい | WebflowサイトのID |
| `collectionId` | string | はい | コレクションのID |
| `offset` | number | いいえ | ページネーション用のオフセット(オプション) |
| `limit` | number | いいえ | 返す最大アイテム数オプション、デフォルト100 |
| `limit` | number | いいえ | 返すアイテムの最大オプション、デフォルト100 |
#### 出力
@@ -59,7 +58,6 @@ Webflow CMSコレクションから単一のアイテムを取得する
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | はい | WebflowサイトのID |
| `collectionId` | string | はい | コレクションのID |
| `itemId` | string | はい | 取得するアイテムのID |
@@ -78,9 +76,8 @@ Webflow CMSコレクションに新しいアイテムを作成する
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | はい | WebflowサイトのID |
| `collectionId` | string | はい | コレクションのID |
| `fieldData` | json | はい | 新しいアイテムのフィールドデータJSONオブジェクト。キーはコレクションフィールド名と一致する必要があります。 |
| `fieldData` | json | はい | 新しいアイテムのフィールドデータJSONオブジェクト形式)。キーはコレクションフィールド名と一致する必要があります。 |
#### 出力
@@ -97,10 +94,9 @@ Webflow CMSコレクション内の既存アイテムを更新する
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | はい | WebflowサイトのID |
| `collectionId` | string | はい | コレクションのID |
| `itemId` | string | はい | 更新するアイテムのID |
| `fieldData` | json | はい | 更新するフィールドデータJSONオブジェクト。変更したいフィールドのみを含めてください。 |
| `fieldData` | json | はい | 更新するフィールドデータJSONオブジェクト形式)。変更したいフィールドのみを含めてください。 |
#### 出力
@@ -117,7 +113,6 @@ Webflow CMSコレクションからアイテムを削除する
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | はい | WebflowサイトのID |
| `collectionId` | string | はい | コレクションのID |
| `itemId` | string | はい | 削除するアイテムのID |

View File

@@ -38,10 +38,9 @@ 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 |
#### 输出
@@ -58,9 +57,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `itemId` | string | 是 | 要检索项目 ID |
| `itemId` | string | 是 | 要检索项目 ID |
#### 输出
@@ -77,9 +75,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 |
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 |
#### 输出
@@ -96,10 +93,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `itemId` | string | 是 | 要更新项目的 ID |
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含需要更改的字段。 |
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含您想更改的字段。 |
#### 输出
@@ -116,7 +112,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `itemId` | string | 是 | 要删除项目的 ID |

View File

@@ -5973,31 +5973,31 @@ checksums:
content/9: 5914baadfaf2ca26d54130a36dd5ed29
content/10: 25507380ac7d9c7f8cf9f5256c6a0dbb
content/11: 371d0e46b4bd2c23f559b8bc112f6955
content/12: e034523b05e8c7bd1723ef0ba96c5332
content/12: e7fb612c3323c1e6b05eacfcea360d34
content/13: bcadfc362b69078beee0088e5936c98b
content/14: e5f830d6049ff79a318110098e5e0130
content/15: 711e90714806b91f93923018e82ad2e9
content/16: 0f3f7d9699d7397cb3a094c3229329ee
content/17: 371d0e46b4bd2c23f559b8bc112f6955
content/18: 4b0c581b30f4449b0bfa3cdd4af69e02
content/18: c53b5b8f901066e63fe159ad2fa5e6e0
content/19: bcadfc362b69078beee0088e5936c98b
content/20: 5f2afdd49c3ac13381401c69d1eca22a
content/21: cc4baa9096fafa4c6276f6136412ba66
content/22: 676f76e8a7154a576d7fa20b245cef70
content/23: 371d0e46b4bd2c23f559b8bc112f6955
content/24: d26dd24c5398fd036d1f464ba3789002
content/24: c67c387eb7e274ee7c07b7e1748afce1
content/25: bcadfc362b69078beee0088e5936c98b
content/26: a6ffebda549ad5b903a66c7d9ac03a20
content/27: 0dadd51cde48d6ea75b29ec3ee4ade56
content/28: cdc74f6483a0b4e9933ecdd92ed7480f
content/29: 371d0e46b4bd2c23f559b8bc112f6955
content/30: cec3953ee52d1d3c8b1a495f9684d35b
content/30: 4cda10aa374e1a46d60ad14eeaa79100
content/31: bcadfc362b69078beee0088e5936c98b
content/32: 5f221421953a0e760ead7388cbf66561
content/33: a3c0372590cef72d5d983dbc8dbbc2cb
content/34: 1402e53c08bdd8a741f44b2d66fcd003
content/35: 371d0e46b4bd2c23f559b8bc112f6955
content/36: db921b05a9e5ddceb28a4f3f1af2a377
content/36: 028e579a28e55def4fbc59f39f4610b7
content/37: bcadfc362b69078beee0088e5936c98b
content/38: 4fe4260da2f137679ce2fa42cffcf56a
content/39: b3f310d5ef115bea5a8b75bf25d7ea9a

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

View File

@@ -4,13 +4,10 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
# POSTGRES_PORT=5432
# Authentication (Required unless DISABLE_AUTH=true)
# Authentication (Required)
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

View File

@@ -1,7 +1,7 @@
'use server'
import { env } from '@/lib/core/config/env'
import { isProd } from '@/lib/core/config/feature-flags'
import { isProd } from '@/lib/core/config/environment'
export async function getOAuthProviderStatus() {
const githubAvailable = !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET)

View File

@@ -1,6 +1,7 @@
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() {

View File

@@ -1,16 +1,16 @@
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
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() {
if (isRegistrationDisabled) {
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
if (isTruthy(env.DISABLE_REGISTRATION)) {
return <div>Registration is disabled, please contact your admin.</div>
}
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
return (
<SignupForm
githubAvailable={githubAvailable}

View File

@@ -1,4 +1,4 @@
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/environment'
import { hasEmailService } from '@/lib/messaging/email/mailer'
import { VerifyContent } from '@/app/(auth)/verify/verify-content'

View File

@@ -13,7 +13,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { quickValidateEmail } from '@/lib/messaging/email/validation'

View File

@@ -1,6 +1,6 @@
'use client'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import Footer from '@/app/(landing)/components/footer/footer'
import Nav from '@/app/(landing)/components/nav/nav'

View File

@@ -7,7 +7,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { GithubIcon } from '@/components/icons'
import { useBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'

View File

@@ -1,23 +1,6 @@
import { toNextJsHandler } from 'better-auth/next-js'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
export const dynamic = 'force-dynamic'
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const path = url.pathname.replace('/api/auth/', '')
if (path === 'get-session' && isAuthDisabled) {
await ensureAnonymousUserExists()
return NextResponse.json(createAnonymousSession())
}
return betterAuthGET(request)
}
export const POST = betterAuthPOST
export const { GET, POST } = toNextJsHandler(auth.handler)

View File

@@ -1,14 +1,9 @@
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
export async function POST() {
try {
if (isAuthDisabled) {
return NextResponse.json({ token: 'anonymous-socket-token' })
}
const hdrs = await headers()
const response = await auth.api.generateOneTimeToken({
headers: hdrs,

View File

@@ -1,14 +1,14 @@
import { db, ssoProvider } from '@sim/db'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SSO-Providers')
export async function GET() {
export async function GET(req: NextRequest) {
try {
const session = await getSession()
const session = await auth.api.getSession({ headers: req.headers })
let providers
if (session?.user?.id) {
@@ -38,6 +38,8 @@ export async function GET() {
: ('oidc' as 'oidc' | 'saml'),
}))
} else {
// Unauthenticated users can only see basic info (domain only)
// This is needed for SSO login flow to check if a domain has SSO enabled
const results = await db
.select({
domain: ssoProvider.domain,

View File

@@ -5,7 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -6,12 +6,6 @@ import { NextRequest } from 'next/server'
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/config/feature-flags', () => ({
isDev: true,
isHosted: false,
isProd: false,
}))
describe('Chat Edit API Route', () => {
const mockSelect = vi.fn()
const mockFrom = vi.fn()
@@ -30,6 +24,7 @@ describe('Chat Edit API Route', () => {
beforeEach(() => {
vi.resetModules()
// Set default return values
mockLimit.mockResolvedValue([])
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
@@ -82,6 +77,10 @@ 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,
}))
@@ -255,6 +254,7 @@ 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' }),
body: JSON.stringify({ authType: 'password' }), // No password provided
})
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
@@ -316,8 +316,9 @@ 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([])
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
@@ -398,6 +399,7 @@ describe('Chat Edit API Route', () => {
}),
}))
// User doesn't own chat but has workspace admin access
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)

View File

@@ -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/feature-flags'
import { isDev } from '@/lib/core/config/environment'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -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/feature-flags'
import { isDev } from '@/lib/core/config/environment'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -44,12 +44,6 @@ 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', () => ({
@@ -68,6 +62,11 @@ describe('Chat API Utils', () => {
NODE_ENV: 'development',
},
})
vi.doMock('@/lib/core/config/environment', () => ({
isDev: true,
isHosted: false,
}))
})
afterEach(() => {

View File

@@ -3,7 +3,7 @@ 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/feature-flags'
import { isDev } from '@/lib/core/config/environment'
import { decryptSecret } from '@/lib/core/security/encryption'
import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
@@ -282,8 +282,8 @@ export async function validateChatAuth(
return { authorized: false, error: 'Email not authorized for SSO access' }
}
const { getSession } = await import('@/lib/auth')
const session = await getSession()
const { auth } = await import('@/lib/auth')
const session = await auth.api.getSession({ headers: request.headers })
if (!session || !session.user) {
return { authorized: false, error: 'auth_required_sso' }

View File

@@ -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 { getSession } from '@/lib/auth'
import { auth } 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() {
export async function GET(request: NextRequest) {
try {
const session = await getSession()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -31,6 +31,7 @@ export async function GET() {
return NextResponse.json({ autoAllowedTools })
}
// If no settings record exists, create one with empty array
await db.insert(settings).values({
id: userId,
userId,
@@ -49,7 +50,7 @@ export async function GET() {
*/
export async function POST(request: NextRequest) {
try {
const session = await getSession()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -64,11 +65,13 @@ 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
@@ -86,6 +89,7 @@ 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,
@@ -105,7 +109,7 @@ export async function POST(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const session = await getSession()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -119,6 +123,7 @@ 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) {

View File

@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { auth } 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<string, boolean> = {
// GET - Fetch user's enabled models
export async function GET(request: NextRequest) {
try {
const session = await getSession()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -40,6 +40,7 @@ export async function GET(request: NextRequest) {
const userId = session.user.id
// Try to fetch existing settings record
const [userSettings] = await db
.select()
.from(settings)
@@ -49,11 +50,13 @@ export async function GET(request: NextRequest) {
if (userSettings) {
const userModelsMap = (userSettings.copilotEnabledModels as Record<string, boolean>) || {}
// 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)
)
@@ -73,6 +76,7 @@ export async function GET(request: NextRequest) {
})
}
// If no settings record exists, create one with defaults
await db.insert(settings).values({
id: userId,
userId,
@@ -93,7 +97,7 @@ export async function GET(request: NextRequest) {
// PUT - Update user's enabled models
export async function PUT(request: NextRequest) {
try {
const session = await getSession()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -106,9 +110,11 @@ 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({
@@ -117,6 +123,7 @@ export async function PUT(request: NextRequest) {
})
.where(eq(settings.userId, userId))
} else {
// Create new settings record
await db.insert(settings).values({
id: userId,
userId,

View File

@@ -1,6 +1,6 @@
import { createContext, Script } from 'vm'
import { type NextRequest, NextResponse } from 'next/server'
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
import { validateProxyUrl } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { executeInE2B } from '@/lib/execution/e2b'
@@ -701,6 +701,7 @@ 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)
@@ -721,14 +722,14 @@ export async function POST(req: NextRequest) {
}
// Python always requires E2B
if (lang === CodeLanguage.Python && !isE2bEnabled) {
if (lang === CodeLanguage.Python && !e2bEnabled) {
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 && !isE2bEnabled) {
if (lang === CodeLanguage.JavaScript && hasImports && !e2bEnabled) {
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.'
)
@@ -739,13 +740,13 @@ export async function POST(req: NextRequest) {
// - Not a custom tool AND
// - (Python OR JavaScript with imports)
const useE2B =
isE2bEnabled &&
e2bEnabled &&
!isCustomTool &&
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports))
if (useE2B) {
logger.info(`[${requestId}] E2B status`, {
enabled: isE2bEnabled,
enabled: e2bEnabled,
hasApiKey: Boolean(process.env.E2B_API_KEY),
language: lang,
})

View File

@@ -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/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationSeatsAPI')

View File

@@ -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/feature-flags'
import { isDev } from '@/lib/core/config/environment'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'

View File

@@ -1,7 +1,6 @@
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'
@@ -148,10 +147,6 @@ 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,

View File

@@ -42,11 +42,11 @@ describe('Scheduled Workflow Execution API Route', () => {
executeScheduleJob: mockExecuteScheduleJob,
}))
vi.doMock('@/lib/core/config/feature-flags', () => ({
isTriggerDevEnabled: false,
isHosted: false,
isProd: false,
isDev: true,
vi.doMock('@/lib/core/config/env', () => ({
env: {
TRIGGER_DEV_ENABLED: false,
},
isTruthy: vi.fn(() => false),
}))
vi.doMock('drizzle-orm', () => ({
@@ -119,11 +119,11 @@ describe('Scheduled Workflow Execution API Route', () => {
},
}))
vi.doMock('@/lib/core/config/feature-flags', () => ({
isTriggerDevEnabled: true,
isHosted: false,
isProd: false,
isDev: true,
vi.doMock('@/lib/core/config/env', () => ({
env: {
TRIGGER_DEV_ENABLED: true,
},
isTruthy: vi.fn(() => 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/feature-flags', () => ({
isTriggerDevEnabled: false,
isHosted: false,
isProd: false,
isDev: true,
vi.doMock('@/lib/core/config/env', () => ({
env: {
TRIGGER_DEV_ENABLED: false,
},
isTruthy: vi.fn(() => false),
}))
vi.doMock('drizzle-orm', () => ({
@@ -250,11 +250,11 @@ describe('Scheduled Workflow Execution API Route', () => {
executeScheduleJob: vi.fn().mockResolvedValue(undefined),
}))
vi.doMock('@/lib/core/config/feature-flags', () => ({
isTriggerDevEnabled: false,
isHosted: false,
isProd: false,
isDev: true,
vi.doMock('@/lib/core/config/env', () => ({
env: {
TRIGGER_DEV_ENABLED: false,
},
isTruthy: vi.fn(() => false),
}))
vi.doMock('drizzle-orm', () => ({

View File

@@ -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 { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { executeScheduleJob } from '@/background/schedule-execution'
@@ -54,7 +54,9 @@ export async function GET(request: NextRequest) {
logger.debug(`[${requestId}] Successfully queried schedules: ${dueSchedules.length} found`)
logger.info(`[${requestId}] Processing ${dueSchedules.length} due scheduled workflows`)
if (isTriggerDevEnabled) {
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (useTrigger) {
const triggerPromises = dueSchedules.map(async (schedule) => {
const queueTime = schedule.lastQueuedAt ?? queuedAt

View File

@@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { isProd } from '@/lib/core/config/feature-flags'
import { isProd } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('TelemetryAPI')

View File

@@ -1,7 +1,6 @@
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'
@@ -42,17 +41,6 @@ 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),

View File

@@ -1,55 +1,32 @@
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 { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebflowCollectionsAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
export async function GET(request: NextRequest) {
try {
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 session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
if (!siteIdValidation.isValid) {
logger.error('Invalid siteId', { error: siteIdValidation.error })
return NextResponse.json({ error: siteIdValidation.error }, { 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 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 getOAuthToken(session.user.id, 'webflow')
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 }
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
{ status: 404 }
)
}
@@ -81,11 +58,11 @@ export async function POST(request: Request) {
name: collection.displayName || collection.slug || collection.id,
}))
return NextResponse.json({ collections: formattedCollections })
} catch (error) {
logger.error('Error processing Webflow collections request:', error)
return NextResponse.json({ collections: formattedCollections }, { status: 200 })
} catch (error: any) {
logger.error('Error fetching Webflow collections', error)
return NextResponse.json(
{ error: 'Failed to retrieve Webflow collections', details: (error as Error).message },
{ error: 'Internal server error', details: error.message },
{ status: 500 }
)
}

View File

@@ -1,106 +0,0 @@
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 }
)
}
}

View File

@@ -1,48 +1,25 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebflowSitesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
export async function GET(request: NextRequest) {
try {
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 session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
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 getOAuthToken(session.user.id, 'webflow')
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 }
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
{ status: 404 }
)
}
@@ -73,11 +50,11 @@ export async function POST(request: Request) {
name: site.displayName || site.shortName || site.id,
}))
return NextResponse.json({ sites: formattedSites })
} catch (error) {
logger.error('Error processing Webflow sites request:', error)
return NextResponse.json({ sites: formattedSites }, { status: 200 })
} catch (error: any) {
logger.error('Error fetching Webflow sites', error)
return NextResponse.json(
{ error: 'Failed to retrieve Webflow sites', details: (error as Error).message },
{ error: 'Internal server error', details: error.message },
{ status: 500 }
)
}

View File

@@ -35,18 +35,19 @@
* 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 (validates seat availability)
* POST /api/v1/admin/organizations/:id/members - Add/update member in organization
* 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
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
* PATCH /api/v1/admin/subscriptions/:id - Update subscription
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'

View File

@@ -12,9 +12,6 @@
* 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.
*
@@ -32,7 +29,6 @@ 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 {
@@ -227,29 +223,6 @@ export const POST = withAdminAuthParams<RouteParams>(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,

View File

@@ -4,12 +4,26 @@
* Get organization seat analytics including member activity.
*
* Response: AdminSingleResponse<AdminSeatAnalytics>
*
* 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,
@@ -61,3 +75,122 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
return internalErrorResponse('Failed to get organization seats')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(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<string, unknown>) || {}
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')
}
})

View File

@@ -5,28 +5,28 @@
*
* Response: AdminSingleResponse<AdminSubscription>
*
* DELETE /api/v1/admin/subscriptions/[id]
* PATCH /api/v1/admin/subscriptions/[id]
*
* 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
* Update subscription details with optional side effects.
*
* Query Parameters:
* - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false)
* - reason?: string - Reason for cancellation (for audit logging)
* 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)
*
* Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean }
* Response: AdminSingleResponse<AdminSubscription & { sideEffects }>
*/
import { db } from '@sim/db'
import { subscription } from '@sim/db/schema'
import { member, subscription } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -43,6 +43,9 @@ 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<RouteParams>(async (_, context) => {
const { id: subscriptionId } = await context.params
@@ -66,13 +69,14 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
export const PATCH = withAdminAuthParams<RouteParams>(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)
@@ -83,70 +87,150 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Subscription')
}
if (existing.status === 'canceled') {
return badRequestResponse('Subscription is already canceled')
const updateData: Record<string, unknown> = {}
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.stripeSubscriptionId) {
return badRequestResponse('Subscription has no Stripe subscription ID')
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
}
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.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
}
// Immediate cancellation
await stripe.subscriptions.cancel(existing.stripeSubscriptionId, {
prorate: true,
invoice_now: true,
})
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<string, unknown>) || {}),
...body.metadata,
}
}
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
plan: existing.plan,
referenceId: existing.referenceId,
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,
reason,
})
return singleResponse({
success: true,
message: 'Subscription cancellation triggered. Webhook will complete cleanup.',
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
atPeriodEnd: false,
...toAdminSubscription(updated),
sideEffects,
warnings,
})
} catch (error) {
logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId })
return internalErrorResponse('Failed to cancel subscription')
logger.error('Admin API: Failed to update subscription', { error, subscriptionId })
return internalErrorResponse('Failed to update subscription')
}
})

View File

@@ -1,7 +1,5 @@
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')
@@ -15,14 +13,6 @@ export interface AuthResult {
}
export async function authenticateV1Request(request: NextRequest): Promise<AuthResult> {
if (isAuthDisabled) {
return {
authenticated: true,
userId: ANONYMOUS_USER_ID,
keyType: 'personal',
}
}
const apiKey = request.headers.get('x-api-key')
if (!apiKey) {

View File

@@ -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/feature-flags'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/environment'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { getModelPricing } from '@/providers/utils'

View File

@@ -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 { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -236,8 +236,9 @@ type AsyncExecutionParams = {
*/
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, input, triggerType } = params
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (!isTriggerDevEnabled) {
if (!useTrigger) {
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.' },

View File

@@ -80,6 +80,10 @@ export function VoiceInterface({
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
const isCallEndedRef = useRef(false)
useEffect(() => {
isCallEndedRef.current = false
}, [])
useEffect(() => {
currentStateRef.current = state
}, [state])
@@ -119,6 +123,8 @@ export function VoiceInterface({
}, [])
useEffect(() => {
if (isCallEndedRef.current) return
if (isPlayingAudio && state !== 'agent_speaking') {
clearResponseTimeout()
setState('agent_speaking')
@@ -139,6 +145,9 @@ export function VoiceInterface({
}
}
} else if (!isPlayingAudio && state === 'agent_speaking') {
// Don't unmute/restart if call has ended
if (isCallEndedRef.current) return
setState('idle')
setCurrentTranscript('')
@@ -226,6 +235,8 @@ export function VoiceInterface({
recognition.onstart = () => {}
recognition.onresult = (event: SpeechRecognitionEvent) => {
if (isCallEndedRef.current) return
const currentState = currentStateRef.current
if (isMutedRef.current || currentState !== 'listening') {
@@ -303,6 +314,8 @@ export function VoiceInterface({
}, [isSupported, onVoiceTranscript, setResponseTimeout])
const startListening = useCallback(() => {
if (isCallEndedRef.current) return
if (!isInitialized || isMuted || state !== 'idle') {
return
}
@@ -320,6 +333,9 @@ export function VoiceInterface({
}, [isInitialized, isMuted, state])
const stopListening = useCallback(() => {
// Don't process if call has ended
if (isCallEndedRef.current) return
setState('idle')
setCurrentTranscript('')
@@ -333,12 +349,15 @@ export function VoiceInterface({
}, [])
const handleInterrupt = useCallback(() => {
if (isCallEndedRef.current) return
if (state === 'agent_speaking') {
onInterrupt?.()
setState('listening')
setCurrentTranscript('')
setIsMuted(false)
isMutedRef.current = false
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = true
@@ -356,11 +375,22 @@ export function VoiceInterface({
}, [state, onInterrupt])
const handleCallEnd = useCallback(() => {
// Mark call as ended FIRST to prevent any effects from restarting recognition
isCallEndedRef.current = true
// Set muted to true to prevent auto-start effect from triggering
setIsMuted(true)
isMutedRef.current = true
setState('idle')
setCurrentTranscript('')
setIsMuted(false)
// Immediately disable audio tracks to stop listening
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = false
})
}
if (recognitionRef.current) {
try {
@@ -377,6 +407,8 @@ export function VoiceInterface({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isCallEndedRef.current) return
if (event.code === 'Space') {
event.preventDefault()
handleInterrupt()
@@ -388,6 +420,8 @@ export function VoiceInterface({
}, [handleInterrupt])
const toggleMute = useCallback(() => {
if (isCallEndedRef.current) return
if (state === 'agent_speaking') {
handleInterrupt()
return
@@ -395,6 +429,7 @@ export function VoiceInterface({
const newMutedState = !isMuted
setIsMuted(newMutedState)
isMutedRef.current = newMutedState
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
@@ -417,6 +452,8 @@ export function VoiceInterface({
}, [isSupported, setupSpeechRecognition, setupAudio])
useEffect(() => {
if (isCallEndedRef.current) return
if (isInitialized && !isMuted && state === 'idle') {
startListening()
}

View File

@@ -47,16 +47,12 @@ export function FileSelectorInput({
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
const normalizedCredentialId =
typeof connectedCredential === 'string'
@@ -79,8 +75,6 @@ export function FileSelectorInput({
projectId: (projectIdValue as string) || undefined,
planId: (planIdValue as string) || undefined,
teamId: (teamIdValue as string) || undefined,
siteId: (siteIdValue as string) || undefined,
collectionId: (collectionIdValue as string) || undefined,
})
}, [
subBlock,
@@ -90,8 +84,6 @@ export function FileSelectorInput({
projectIdValue,
planIdValue,
teamIdValue,
siteIdValue,
collectionIdValue,
])
const missingCredential = !normalizedCredentialId
@@ -105,10 +97,6 @@ export function FileSelectorInput({
!selectorResolution.context.projectId
const missingPlan =
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
const missingSite =
selectorResolution?.key === 'webflow.collections' && !selectorResolution.context.siteId
const missingCollection =
selectorResolution?.key === 'webflow.items' && !selectorResolution.context.collectionId
const disabledReason =
finalDisabled ||
@@ -117,8 +105,6 @@ export function FileSelectorInput({
missingDomain ||
missingProject ||
missingPlan ||
missingSite ||
missingCollection ||
!selectorResolution?.key
if (!selectorResolution?.key) {

View File

@@ -43,12 +43,14 @@ export function ProjectSelectorInput({
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const linearCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
// Derive provider from serviceId using OAuth config
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const isLinear = serviceId === 'linear'
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
@@ -63,6 +65,7 @@ export function ProjectSelectorInput({
})
// Jira/Discord upstream fields - use values from previewContextValues or store
const jiraCredential = connectedCredential
const domain = (jiraDomain as string) || ''
// Verify Jira credential belongs to current user; if not, treat as absent
@@ -81,11 +84,19 @@ export function ProjectSelectorInput({
const selectorResolution = useMemo(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl || undefined,
credentialId: (connectedCredential as string) || undefined,
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
domain,
teamId: (linearTeamId as string) || undefined,
})
}, [subBlock, workflowIdFromUrl, connectedCredential, domain, linearTeamId])
}, [
subBlock,
workflowIdFromUrl,
isLinear,
linearCredential,
jiraCredential,
domain,
linearTeamId,
])
const missingCredential = !selectorResolution?.context.credentialId

View File

@@ -47,16 +47,12 @@ export function FileSelectorInput({
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
const normalizedCredentialId =
typeof connectedCredential === 'string'
@@ -79,8 +75,6 @@ export function FileSelectorInput({
projectId: (projectIdValue as string) || undefined,
planId: (planIdValue as string) || undefined,
teamId: (teamIdValue as string) || undefined,
siteId: (siteIdValue as string) || undefined,
collectionId: (collectionIdValue as string) || undefined,
})
}, [
subBlock,
@@ -90,8 +84,6 @@ export function FileSelectorInput({
projectIdValue,
planIdValue,
teamIdValue,
siteIdValue,
collectionIdValue,
])
const missingCredential = !normalizedCredentialId
@@ -105,10 +97,6 @@ export function FileSelectorInput({
!selectorResolution?.context.projectId
const missingPlan =
selectorResolution?.key === 'microsoft.planner' && !selectorResolution?.context.planId
const missingSite =
selectorResolution?.key === 'webflow.collections' && !selectorResolution?.context.siteId
const missingCollection =
selectorResolution?.key === 'webflow.items' && !selectorResolution?.context.collectionId
const disabledReason =
finalDisabled ||
@@ -117,8 +105,6 @@ export function FileSelectorInput({
missingDomain ||
missingProject ||
missingPlan ||
missingSite ||
missingCollection ||
!selectorResolution?.key
if (!selectorResolution?.key) {

View File

@@ -579,10 +579,8 @@ const WorkflowContent = React.memo(() => {
const node = nodeIndex.get(id)
if (!node) return false
const blockParentId = blocks[id]?.data?.parentId
const dropParentId = containerAtPoint?.loopId
if (dropParentId !== blockParentId) return false
// If dropping outside containers, ignore blocks that are inside a container
if (!containerAtPoint && blocks[id]?.data?.parentId) return false
return true
})
.map(([id, block]) => {

View File

@@ -16,7 +16,6 @@ import {
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { useBrandConfig } from '@/lib/branding/branding'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -60,7 +59,6 @@ export function General({ onOpenChange }: GeneralProps) {
const isLoading = isProfileLoading || isSettingsLoading
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
const [isSuperUser, setIsSuperUser] = useState(false)
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
@@ -463,12 +461,10 @@ export function General({ onOpenChange }: GeneralProps) {
</div>
)}
{!isAuthDisabled && (
<div className='mt-auto flex items-center gap-[8px]'>
<Button onClick={handleSignOut}>Sign out</Button>
<Button onClick={() => setShowResetPasswordModal(true)}>Reset password</Button>
</div>
)}
<div className='mt-auto flex items-center gap-[8px]'>
<Button onClick={handleSignOut}>Sign out</Button>
<Button onClick={() => setShowResetPasswordModal(true)}>Reset password</Button>
</div>
{/* Password Reset Confirmation Modal */}
<Modal open={showResetPasswordModal} onOpenChange={setShowResetPasswordModal}>

View File

@@ -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/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -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/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { getUserRole } from '@/lib/workspaces/organization'
import {
ApiKeys,

View File

@@ -1,5 +1,5 @@
import { AgentIcon } from '@/components/icons'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'

View File

@@ -1,5 +1,5 @@
import { ChartBarIcon } from '@/components/icons'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockConfig, ParamType } from '@/blocks/types'
import type { ProviderId } from '@/providers/types'

View File

@@ -1,5 +1,5 @@
import { ShieldCheckIcon } from '@/components/icons'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import type { BlockConfig } from '@/blocks/types'
import { getHostedModels, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'

View File

@@ -1,5 +1,5 @@
import { ConnectIcon } from '@/components/icons'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { ProviderId } from '@/providers/types'
import {

View File

@@ -1,5 +1,5 @@
import { TranslateIcon } from '@/components/icons'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import { getHostedModels, getProviderIcon, providers } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'

View File

@@ -39,65 +39,19 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
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',
mode: 'advanced',
dependsOn: ['credential'],
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',
canonicalParamId: 'itemId',
placeholder: 'Enter item ID',
mode: 'advanced',
placeholder: 'ID of the item',
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
required: true,
},
@@ -154,17 +108,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
}
},
params: (params) => {
const {
credential,
fieldData,
siteId,
manualSiteId,
collectionId,
manualCollectionId,
itemId,
manualItemId,
...rest
} = params
const { credential, fieldData, ...rest } = params
let parsedFieldData: any | undefined
try {
@@ -175,46 +119,15 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
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':
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 }
return { ...baseParams, fieldData: parsedFieldData }
default:
return baseParams
}
@@ -224,15 +137,12 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
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' },
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' },
// 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
},
outputs: {
items: { type: 'json', description: 'Array of items (list operation)' },

View File

@@ -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/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface UnsubscribeOptions {

View File

@@ -1,4 +1,5 @@
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'
@@ -10,11 +11,11 @@ import { executeTool } from '@/tools'
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
vi.mock('@/lib/core/config/feature-flags', () => ({
isHosted: false,
isProd: false,
isDev: true,
isTest: false,
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),
getCostMultiplier: vi.fn().mockReturnValue(1),
isEmailVerificationEnabled: false,
isBillingEnabled: false,
@@ -64,6 +65,7 @@ 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
@@ -118,6 +120,7 @@ describe('AgentBlockHandler', () => {
loops: {},
} as SerializedWorkflow,
}
mockIsHosted.mockReturnValue(false)
mockGetProviderFromModel.mockReturnValue('mock-provider')
mockFetch.mockImplementation(() => {
@@ -549,6 +552,8 @@ 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.',

View File

@@ -987,18 +987,21 @@ export class AgentBlockHandler implements BlockHandler {
try {
const executionData = JSON.parse(executionDataHeader)
// If execution data contains full content, persist to memory
if (ctx && inputs && executionData.output?.content) {
const assistantMessage: Message = {
role: 'assistant',
content: executionData.output.content,
}
// Fire and forget - don't await
memoryService
.persistMemoryMessage(ctx, inputs, assistantMessage, block.id)
.catch((error) =>
logger.error('Failed to persist streaming response to memory:', error)
// If execution data contains content or tool calls, persist to memory
if (
ctx &&
inputs &&
(executionData.output?.content || executionData.output?.toolCalls?.list?.length)
) {
const toolCalls = executionData.output?.toolCalls?.list
const messages = this.buildMessagesForMemory(executionData.output.content, toolCalls)
// Fire and forget - don't await, persist all messages
Promise.all(
messages.map((message) =>
memoryService.persistMemoryMessage(ctx, inputs, message, block.id)
)
).catch((error) => logger.error('Failed to persist streaming response to memory:', error))
}
return {
@@ -1117,25 +1120,28 @@ export class AgentBlockHandler implements BlockHandler {
return
}
// Extract content from regular response
// Extract content and tool calls from regular response
const blockOutput = result as any
const content = blockOutput?.content
const toolCalls = blockOutput?.toolCalls?.list
if (!content || typeof content !== 'string') {
// Build messages to persist
const messages = this.buildMessagesForMemory(content, toolCalls)
if (messages.length === 0) {
return
}
const assistantMessage: Message = {
role: 'assistant',
content,
// Persist all messages
for (const message of messages) {
await memoryService.persistMemoryMessage(ctx, inputs, message, blockId)
}
await memoryService.persistMemoryMessage(ctx, inputs, assistantMessage, blockId)
logger.debug('Persisted assistant response to memory', {
workflowId: ctx.workflowId,
memoryType: inputs.memoryType,
conversationId: inputs.conversationId,
messageCount: messages.length,
})
} catch (error) {
logger.error('Failed to persist response to memory:', error)
@@ -1143,6 +1149,69 @@ export class AgentBlockHandler implements BlockHandler {
}
}
/**
* Builds messages for memory storage including tool calls and results
* Returns proper OpenAI-compatible message format:
* - Assistant message with tool_calls array (if tools were used)
* - Tool role messages with results (one per tool call)
* - Final assistant message with content (if present)
*/
private buildMessagesForMemory(
content: string | undefined,
toolCalls: any[] | undefined
): Message[] {
const messages: Message[] = []
if (toolCalls?.length) {
// Generate stable IDs for each tool call (only if not provided by provider)
// Use index to ensure uniqueness even for same tool name in same millisecond
const toolCallsWithIds = toolCalls.map((tc: any, index: number) => ({
...tc,
_stableId:
tc.id ||
`call_${tc.name}_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 7)}`,
}))
// Add assistant message with tool_calls
const formattedToolCalls = toolCallsWithIds.map((tc: any) => ({
id: tc._stableId,
type: 'function' as const,
function: {
name: tc.name,
arguments: tc.rawArguments || JSON.stringify(tc.arguments || {}),
},
}))
messages.push({
role: 'assistant',
content: null,
tool_calls: formattedToolCalls,
})
// Add tool result messages using the same stable IDs
for (const tc of toolCallsWithIds) {
const resultContent =
typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result || {})
messages.push({
role: 'tool',
content: resultContent,
tool_call_id: tc._stableId,
name: tc.name, // Store tool name for providers that need it (e.g., Google/Gemini)
})
}
}
// Add final assistant response if present
if (content && typeof content === 'string') {
messages.push({
role: 'assistant',
content,
})
}
return messages
}
private processProviderResponse(
response: any,
block: SerializedBlock,

View File

@@ -32,7 +32,7 @@ describe('Memory', () => {
})
describe('applySlidingWindow (message-based)', () => {
it('should keep last N conversation messages', () => {
it('should keep last N turns (turn = user message + assistant response)', () => {
const messages: Message[] = [
{ role: 'system', content: 'System prompt' },
{ role: 'user', content: 'Message 1' },
@@ -43,9 +43,10 @@ describe('Memory', () => {
{ role: 'assistant', content: 'Response 3' },
]
const result = (memoryService as any).applySlidingWindow(messages, '4')
// Limit to 2 turns: should keep turns 2 and 3
const result = (memoryService as any).applySlidingWindow(messages, '2')
expect(result.length).toBe(5)
expect(result.length).toBe(5) // system + 2 turns (4 messages)
expect(result[0].role).toBe('system')
expect(result[0].content).toBe('System prompt')
expect(result[1].content).toBe('Message 2')
@@ -113,19 +114,18 @@ describe('Memory', () => {
it('should preserve first system message and exclude it from token count', () => {
const messages: Message[] = [
{ role: 'system', content: 'A' }, // System message - always preserved
{ role: 'user', content: 'B' }, // ~1 token
{ role: 'assistant', content: 'C' }, // ~1 token
{ role: 'user', content: 'D' }, // ~1 token
{ role: 'user', content: 'B' }, // ~1 token (turn 1)
{ role: 'assistant', content: 'C' }, // ~1 token (turn 1)
{ role: 'user', content: 'D' }, // ~1 token (turn 2)
]
// Limit to 2 tokens - should fit system message + last 2 conversation messages (D, C)
// Limit to 2 tokens - fits turn 2 (D=1 token), but turn 1 (B+C=2 tokens) would exceed
const result = (memoryService as any).applySlidingWindowByTokens(messages, '2', 'gpt-4o')
// Should have: system message + 2 conversation messages = 3 total
expect(result.length).toBe(3)
// Should have: system message + turn 2 (1 message) = 2 total
expect(result.length).toBe(2)
expect(result[0].role).toBe('system') // First system message preserved
expect(result[1].content).toBe('C') // Second most recent conversation message
expect(result[2].content).toBe('D') // Most recent conversation message
expect(result[1].content).toBe('D') // Most recent turn
})
it('should process messages from newest to oldest', () => {
@@ -249,29 +249,29 @@ describe('Memory', () => {
})
describe('Token-based vs Message-based comparison', () => {
it('should produce different results for same message count limit', () => {
it('should produce different results based on turn limits vs token limits', () => {
const messages: Message[] = [
{ role: 'user', content: 'A' }, // Short message (~1 token)
{ role: 'user', content: 'A' }, // Short message (~1 token) - turn 1
{
role: 'assistant',
content: 'This is a much longer response that takes many more tokens',
}, // Long message (~15 tokens)
{ role: 'user', content: 'B' }, // Short message (~1 token)
}, // Long message (~15 tokens) - turn 1
{ role: 'user', content: 'B' }, // Short message (~1 token) - turn 2
]
// Message-based: last 2 messages
const messageResult = (memoryService as any).applySlidingWindow(messages, '2')
expect(messageResult.length).toBe(2)
// Turn-based with limit 1: keeps last turn only
const messageResult = (memoryService as any).applySlidingWindow(messages, '1')
expect(messageResult.length).toBe(1) // Only turn 2 (message B)
// Token-based: with limit of 10 tokens, might fit all 3 messages or just last 2
// Token-based: with limit of 10 tokens, fits turn 2 (1 token) but not turn 1 (~16 tokens)
const tokenResult = (memoryService as any).applySlidingWindowByTokens(
messages,
'10',
'gpt-4o'
)
// The long message should affect what fits
expect(tokenResult.length).toBeGreaterThanOrEqual(1)
// Both should only fit the last turn due to the long assistant message
expect(tokenResult.length).toBe(1)
})
})
})

View File

@@ -202,13 +202,51 @@ export class Memory {
const systemMessages = messages.filter((msg) => msg.role === 'system')
const conversationMessages = messages.filter((msg) => msg.role !== 'system')
const recentMessages = conversationMessages.slice(-limit)
// Group messages into conversation turns
// A turn = user message + any tool calls/results + assistant response
const turns = this.groupMessagesIntoTurns(conversationMessages)
// Take the last N turns
const recentTurns = turns.slice(-limit)
// Flatten back to messages
const recentMessages = recentTurns.flat()
const firstSystemMessage = systemMessages.length > 0 ? [systemMessages[0]] : []
return [...firstSystemMessage, ...recentMessages]
}
/**
* Groups messages into conversation turns.
* A turn starts with a user message and includes all subsequent messages
* until the next user message (tool calls, tool results, assistant response).
*/
private groupMessagesIntoTurns(messages: Message[]): Message[][] {
const turns: Message[][] = []
let currentTurn: Message[] = []
for (const msg of messages) {
if (msg.role === 'user') {
// Start a new turn
if (currentTurn.length > 0) {
turns.push(currentTurn)
}
currentTurn = [msg]
} else {
// Add to current turn (assistant, tool, etc.)
currentTurn.push(msg)
}
}
// Don't forget the last turn
if (currentTurn.length > 0) {
turns.push(currentTurn)
}
return turns
}
/**
* Apply token-based sliding window to limit conversation by token count
*
@@ -216,6 +254,11 @@ export class Memory {
* - For consistency with message-based sliding window, the first system message is preserved
* - System messages are excluded from the token count
* - This ensures system prompts are always available while limiting conversation history
*
* Turn handling:
* - Messages are grouped into turns (user + tool calls/results + assistant response)
* - Complete turns are added to stay within token limit
* - This prevents breaking tool call/result pairs
*/
private applySlidingWindowByTokens(
messages: Message[],
@@ -233,25 +276,31 @@ export class Memory {
const systemMessages = messages.filter((msg) => msg.role === 'system')
const conversationMessages = messages.filter((msg) => msg.role !== 'system')
// Group into turns to keep tool call/result pairs together
const turns = this.groupMessagesIntoTurns(conversationMessages)
const result: Message[] = []
let currentTokenCount = 0
// Add conversation messages from most recent backwards
for (let i = conversationMessages.length - 1; i >= 0; i--) {
const message = conversationMessages[i]
const messageTokens = getAccurateTokenCount(message.content, model)
// Add turns from most recent backwards
for (let i = turns.length - 1; i >= 0; i--) {
const turn = turns[i]
const turnTokens = turn.reduce(
(sum, msg) => sum + getAccurateTokenCount(msg.content || '', model),
0
)
if (currentTokenCount + messageTokens <= tokenLimit) {
result.unshift(message)
currentTokenCount += messageTokens
if (currentTokenCount + turnTokens <= tokenLimit) {
result.unshift(...turn)
currentTokenCount += turnTokens
} else if (result.length === 0) {
logger.warn('Single message exceeds token limit, including anyway', {
messageTokens,
logger.warn('Single turn exceeds token limit, including anyway', {
turnTokens,
tokenLimit,
messageRole: message.role,
turnMessages: turn.length,
})
result.unshift(message)
currentTokenCount += messageTokens
result.unshift(...turn)
currentTokenCount += turnTokens
break
} else {
// Token limit reached, stop processing
@@ -259,17 +308,20 @@ export class Memory {
}
}
// No need to remove orphaned messages - turns are already complete
const cleanedResult = result
logger.debug('Applied token-based sliding window', {
totalMessages: messages.length,
conversationMessages: conversationMessages.length,
includedMessages: result.length,
includedMessages: cleanedResult.length,
totalTokens: currentTokenCount,
tokenLimit,
})
// Preserve first system message and prepend to results (consistent with message-based window)
const firstSystemMessage = systemMessages.length > 0 ? [systemMessages[0]] : []
return [...firstSystemMessage, ...result]
return [...firstSystemMessage, ...cleanedResult]
}
/**
@@ -324,7 +376,7 @@ export class Memory {
// Count tokens used by system messages first
let systemTokenCount = 0
for (const msg of systemMessages) {
systemTokenCount += getAccurateTokenCount(msg.content, model)
systemTokenCount += getAccurateTokenCount(msg.content || '', model)
}
// Calculate remaining tokens available for conversation messages
@@ -339,30 +391,36 @@ export class Memory {
return systemMessages
}
// Group into turns to keep tool call/result pairs together
const turns = this.groupMessagesIntoTurns(conversationMessages)
const result: Message[] = []
let currentTokenCount = 0
for (let i = conversationMessages.length - 1; i >= 0; i--) {
const message = conversationMessages[i]
const messageTokens = getAccurateTokenCount(message.content, model)
for (let i = turns.length - 1; i >= 0; i--) {
const turn = turns[i]
const turnTokens = turn.reduce(
(sum, msg) => sum + getAccurateTokenCount(msg.content || '', model),
0
)
if (currentTokenCount + messageTokens <= remainingTokens) {
result.unshift(message)
currentTokenCount += messageTokens
if (currentTokenCount + turnTokens <= remainingTokens) {
result.unshift(...turn)
currentTokenCount += turnTokens
} else if (result.length === 0) {
logger.warn('Single message exceeds remaining context window, including anyway', {
messageTokens,
logger.warn('Single turn exceeds remaining context window, including anyway', {
turnTokens,
remainingTokens,
systemTokenCount,
messageRole: message.role,
turnMessages: turn.length,
})
result.unshift(message)
currentTokenCount += messageTokens
result.unshift(...turn)
currentTokenCount += turnTokens
break
} else {
logger.info('Auto-trimmed conversation history to fit context window', {
originalMessages: conversationMessages.length,
trimmedMessages: result.length,
originalTurns: turns.length,
trimmedTurns: turns.length - i - 1,
conversationTokens: currentTokenCount,
systemTokens: systemTokenCount,
totalTokens: currentTokenCount + systemTokenCount,
@@ -372,6 +430,7 @@ export class Memory {
}
}
// No need to remove orphaned messages - turns are already complete
return [...systemMessages, ...result]
}
@@ -638,7 +697,7 @@ export class Memory {
/**
* Validate inputs to prevent malicious data or performance issues
*/
private validateInputs(conversationId?: string, content?: string): void {
private validateInputs(conversationId?: string, content?: string | null): void {
if (conversationId) {
if (conversationId.length > 255) {
throw new Error('Conversation ID too long (max 255 characters)')

View File

@@ -37,10 +37,22 @@ export interface ToolInput {
}
export interface Message {
role: 'system' | 'user' | 'assistant'
content: string
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null
function_call?: any
tool_calls?: any[]
tool_calls?: ToolCallMessage[]
tool_call_id?: string
/** Tool name for tool role messages (used by providers like Google/Gemini) */
name?: string
}
export interface ToolCallMessage {
id: string
type: 'function'
function: {
name: string
arguments: string
}
}
export interface StreamingConfig {

View File

@@ -1,5 +1,5 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isHosted } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotKeysQuery')

View File

@@ -673,99 +673,6 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
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 {

View File

@@ -15,8 +15,6 @@ export interface SelectorResolutionArgs {
planId?: string
teamId?: string
knowledgeBaseId?: string
siteId?: string
collectionId?: string
}
const defaultContext: SelectorContext = {}
@@ -54,8 +52,6 @@ function buildBaseContext(
planId: args.planId,
teamId: args.teamId,
knowledgeBaseId: args.knowledgeBaseId,
siteId: args.siteId,
collectionId: args.collectionId,
...extra,
}
}
@@ -110,14 +106,6 @@ 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 }
}
@@ -171,8 +159,6 @@ 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 }
}

View File

@@ -23,9 +23,6 @@ export type SelectorKey =
| 'microsoft.planner'
| 'google.drive'
| 'knowledge.documents'
| 'webflow.sites'
| 'webflow.collections'
| 'webflow.items'
export interface SelectorOption {
id: string
@@ -46,8 +43,6 @@ export interface SelectorContext {
planId?: string
mimeType?: string
fileId?: string
siteId?: string
collectionId?: string
}
export interface SelectorQueryArgs {

View File

@@ -1,104 +0,0 @@
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<void> {
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,
},
}
}

View File

@@ -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/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
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,9 +42,7 @@ export function useSession(): SessionHookResult {
return ctx
}
export const useActiveOrganization = isBillingEnabled
? client.useActiveOrganization
: () => ({ data: undefined, isPending: false, error: null })
export const { useActiveOrganization } = client
export const useSubscription = () => {
return {

View File

@@ -38,19 +38,13 @@ import {
handleSubscriptionCreated,
handleSubscriptionDeleted,
} from '@/lib/billing/webhooks/subscription'
import { env } from '@/lib/core/config/env'
import {
isAuthDisabled,
isBillingEnabled,
isEmailVerificationEnabled,
isRegistrationDisabled,
} from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
import { isBillingEnabled, isEmailVerificationEnabled } from '@/lib/core/config/environment'
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')
@@ -276,7 +270,7 @@ export const auth = betterAuth({
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled)
if (ctx.path.startsWith('/sign-up') && isTruthy(env.DISABLE_REGISTRATION))
throw new Error('Registration is disabled, please contact your admin.')
if (
@@ -2093,6 +2087,14 @@ 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,
@@ -2191,11 +2193,6 @@ 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,

View File

@@ -1,10 +0,0 @@
/** 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

View File

@@ -1,4 +1 @@
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'

View File

@@ -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/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('UsageMonitor')

View File

@@ -9,7 +9,7 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
import { isProd } from '@/lib/core/config/feature-flags'
import { isProd } from '@/lib/core/config/environment'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -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/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -21,131 +21,6 @@ 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<RestoreProResult> {
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
@@ -534,6 +409,7 @@ 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)
@@ -552,10 +428,104 @@ export async function removeUserFromOrganization(
)
}
// If no remaining paid teams, try to restore personal Pro
if (!hasAnyPaidTeam) {
const restoreResult = await restoreUserProSubscription(userId)
billingActions.proRestored = restoreResult.restored
billingActions.usageRestored = restoreResult.usageRestored
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,
})
}
}
}
} catch (postRemoveError) {
logger.error('Post-removal personal Pro restore check failed', {

View File

@@ -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/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('StorageLimits')

View File

@@ -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/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('StorageTracking')

View File

@@ -1,9 +1,7 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { 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,
@@ -13,71 +11,6 @@ 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<number> {
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
*/
@@ -165,24 +98,12 @@ export async function handleSubscriptionDeleted(subscription: {
const totalOverage = await calculateSubscriptionOverage(subscription)
const stripe = requireStripeClient()
// Enterprise plans have no overages - reset usage and cleanup org
// Enterprise plans have no overages - just reset usage
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
}
@@ -288,32 +209,13 @@ 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 handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup
// We only need to handle overage billing and usage reset
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', {

View File

@@ -20,7 +20,6 @@ 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

View File

@@ -35,31 +35,6 @@ 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
*/

View File

@@ -1,5 +1,5 @@
import { getEnv } from '@/lib/core/config/env'
import { isProd } from '@/lib/core/config/feature-flags'
import { isProd } from '@/lib/core/config/environment'
/**
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL

View File

@@ -6,7 +6,7 @@ import {
} from '@sim/db/schema'
import { and, eq, or, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkflowExecutionLog } from '@/lib/logs/types'
import {
@@ -140,7 +140,9 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog):
alertConfig: alertConfig || undefined,
}
if (isTriggerDevEnabled) {
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (useTrigger) {
await workspaceNotificationDeliveryTask.trigger(payload)
logger.info(
`Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev`

View File

@@ -15,7 +15,7 @@ import {
maybeSendUsageThresholdEmail,
} from '@/lib/billing/core/usage'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { filterForDisplay } from '@/lib/core/utils/display-filters'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -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/feature-flags'
import { isTest } from '@/lib/core/config/environment'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -7,7 +7,7 @@ import {
} from '@sim/db/schema'
import { and, eq, gte, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
executeNotificationDelivery,
@@ -118,7 +118,9 @@ async function checkWorkflowInactivity(
alertConfig,
}
if (isTriggerDevEnabled) {
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (useTrigger) {
await workspaceNotificationDeliveryTask.trigger(payload)
} else {
void executeNotificationDelivery(payload).catch((error) => {

View File

@@ -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 { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { env, isTruthy } from '@/lib/core/config/env'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { createLogger } from '@/lib/logs/console/logger'
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
@@ -707,7 +707,9 @@ export async function queueWebhookExecution(
...(credentialId ? { credentialId } : {}),
}
if (isTriggerDevEnabled) {
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (useTrigger) {
const handle = await tasks.trigger('webhook-execution', payload)
logger.info(
`[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${

View File

@@ -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/feature-flags'
import { isDev, isHosted } from './lib/core/config/environment'
import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/core/security/csp'
const nextConfig: NextConfig = {

View File

@@ -4,7 +4,12 @@ import type { StreamingExecution } from '@/executor/types'
import { executeTool } from '@/tools'
import { getProviderDefaultModel, getProviderModels } from '../models'
import type { ProviderConfig, ProviderRequest, ProviderResponse, TimeSegment } from '../types'
import { prepareToolExecution, prepareToolsWithUsageControl, trackForcedToolUsage } from '../utils'
import {
prepareToolExecution,
prepareToolsWithUsageControl,
sanitizeMessagesForProvider,
trackForcedToolUsage,
} from '../utils'
const logger = createLogger('AnthropicProvider')
@@ -68,8 +73,12 @@ export const anthropicProvider: ProviderConfig = {
// Add remaining messages
if (request.messages) {
request.messages.forEach((msg) => {
// Sanitize messages to ensure proper tool call/result pairing
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
sanitizedMessages.forEach((msg) => {
if (msg.role === 'function') {
// Legacy function role format
messages.push({
role: 'user',
content: [
@@ -80,7 +89,41 @@ export const anthropicProvider: ProviderConfig = {
},
],
})
} else if (msg.role === 'tool') {
// Modern tool role format (OpenAI-compatible)
messages.push({
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: (msg as any).tool_call_id,
content: msg.content || '',
},
],
})
} else if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
// Modern tool_calls format (OpenAI-compatible)
const toolUseContent = msg.tool_calls.map((tc: any) => ({
type: 'tool_use',
id: tc.id,
name: tc.function?.name || tc.name,
input:
typeof tc.function?.arguments === 'string'
? (() => {
try {
return JSON.parse(tc.function.arguments)
} catch {
return {}
}
})()
: tc.function?.arguments || tc.arguments || {},
}))
messages.push({
role: 'assistant',
content: toolUseContent,
})
} else if (msg.function_call) {
// Legacy function_call format
const toolUseId = `${msg.function_call.name}-${Date.now()}`
messages.push({
role: 'assistant',
@@ -490,9 +533,14 @@ ${fieldDescriptions}
}
}
// Use the original tool use ID from the API response
const toolUseId = toolUse.id || generateToolUseId(toolName)
toolCalls.push({
id: toolUseId,
name: toolName,
arguments: toolParams,
rawArguments: JSON.stringify(toolArgs),
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,
@@ -501,7 +549,6 @@ ${fieldDescriptions}
})
// Add the tool call and result to messages (both success and failure)
const toolUseId = generateToolUseId(toolName)
currentMessages.push({
role: 'assistant',
@@ -840,9 +887,14 @@ ${fieldDescriptions}
}
}
// Use the original tool use ID from the API response
const toolUseId = toolUse.id || generateToolUseId(toolName)
toolCalls.push({
id: toolUseId,
name: toolName,
arguments: toolParams,
rawArguments: JSON.stringify(toolArgs),
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,
@@ -851,7 +903,6 @@ ${fieldDescriptions}
})
// Add the tool call and result to messages (both success and failure)
const toolUseId = generateToolUseId(toolName)
currentMessages.push({
role: 'assistant',

View File

@@ -12,6 +12,7 @@ import type {
import {
prepareToolExecution,
prepareToolsWithUsageControl,
sanitizeMessagesForProvider,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
@@ -120,9 +121,10 @@ export const azureOpenAIProvider: ProviderConfig = {
})
}
// Add remaining messages
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
if (request.messages) {
allMessages.push(...request.messages)
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
allMessages.push(...sanitizedMessages)
}
// Transform tools to Azure OpenAI format if provided
@@ -417,8 +419,10 @@ export const azureOpenAIProvider: ProviderConfig = {
}
toolCalls.push({
id: toolCall.id,
name: toolName,
arguments: toolParams,
rawArguments: toolCall.function.arguments,
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,

View File

@@ -11,6 +11,7 @@ import type {
import {
prepareToolExecution,
prepareToolsWithUsageControl,
sanitizeMessagesForProvider,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
@@ -86,9 +87,10 @@ export const cerebrasProvider: ProviderConfig = {
})
}
// Add remaining messages
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
if (request.messages) {
allMessages.push(...request.messages)
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
allMessages.push(...sanitizedMessages)
}
// Transform tools to Cerebras format if provided
@@ -323,8 +325,10 @@ export const cerebrasProvider: ProviderConfig = {
}
toolCalls.push({
id: toolCall.id,
name: toolName,
arguments: toolParams,
rawArguments: toolCall.function.arguments,
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,

View File

@@ -11,6 +11,7 @@ import type {
import {
prepareToolExecution,
prepareToolsWithUsageControl,
sanitizeMessagesForProvider,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
@@ -84,9 +85,10 @@ export const deepseekProvider: ProviderConfig = {
})
}
// Add remaining messages
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
if (request.messages) {
allMessages.push(...request.messages)
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
allMessages.push(...sanitizedMessages)
}
// Transform tools to OpenAI format if provided
@@ -323,8 +325,10 @@ export const deepseekProvider: ProviderConfig = {
}
toolCalls.push({
id: toolCall.id,
name: toolName,
arguments: toolParams,
rawArguments: toolCall.function.arguments,
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,

View File

@@ -10,6 +10,7 @@ import type {
import {
prepareToolExecution,
prepareToolsWithUsageControl,
sanitizeMessagesForProvider,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
@@ -552,9 +553,14 @@ export const googleProvider: ProviderConfig = {
}
}
// Generate a unique ID for this tool call (Google doesn't provide one)
const toolCallId = `call_${toolName}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
toolCalls.push({
id: toolCallId,
name: toolName,
arguments: toolParams,
rawArguments: JSON.stringify(toolArgs),
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,
@@ -1087,9 +1093,10 @@ function convertToGeminiFormat(request: ProviderRequest): {
contents.push({ role: 'user', parts: [{ text: request.context }] })
}
// Process messages
// Process messages (sanitized to ensure proper tool call/result pairing)
if (request.messages && request.messages.length > 0) {
for (const message of request.messages) {
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
for (const message of sanitizedMessages) {
if (message.role === 'system') {
// Add to system instruction
if (!systemInstruction) {
@@ -1119,10 +1126,28 @@ function convertToGeminiFormat(request: ProviderRequest): {
contents.push({ role: 'model', parts: functionCalls })
}
} else if (message.role === 'tool') {
// Convert tool response (Gemini only accepts user/model roles)
// Convert tool response to Gemini's functionResponse format
// Gemini uses 'user' role for function responses
const functionName = (message as any).name || 'function'
let responseData: any
try {
responseData =
typeof message.content === 'string' ? JSON.parse(message.content) : message.content
} catch {
responseData = { result: message.content }
}
contents.push({
role: 'user',
parts: [{ text: `Function result: ${message.content}` }],
parts: [
{
functionResponse: {
name: functionName,
response: responseData,
},
},
],
})
}
}

View File

@@ -11,6 +11,7 @@ import type {
import {
prepareToolExecution,
prepareToolsWithUsageControl,
sanitizeMessagesForProvider,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
@@ -75,9 +76,10 @@ export const groqProvider: ProviderConfig = {
})
}
// Add remaining messages
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
if (request.messages) {
allMessages.push(...request.messages)
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
allMessages.push(...sanitizedMessages)
}
// Transform tools to function format if provided
@@ -296,8 +298,10 @@ export const groqProvider: ProviderConfig = {
}
toolCalls.push({
id: toolCall.id,
name: toolName,
arguments: toolParams,
rawArguments: toolCall.function.arguments,
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,

View File

@@ -1,4 +1,4 @@
import { getCostMultiplier } from '@/lib/core/config/feature-flags'
import { getCostMultiplier } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
import type { StreamingExecution } from '@/executor/types'
import type { ProviderRequest, ProviderResponse } from '@/providers/types'

Some files were not shown because too many files have changed in this diff Show More