Compare commits

...

5 Commits

Author SHA1 Message Date
Cursor Agent
d2cebb5411 fix(toolbar): restore search icon on blur when input is empty 2026-01-09 03:45:26 +00:00
Vikhyath Mondreti
e5c95093f6 improvement(autoconnect): click to add paths also autoconnect (#2737) 2026-01-08 18:16:15 -08:00
Waleed
b87af80bff feat(i18n): update translations (#2732)
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
2026-01-08 18:14:00 -08:00
Vikhyath Mondreti
c2180bf8a0 improvement(enterprise): feature flagging + runtime checks consolidation (#2730)
* improvement(enterprise): enterprise checks code consolidation

* update docs

* revert isHosted check

* add unique index to prevent multiple orgs per user

* address greptile comments

* ui bug
2026-01-08 13:53:22 -08:00
Waleed
fdac4314d2 fix(chat): update stream to respect all output select objects (#2729) 2026-01-08 11:54:07 -08:00
40 changed files with 10715 additions and 158 deletions

View File

@@ -0,0 +1,76 @@
---
title: Enterprise
description: Enterprise-Funktionen für Organisationen mit erweiterten
Sicherheits- und Compliance-Anforderungen
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen.
---
## Bring Your Own Key (BYOK)
Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim Studio.
### Unterstützte Anbieter
| Anbieter | Verwendung |
|----------|-------|
| OpenAI | Knowledge Base-Embeddings, Agent-Block |
| Anthropic | Agent-Block |
| Google | Agent-Block |
| Mistral | Knowledge Base OCR |
### Einrichtung
1. Navigieren Sie zu **Einstellungen** → **BYOK** in Ihrem Workspace
2. Klicken Sie auf **Schlüssel hinzufügen** für Ihren Anbieter
3. Geben Sie Ihren API-Schlüssel ein und speichern Sie
<Callout type="warn">
BYOK-Schlüssel werden verschlüsselt gespeichert. Nur Organisationsadministratoren und -inhaber können Schlüssel verwalten.
</Callout>
Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim Studio. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück.
---
## Single Sign-On (SSO)
Enterprise-Authentifizierung mit SAML 2.0- und OIDC-Unterstützung für zentralisiertes Identitätsmanagement.
### Unterstützte Anbieter
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Jeder SAML 2.0- oder OIDC-Anbieter
### Einrichtung
1. Navigieren Sie zu **Einstellungen** → **SSO** in Ihrem Workspace
2. Wählen Sie Ihren Identitätsanbieter
3. Konfigurieren Sie die Verbindung mithilfe der Metadaten Ihres IdP
4. Aktivieren Sie SSO für Ihre Organisation
<Callout type="info">
Sobald SSO aktiviert ist, authentifizieren sich Teammitglieder über Ihren Identitätsanbieter anstelle von E-Mail/Passwort.
</Callout>
---
## Self-Hosted
Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgebungsvariablen aktiviert werden:
| Variable | Beschreibung |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger |
<Callout type="warn">
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
</Callout>

View File

@@ -0,0 +1,75 @@
---
title: Enterprise
description: Enterprise features for organizations with advanced security and compliance requirements
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
---
## Bring Your Own Key (BYOK)
Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
### Supported Providers
| Provider | Usage |
|----------|-------|
| OpenAI | Knowledge Base embeddings, Agent block |
| Anthropic | Agent block |
| Google | Agent block |
| Mistral | Knowledge Base OCR |
### Setup
1. Navigate to **Settings** → **BYOK** in your workspace
2. Click **Add Key** for your provider
3. Enter your API key and save
<Callout type="warn">
BYOK keys are encrypted at rest. Only organization admins and owners can manage keys.
</Callout>
When configured, workflows use your key instead of Sim Studio's hosted keys. If removed, workflows automatically fall back to hosted keys.
---
## Single Sign-On (SSO)
Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management.
### Supported Providers
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Any SAML 2.0 or OIDC provider
### Setup
1. Navigate to **Settings** → **SSO** in your workspace
2. Choose your identity provider
3. Configure the connection using your IdP's metadata
4. Enable SSO for your organization
<Callout type="info">
Once SSO is enabled, team members authenticate through your identity provider instead of email/password.
</Callout>
---
## Self-Hosted
For self-hosted deployments, enterprise features can be enabled via environment variables:
| Variable | Description |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
<Callout type="warn">
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
</Callout>

View File

@@ -15,6 +15,7 @@
"permissions",
"sdks",
"self-hosting",
"./enterprise/index",
"./keyboard-shortcuts/index"
],
"defaultOpen": false

View File

@@ -0,0 +1,76 @@
---
title: Enterprise
description: Funciones enterprise para organizaciones con requisitos avanzados
de seguridad y cumplimiento
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión.
---
## Bring Your Own Key (BYOK)
Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim Studio.
### Proveedores compatibles
| Proveedor | Uso |
|----------|-------|
| OpenAI | Embeddings de base de conocimiento, bloque Agent |
| Anthropic | Bloque Agent |
| Google | Bloque Agent |
| Mistral | OCR de base de conocimiento |
### Configuración
1. Navega a **Configuración** → **BYOK** en tu espacio de trabajo
2. Haz clic en **Añadir clave** para tu proveedor
3. Introduce tu clave API y guarda
<Callout type="warn">
Las claves BYOK están cifradas en reposo. Solo los administradores y propietarios de la organización pueden gestionar las claves.
</Callout>
Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim Studio. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas.
---
## Single Sign-On (SSO)
Autenticación enterprise con soporte SAML 2.0 y OIDC para gestión centralizada de identidades.
### Proveedores compatibles
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Cualquier proveedor SAML 2.0 u OIDC
### Configuración
1. Navega a **Configuración** → **SSO** en tu espacio de trabajo
2. Elige tu proveedor de identidad
3. Configura la conexión usando los metadatos de tu IdP
4. Activa SSO para tu organización
<Callout type="info">
Una vez que SSO está activado, los miembros del equipo se autentican a través de tu proveedor de identidad en lugar de correo electrónico/contraseña.
</Callout>
---
## Self-Hosted
Para implementaciones self-hosted, las funciones enterprise se pueden activar mediante variables de entorno:
| Variable | Descripción |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Inicio de sesión único con SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Grupos de sondeo para activadores de correo electrónico |
<Callout type="warn">
BYOK solo está disponible en Sim Studio alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno.
</Callout>

View File

@@ -0,0 +1,76 @@
---
title: Entreprise
description: Fonctionnalités entreprise pour les organisations ayant des
exigences avancées en matière de sécurité et de conformité
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Entreprise fournit des fonctionnalités avancées pour les organisations ayant des exigences renforcées en matière de sécurité, de conformité et de gestion.
---
## Apportez votre propre clé (BYOK)
Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim Studio.
### Fournisseurs pris en charge
| Fournisseur | Utilisation |
|----------|-------|
| OpenAI | Embeddings de base de connaissances, bloc Agent |
| Anthropic | Bloc Agent |
| Google | Bloc Agent |
| Mistral | OCR de base de connaissances |
### Configuration
1. Accédez à **Paramètres** → **BYOK** dans votre espace de travail
2. Cliquez sur **Ajouter une clé** pour votre fournisseur
3. Saisissez votre clé API et enregistrez
<Callout type="warn">
Les clés BYOK sont chiffrées au repos. Seuls les administrateurs et propriétaires de l'organisation peuvent gérer les clés.
</Callout>
Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim Studio. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées.
---
## Authentification unique (SSO)
Authentification entreprise avec prise en charge de SAML 2.0 et OIDC pour une gestion centralisée des identités.
### Fournisseurs pris en charge
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Tout fournisseur SAML 2.0 ou OIDC
### Configuration
1. Accédez à **Paramètres** → **SSO** dans votre espace de travail
2. Choisissez votre fournisseur d'identité
3. Configurez la connexion en utilisant les métadonnées de votre IdP
4. Activez le SSO pour votre organisation
<Callout type="info">
Une fois le SSO activé, les membres de l'équipe s'authentifient via votre fournisseur d'identité au lieu d'utiliser un email/mot de passe.
</Callout>
---
## Auto-hébergé
Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent être activées via des variables d'environnement :
| Variable | Description |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail |
<Callout type="warn">
BYOK est uniquement disponible sur Sim Studio hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement.
</Callout>

View File

@@ -0,0 +1,75 @@
---
title: エンタープライズ
description: 高度なセキュリティとコンプライアンス要件を持つ組織向けのエンタープライズ機能
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
---
## Bring Your Own Key (BYOK)
Sim Studioのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
### 対応プロバイダー
| プロバイダー | 用途 |
|----------|-------|
| OpenAI | ナレッジベースの埋め込み、エージェントブロック |
| Anthropic | エージェントブロック |
| Google | エージェントブロック |
| Mistral | ナレッジベースOCR |
### セットアップ
1. ワークスペースの**設定** → **BYOK**に移動します
2. プロバイダーの**キーを追加**をクリックします
3. APIキーを入力して保存します
<Callout type="warn">
BYOKキーは保存時に暗号化されます。組織の管理者とオーナーのみがキーを管理できます。
</Callout>
設定すると、ワークフローはSim Studioのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
---
## シングルサインオン (SSO)
集中型IDマネジメントのためのSAML 2.0およびOIDCサポートを備えたエンタープライズ認証。
### 対応プロバイダー
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- SAML 2.0またはOIDCに対応する任意のプロバイダー
### セットアップ
1. ワークスペースの**設定** → **SSO**に移動します
2. IDプロバイダーを選択します
3. IdPのメタデータを使用して接続を設定します
4. 組織のSSOを有効にします
<Callout type="info">
SSOを有効にすると、チームメンバーはメール/パスワードの代わりにIDプロバイダーを通じて認証します。
</Callout>
---
## セルフホスト
セルフホストデプロイメントの場合、エンタープライズ機能は環境変数を介して有効にできます:
| 変数 | 説明 |
|----------|-------------|
| `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン |
| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ |
<Callout type="warn">
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
</Callout>

View File

@@ -0,0 +1,75 @@
---
title: 企业版
description: 为具有高级安全性和合规性需求的组织提供企业级功能
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
---
## 自带密钥BYOK
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim Studio 托管的密钥。
### 支持的服务商
| Provider | Usage |
|----------|-------|
| OpenAI | 知识库嵌入、Agent 模块 |
| Anthropic | Agent 模块 |
| Google | Agent 模块 |
| Mistral | 知识库 OCR |
### 配置方法
1. 在您的工作区进入 **设置** → **BYOK**
2. 为您的服务商点击 **添加密钥**
3. 输入您的 API 密钥并保存
<Callout type="warn">
BYOK 密钥静态加密存储。仅组织管理员和所有者可管理密钥。
</Callout>
配置后,工作流将使用您的密钥而非 Sim Studio 托管密钥。如移除,工作流会自动切换回托管密钥。
---
## 单点登录SSO
企业级身份认证,支持 SAML 2.0 和 OIDC实现集中式身份管理。
### 支持的服务商
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- 任何 SAML 2.0 或 OIDC 服务商
### 配置方法
1. 在您的工作区进入 **设置** → **SSO**
2. 选择您的身份提供商
3. 使用 IdP 元数据配置连接
4. 为您的组织启用 SSO
<Callout type="info">
启用 SSO 后,团队成员将通过您的身份提供商进行身份验证,而不再使用邮箱/密码。
</Callout>
---
## 自主部署
对于自主部署场景,可通过环境变量启用企业功能:
| 变量 | 描述 |
|----------|-------------|
| `SSO_ENABLED``NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 |
| `CREDENTIAL_SETS_ENABLED``NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 |
<Callout type="warn">
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。
</Callout>

View File

@@ -50308,3 +50308,30 @@ checksums:
content/68: ba6b5020ed971cd7ffc7f0423650dfbf
content/69: b3f310d5ef115bea5a8b75bf25d7ea9a
content/70: 0362be478aa7ba4b6d1ebde0bd83e83a
f5bc5f89ed66818f4c485c554bf26eea:
meta/title: c70474271708e5b27392fde87462fa26
meta/description: 7b47db7fbb818c180b99354b912a72b3
content/0: 232be69c8f3053a40f695f9c9dcb3f2e
content/1: a4a62a6e782e18bd863546dfcf2aec1c
content/2: 51adf33450cab2ef392e93147386647c
content/3: ada515cf6e2e0f9d3f57f720f79699d3
content/4: d5e8b9f64d855675588845dc4124c491
content/5: 3acf1f0551f6097ca6159e66f5c8da1a
content/6: 6a6e277ded1a063ec2c2067abb519088
content/7: 6debcd334c3310480cbe6feab87f37b5
content/8: 0e3372052a2b3a1c43d853d6ed269d69
content/9: 90063613714128f4e61e9588e2d2c735
content/10: 182154179fe2a8b6b73fde0d04e0bf4c
content/11: 51adf33450cab2ef392e93147386647c
content/12: 73c3e8a5d36d6868fdb455fcb3d6074c
content/13: 30cd8f1d6197bce560a091ba19d0392a
content/14: 3acf1f0551f6097ca6159e66f5c8da1a
content/15: 997deef758698d207be9382c45301ad6
content/16: 6debcd334c3310480cbe6feab87f37b5
content/17: e26c8c2dffd70baef0253720c1511886
content/18: a99eba53979531f1c974cf653c346909
content/19: 51adf33450cab2ef392e93147386647c
content/20: ca3ec889fb218b8b130959ff04baa659
content/21: 306617201cf63b42f09bb72c9722e048
content/22: 4b48ba3f10b043f74b70edeb4ad87080
content/23: c8531bd570711abc1963d8b5dcf9deef

View File

@@ -1,7 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { auth, getSession } from '@/lib/auth'
import { hasSSOAccess } from '@/lib/billing'
import { env } from '@/lib/core/config/env'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
@@ -63,10 +64,22 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
export async function POST(request: NextRequest) {
try {
// SSO plugin must be enabled in Better Auth
if (!env.SSO_ENABLED) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
}
// Check plan access (enterprise) or env var override
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const hasAccess = await hasSSOAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
}
const rawBody = await request.json()
const parseResult = ssoRegistrationSchema.safeParse(rawBody)

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -45,6 +46,15 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id, invitationId } = await params
try {

View File

@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -47,6 +48,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -69,6 +79,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {
@@ -178,6 +197,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const { searchParams } = new URL(req.url)
const invitationId = searchParams.get('invitationId')

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMembers')
@@ -39,6 +40,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -110,6 +120,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const { searchParams } = new URL(req.url)
const memberId = searchParams.get('memberId')

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSet')
@@ -49,6 +50,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -66,6 +76,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {
@@ -129,6 +148,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {

View File

@@ -5,6 +5,7 @@ import { and, count, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSets')
@@ -22,6 +23,15 @@ export async function GET(req: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { searchParams } = new URL(req.url)
const organizationId = searchParams.get('organizationId')
@@ -85,6 +95,15 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
try {
const body = await req.json()
const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body)

View File

@@ -0,0 +1,199 @@
/**
* Admin BYOK Keys API
*
* GET /api/v1/admin/byok
* List all BYOK keys with optional filtering.
*
* Query Parameters:
* - organizationId?: string - Filter by organization ID (finds all workspaces billed to this org)
* - workspaceId?: string - Filter by specific workspace ID
*
* Response: { data: AdminBYOKKey[], pagination: PaginationMeta }
*
* DELETE /api/v1/admin/byok
* Delete BYOK keys for an organization or workspace.
* Used when an enterprise plan churns to clean up BYOK keys.
*
* Query Parameters:
* - organizationId: string - Delete all BYOK keys for workspaces billed to this org
* - workspaceId?: string - Delete keys for a specific workspace only (optional)
*
* Response: { success: true, deletedCount: number, workspacesAffected: string[] }
*/
import { db } from '@sim/db'
import { user, workspace, workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminBYOKAPI')
export interface AdminBYOKKey {
id: string
workspaceId: string
workspaceName: string
organizationId: string
providerId: string
createdAt: string
createdByUserId: string | null
createdByEmail: string | null
}
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
const workspaceId = url.searchParams.get('workspaceId')
try {
let workspaceIds: string[] = []
if (workspaceId) {
workspaceIds = [workspaceId]
} else if (organizationId) {
const workspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.billedAccountUserId, organizationId))
workspaceIds = workspaces.map((w) => w.id)
}
const query = db
.select({
id: workspaceBYOKKeys.id,
workspaceId: workspaceBYOKKeys.workspaceId,
workspaceName: workspace.name,
organizationId: workspace.billedAccountUserId,
providerId: workspaceBYOKKeys.providerId,
createdAt: workspaceBYOKKeys.createdAt,
createdByUserId: workspaceBYOKKeys.createdBy,
createdByEmail: user.email,
})
.from(workspaceBYOKKeys)
.innerJoin(workspace, eq(workspaceBYOKKeys.workspaceId, workspace.id))
.leftJoin(user, eq(workspaceBYOKKeys.createdBy, user.id))
let keys
if (workspaceIds.length > 0) {
keys = await query.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
} else {
keys = await query
}
const formattedKeys: AdminBYOKKey[] = keys.map((k) => ({
id: k.id,
workspaceId: k.workspaceId,
workspaceName: k.workspaceName,
organizationId: k.organizationId,
providerId: k.providerId,
createdAt: k.createdAt.toISOString(),
createdByUserId: k.createdByUserId,
createdByEmail: k.createdByEmail,
}))
logger.info('Admin API: Listed BYOK keys', {
organizationId,
workspaceId,
count: formattedKeys.length,
})
return singleResponse({
data: formattedKeys,
pagination: {
total: formattedKeys.length,
limit: formattedKeys.length,
offset: 0,
hasMore: false,
},
})
} catch (error) {
logger.error('Admin API: Failed to list BYOK keys', { error, organizationId, workspaceId })
return internalErrorResponse('Failed to list BYOK keys')
}
})
export const DELETE = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
const workspaceId = url.searchParams.get('workspaceId')
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
if (!organizationId && !workspaceId) {
return badRequestResponse('Either organizationId or workspaceId is required')
}
try {
let workspaceIds: string[] = []
if (workspaceId) {
workspaceIds = [workspaceId]
} else if (organizationId) {
const workspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.billedAccountUserId, organizationId))
workspaceIds = workspaces.map((w) => w.id)
}
if (workspaceIds.length === 0) {
logger.info('Admin API: No workspaces found for BYOK cleanup', {
organizationId,
workspaceId,
})
return singleResponse({
success: true,
deletedCount: 0,
workspacesAffected: [],
message: 'No workspaces found for the given organization/workspace ID',
})
}
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(workspaceBYOKKeys)
.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
const totalToDelete = Number(countResult[0]?.count ?? 0)
if (totalToDelete === 0) {
logger.info('Admin API: No BYOK keys to delete', {
organizationId,
workspaceId,
workspaceIds,
})
return singleResponse({
success: true,
deletedCount: 0,
workspacesAffected: [],
message: 'No BYOK keys found for the specified workspaces',
})
}
await db.delete(workspaceBYOKKeys).where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
logger.info('Admin API: Deleted BYOK keys', {
organizationId,
workspaceId,
workspaceIds,
deletedCount: totalToDelete,
reason,
})
return singleResponse({
success: true,
deletedCount: totalToDelete,
workspacesAffected: workspaceIds,
reason,
})
} catch (error) {
logger.error('Admin API: Failed to delete BYOK keys', { error, organizationId, workspaceId })
return internalErrorResponse('Failed to delete BYOK keys')
}
})

View File

@@ -51,6 +51,10 @@
* 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)
*
* BYOK Keys:
* GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
* DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'

View File

@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -56,6 +58,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let byokEnabled = true
if (isHosted) {
byokEnabled = await isEnterpriseOrgAdminOrOwner(userId)
}
if (!byokEnabled) {
return NextResponse.json({ keys: [], byokEnabled: false })
}
const byokKeys = await db
.select({
id: workspaceBYOKKeys.id,
@@ -97,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
)
return NextResponse.json({ keys: formattedKeys })
return NextResponse.json({ keys: formattedKeys, byokEnabled: true })
} catch (error: unknown) {
logger.error(`[${requestId}] BYOK keys GET error`, error)
return NextResponse.json(
@@ -120,6 +131,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
if (isHosted) {
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
if (!canManageBYOK) {
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
return NextResponse.json(
{
error:
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
},
{ status: 403 }
)
}
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(
@@ -220,6 +245,20 @@ export async function DELETE(
const userId = session.user.id
if (isHosted) {
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
if (!canManageBYOK) {
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
return NextResponse.json(
{
error:
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
},
{ status: 403 }
)
}
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(

View File

@@ -473,6 +473,7 @@ export function Chat() {
/**
* Processes streaming response from workflow execution
* Reads the stream chunk by chunk and updates the message content in real-time
* When the final event arrives, extracts any additional selected outputs (model, tokens, toolCalls)
* @param stream - ReadableStream containing the workflow execution response
* @param responseMessageId - ID of the message to update with streamed content
*/
@@ -529,6 +530,35 @@ export function Chat() {
return
}
if (
selectedOutputs.length > 0 &&
'logs' in result &&
Array.isArray(result.logs) &&
activeWorkflowId
) {
const additionalOutputs: string[] = []
for (const outputId of selectedOutputs) {
const blockId = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockId)
if (path === 'content') continue
const outputValue = extractOutputFromLogs(result.logs as BlockLog[], outputId)
if (outputValue !== undefined) {
const formattedValue =
typeof outputValue === 'string' ? outputValue : JSON.stringify(outputValue)
if (formattedValue) {
additionalOutputs.push(`**${path}:** ${formattedValue}`)
}
}
}
if (additionalOutputs.length > 0) {
appendMessageContent(responseMessageId, `\n\n${additionalOutputs.join('\n\n')}`)
}
}
finalizeMessageStream(responseMessageId)
} else if (contentChunk) {
accumulatedContent += contentChunk
@@ -552,7 +582,7 @@ export function Chat() {
focusInput(100)
}
},
[appendMessageContent, finalizeMessageStream, focusInput]
[appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId]
)
/**
@@ -564,7 +594,6 @@ export function Chat() {
if (!result || !activeWorkflowId) return
if (typeof result !== 'object') return
// Handle streaming response
if ('stream' in result && result.stream instanceof ReadableStream) {
const responseMessageId = crypto.randomUUID()
addMessage({
@@ -578,7 +607,6 @@ export function Chat() {
return
}
// Handle success with logs
if ('success' in result && result.success && 'logs' in result && Array.isArray(result.logs)) {
selectedOutputs
.map((outputId) => extractOutputFromLogs(result.logs as BlockLog[], outputId))
@@ -596,7 +624,6 @@ export function Chat() {
return
}
// Handle error response
if ('success' in result && !result.success) {
const errorMessage =
'error' in result && typeof result.error === 'string'
@@ -622,7 +649,6 @@ export function Chat() {
const sentMessage = chatMessage.trim()
// Update prompt history (only if new unique message)
if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) {
setPromptHistory((prev) => [...prev, sentMessage])
}
@@ -631,10 +657,8 @@ export function Chat() {
const conversationId = getConversationId(activeWorkflowId)
try {
// Process file attachments
const attachmentsWithData = await processFileAttachments(chatFiles)
// Add user message
const messageContent =
sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : '')
addMessage({
@@ -644,7 +668,6 @@ export function Chat() {
attachments: attachmentsWithData,
})
// Prepare workflow input
const workflowInput: {
input: string
conversationId: string
@@ -667,13 +690,11 @@ export function Chat() {
}
}
// Clear input and files
setChatMessage('')
clearFiles()
clearErrors()
focusInput(10)
// Execute workflow
const result = await handleRunWorkflow(workflowInput)
handleWorkflowResponse(result)
} catch (error) {

View File

@@ -305,12 +305,15 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
/**
* Handle search input blur.
*
* We intentionally keep search mode active after blur so that ArrowUp/Down
* Deactivates search mode when the input is empty, restoring the search icon.
* When there is search text, we keep search mode active so that ArrowUp/Down
* navigation continues to work after the first move from the search input
* into the triggers/blocks list (e.g. when initiated via Mod+F).
*/
const handleSearchBlur = () => {
// No-op by design
if (!searchQuery.trim()) {
setIsSearchActive(false)
}
}
/**

View File

@@ -885,6 +885,7 @@ export function useWorkflowExecution() {
const activeBlocksSet = new Set<string>()
const streamedContent = new Map<string, string>()
const accumulatedBlockLogs: BlockLog[] = []
// Execute the workflow
try {
@@ -933,14 +934,30 @@ export function useWorkflowExecution() {
// Edges already tracked in onBlockStarted, no need to track again
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
// Accumulate block log for the execution result
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
})
// Add to console
addConsole({
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt: new Date(Date.now() - data.durationMs).toISOString(),
endedAt: new Date().toISOString(),
startedAt,
endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
@@ -967,6 +984,24 @@ export function useWorkflowExecution() {
// Track failed block execution in run path
setBlockRunStatus(data.blockId, 'error')
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
// Accumulate block error log for the execution result
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
})
// Add error to console
addConsole({
input: data.input || {},
@@ -974,8 +1009,8 @@ export function useWorkflowExecution() {
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt: new Date(Date.now() - data.durationMs).toISOString(),
endedAt: new Date().toISOString(),
startedAt,
endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
@@ -1029,7 +1064,7 @@ export function useWorkflowExecution() {
startTime: data.startTime,
endTime: data.endTime,
},
logs: [],
logs: accumulatedBlockLogs,
}
},
@@ -1041,7 +1076,7 @@ export function useWorkflowExecution() {
metadata: {
duration: data.duration,
},
logs: [],
logs: accumulatedBlockLogs,
}
// Only add workflow-level error if no blocks have executed yet

View File

@@ -1337,6 +1337,11 @@ const WorkflowContent = React.memo(() => {
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
targetParentId: null,
})
addBlock(
id,
type,
@@ -1349,7 +1354,7 @@ const WorkflowContent = React.memo(() => {
},
undefined,
undefined,
undefined
autoConnectEdge
)
return
@@ -1368,6 +1373,12 @@ const WorkflowContent = React.memo(() => {
const baseName = defaultTriggerName || blockConfig.name
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
enableTriggerMode,
targetParentId: null,
})
addBlock(
id,
type,
@@ -1376,7 +1387,7 @@ const WorkflowContent = React.memo(() => {
undefined,
undefined,
undefined,
undefined,
autoConnectEdge,
enableTriggerMode
)
}
@@ -1395,6 +1406,7 @@ const WorkflowContent = React.memo(() => {
addBlock,
effectivePermissions.canEdit,
checkTriggerConstraints,
tryCreateAutoConnectEdge,
])
/**

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { Crown, Eye, EyeOff } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -81,7 +81,9 @@ export function BYOK() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { data: keys = [], isLoading } = useBYOKKeys(workspaceId)
const { data, isLoading } = useBYOKKeys(workspaceId)
const keys = data?.keys ?? []
const byokEnabled = data?.byokEnabled ?? true
const upsertKey = useUpsertBYOKKey()
const deleteKey = useDeleteBYOKKey()
@@ -96,6 +98,31 @@ export function BYOK() {
return keys.find((k) => k.providerId === providerId)
}
// Show enterprise-only gate if BYOK is not enabled
if (!isLoading && !byokEnabled) {
return (
<div className='flex h-full flex-col items-center justify-center gap-[16px] py-[32px]'>
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-6)]'>
<Crown className='h-[24px] w-[24px] text-[var(--amber-9)]' />
</div>
<div className='flex flex-col items-center gap-[8px] text-center'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>Enterprise Feature</h3>
<p className='max-w-[320px] text-[13px] text-[var(--text-secondary)]'>
Bring Your Own Key (BYOK) is available exclusively on the Enterprise plan. Upgrade to
use your own API keys and eliminate the 2x cost multiplier.
</p>
</div>
<Button
variant='primary'
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
onClick={() => window.open('https://sim.ai/enterprise', '_blank')}
>
Contact Sales
</Button>
</div>
)
}
const handleSave = async () => {
if (!editingProvider || !apiKeyInput.trim()) return

View File

@@ -480,7 +480,7 @@ export function General({ onOpenChange }: GeneralProps) {
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='auto-connect'>Auto-connect on drag</Label>
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<Switch
id='auto-connect'
checked={settings?.autoConnect ?? true}

View File

@@ -53,6 +53,7 @@ import { useSettingsModalStore } from '@/stores/settings-modal/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
interface SettingsModalProps {
open: boolean
@@ -86,8 +87,8 @@ type NavigationItem = {
hideWhenBillingDisabled?: boolean
requiresTeam?: boolean
requiresEnterprise?: boolean
requiresOwner?: boolean
requiresHosted?: boolean
selfHostedOverride?: boolean
}
const sectionConfig: { key: NavigationSection; title: string }[] = [
@@ -113,6 +114,7 @@ const allNavigationItems: NavigationItem[] = [
icon: Users,
section: 'subscription',
hideWhenBillingDisabled: true,
requiresHosted: true,
requiresTeam: true,
},
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
@@ -123,7 +125,8 @@ const allNavigationItems: NavigationItem[] = [
label: 'Email Polling',
icon: Mail,
section: 'system',
requiresTeam: true,
requiresHosted: true,
selfHostedOverride: isCredentialSetsEnabled,
},
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
@@ -134,6 +137,7 @@ const allNavigationItems: NavigationItem[] = [
icon: KeySquare,
section: 'system',
requiresHosted: true,
requiresEnterprise: true,
},
{
id: 'copilot',
@@ -148,9 +152,9 @@ const allNavigationItems: NavigationItem[] = [
label: 'Single Sign-On',
icon: LogIn,
section: 'system',
requiresTeam: true,
requiresHosted: true,
requiresEnterprise: true,
requiresOwner: true,
selfHostedOverride: isSSOEnabled,
},
]
@@ -173,8 +177,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const userRole = getUserRole(activeOrganization, userEmail)
const isOwner = userRole === 'owner'
const isAdmin = userRole === 'admin'
const canManageSSO = isOwner || isAdmin
const isOrgAdminOrOwner = isOwner || isAdmin
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
@@ -192,29 +197,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
// SSO has special logic that must be checked before requiresTeam
if (item.id === 'sso') {
if (isHosted) {
return hasOrganization && hasEnterprisePlan && canManageSSO
if (item.selfHostedOverride && !isHosted) {
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
}
// For self-hosted, only show SSO tab if explicitly enabled via environment variable
if (!isSSOEnabled) return false
// Show tab if user is the SSO provider owner, or if no providers exist yet (to allow initial setup)
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
return true
}
if (item.requiresTeam) {
const isMember = userRole === 'member' || isAdmin
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
if (isMember) return true
if (isOwner && hasTeamPlan) return true
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
return false
}
if (item.requiresEnterprise && !hasEnterprisePlan) {
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
return false
}
@@ -222,24 +217,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
if (item.requiresOwner && !isOwner) {
return false
}
return true
})
}, [
hasOrganization,
hasTeamPlan,
hasEnterprisePlan,
canManageSSO,
isOrgAdminOrOwner,
isSSOProviderOwner,
isSSOEnabled,
ssoProvidersData?.providers?.length,
isOwner,
isAdmin,
userRole,
subscriptionStatus.isTeam,
subscriptionStatus.isEnterprise,
])
// Memoized callbacks to prevent infinite loops in child components

View File

@@ -15,18 +15,26 @@ export interface BYOKKey {
updatedAt: string
}
export interface BYOKKeysResponse {
keys: BYOKKey[]
byokEnabled: boolean
}
export const byokKeysKeys = {
all: ['byok-keys'] as const,
workspace: (workspaceId: string) => [...byokKeysKeys.all, 'workspace', workspaceId] as const,
}
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKey[]> {
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKeysResponse> {
const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId))
if (!response.ok) {
throw new Error(`Failed to load BYOK keys: ${response.statusText}`)
}
const { keys } = await response.json()
return keys
const data = await response.json()
return {
keys: data.keys ?? [],
byokEnabled: data.byokEnabled ?? true,
}
}
export function useBYOKKeys(workspaceId: string) {
@@ -36,6 +44,7 @@ export function useBYOKKeys(workspaceId: string) {
enabled: !!workspaceId,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
select: (data) => data,
})
}

View File

@@ -2,7 +2,12 @@ import { db } from '@sim/db'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getHostedModels } from '@/providers/models'
import { useProvidersStore } from '@/stores/providers/store'
const logger = createLogger('BYOKKeys')
@@ -51,9 +56,6 @@ export async function getApiKeyWithBYOK(
workspaceId: string | undefined | null,
userProvidedKey?: string
): Promise<{ apiKey: string; isBYOK: boolean }> {
const { isHosted } = await import('@/lib/core/config/feature-flags')
const { useProvidersStore } = await import('@/stores/providers/store')
const isOllamaModel =
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
if (isOllamaModel) {
@@ -83,23 +85,27 @@ export async function getApiKeyWithBYOK(
workspaceId &&
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)
) {
const { getHostedModels } = await import('@/providers/models')
const hostedModels = getHostedModels()
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
if (isModelHosted || isMistralModel) {
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
if (byokResult) {
logger.info('Using BYOK key', { provider, model, workspaceId })
return byokResult
const hasEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
if (hasEnterprise) {
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
if (byokResult) {
logger.info('Using BYOK key', { provider, model, workspaceId })
return byokResult
}
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
} else {
logger.debug('Workspace not on enterprise plan, skipping BYOK', { workspaceId })
}
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
if (isModelHosted) {
try {
const { getRotatingApiKey } = await import('@/lib/core/config/api-keys')
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
return { apiKey: serverKey, isBYOK: false }
} catch (_error) {

View File

@@ -0,0 +1,53 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('PlanLookup')
/**
* Get the highest priority active subscription for a user
* Priority: Enterprise > Team > Pro > Free
*/
export async function getHighestPrioritySubscription(userId: string) {
try {
const personalSubs = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]
if (allSubs.length === 0) return null
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
if (proSub) return proSub
return null
} catch (error) {
logger.error('Error getting highest priority subscription', { error, userId })
return null
}
}

View File

@@ -1,7 +1,9 @@
import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { member, subscription, user, userStats, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import {
checkEnterprisePlan,
checkProPlan,
@@ -10,65 +12,17 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
import { isProd } from '@/lib/core/config/feature-flags'
import {
isCredentialSetsEnabled,
isHosted,
isProd,
isSsoEnabled,
} from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('SubscriptionCore')
/**
* Core subscription management - single source of truth
* Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
*/
/**
* Get the highest priority active subscription for a user
* Priority: Enterprise > Team > Pro > Free
*/
export async function getHighestPrioritySubscription(userId: string) {
try {
// Get direct subscriptions
const personalSubs = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
// Get organization memberships
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
// Get organization subscriptions
let orgSubs: any[] = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]
if (allSubs.length === 0) return null
// Return highest priority subscription
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
if (proSub) return proSub
return null
} catch (error) {
logger.error('Error getting highest priority subscription', { error, userId })
return null
}
}
export { getHighestPrioritySubscription }
/**
* Check if user is on Pro plan (direct or via organization)
@@ -144,6 +98,224 @@ export async function isEnterprisePlan(userId: string): Promise<boolean> {
}
}
/**
* Check if user is an admin or owner of an enterprise organization
* Returns true if:
* - User is a member of an enterprise organization AND
* - User's role in that organization is 'owner' or 'admin'
*
* In non-production environments, returns true for convenience.
*/
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const [memberRecord] = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!memberRecord) {
return false
}
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, memberRecord.organizationId),
eq(subscription.status, 'active')
)
)
.limit(1)
const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
if (isEnterprise) {
logger.info('User is enterprise org admin/owner', {
userId,
organizationId: memberRecord.organizationId,
role: memberRecord.role,
})
}
return !!isEnterprise
} catch (error) {
logger.error('Error checking enterprise org admin/owner status', { error, userId })
return false
}
}
/**
* Check if user is an admin or owner of a team or enterprise organization
* Returns true if:
* - User is a member of a team/enterprise organization AND
* - User's role in that organization is 'owner' or 'admin'
*
* In non-production environments, returns true for convenience.
*/
export async function isTeamOrgAdminOrOwner(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const [memberRecord] = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!memberRecord) {
return false
}
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, memberRecord.organizationId),
eq(subscription.status, 'active')
)
)
.limit(1)
const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
if (hasTeamPlan) {
logger.info('User is team org admin/owner', {
userId,
organizationId: memberRecord.organizationId,
role: memberRecord.role,
plan: orgSub.plan,
})
}
return !!hasTeamPlan
} catch (error) {
logger.error('Error checking team org admin/owner status', { error, userId })
return false
}
}
/**
* Check if a workspace has access to enterprise features (BYOK)
* Used at execution time to determine if BYOK keys should be used
* Returns true if workspace's billed account is on enterprise plan
*/
export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const [ws] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!ws) {
return false
}
return isEnterprisePlan(ws.billedAccountUserId)
} catch (error) {
logger.error('Error checking workspace enterprise status', { error, workspaceId })
return false
}
}
/**
* Check if an organization has team or enterprise plan
* Used at execution time (e.g., polling services) to check org billing directly
*/
export async function isOrganizationOnTeamOrEnterprisePlan(
organizationId: string
): Promise<boolean> {
try {
if (!isProd) {
return true
}
if (isCredentialSetsEnabled && !isHosted) {
return true
}
const [orgSub] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
} catch (error) {
logger.error('Error checking organization plan status', { error, organizationId })
return false
}
}
/**
* Check if user has access to credential sets (email polling) feature
* Returns true if:
* - CREDENTIAL_SETS_ENABLED env var is set (self-hosted override), OR
* - User is admin/owner of a team/enterprise organization
*
* In non-production environments, returns true for convenience.
*/
export async function hasCredentialSetsAccess(userId: string): Promise<boolean> {
try {
if (isCredentialSetsEnabled && !isHosted) {
return true
}
return isTeamOrgAdminOrOwner(userId)
} catch (error) {
logger.error('Error checking credential sets access', { error, userId })
return false
}
}
/**
* Check if user has access to SSO feature
* Returns true if:
* - SSO_ENABLED env var is set (self-hosted override), OR
* - User is admin/owner of an enterprise organization
*
* In non-production environments, returns true for convenience.
*/
export async function hasSSOAccess(userId: string): Promise<boolean> {
try {
if (isSsoEnabled && !isHosted) {
return true
}
return isEnterpriseOrgAdminOrOwner(userId)
} catch (error) {
logger.error('Error checking SSO access', { error, userId })
return false
}
}
/**
* Check if user has exceeded their cost limit based on current period usage
*/
@@ -160,7 +332,6 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
if (subscription) {
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
limit = await getUserUsageLimit(userId)
logger.info('Using organization limit', {
userId,
@@ -221,14 +392,16 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
// Determine plan types based on subscription (avoid redundant DB calls)
const isPro =
!isProd ||
(subscription &&
!!(
subscription &&
(checkProPlan(subscription) ||
checkTeamPlan(subscription) ||
checkEnterprisePlan(subscription)))
checkEnterprisePlan(subscription))
)
const isTeam =
!isProd ||
(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
const isEnterprise = !isProd || (subscription && checkEnterprisePlan(subscription))
!!(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
const isEnterprise = !isProd || !!(subscription && checkEnterprisePlan(subscription))
const isFree = !isPro && !isTeam && !isEnterprise
// Determine plan name
@@ -244,7 +417,6 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
if (subscription) {
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
limit = await getUserUsageLimit(userId)
} else {
// Pro/Free: Use individual limit

View File

@@ -7,7 +7,7 @@ import {
renderFreeTierUpgradeEmail,
renderUsageThresholdEmail,
} from '@/components/emails'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import {
canEditUsageLimit,
getFreeTierLimit,

View File

@@ -10,9 +10,15 @@ export * from '@/lib/billing/core/subscription'
export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasCredentialSetsAccess,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
isEnterprisePlan as hasEnterprisePlan,
isOrganizationOnTeamOrEnterprisePlan,
isProPlan as hasProPlan,
isTeamOrgAdminOrOwner,
isTeamPlan as hasTeamPlan,
isWorkspaceOnEnterprisePlan,
sendPlanWelcomeEmail,
} from '@/lib/billing/core/subscription'
export * from '@/lib/billing/core/usage'

View File

@@ -248,6 +248,9 @@ export const env = createEnv({
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
// Credential Sets (Email Polling) - for self-hosted deployments
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
// SSO Configuration (for script-based registration)
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type
@@ -325,6 +328,7 @@ export const env = createEnv({
// Feature Flags
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
},
@@ -353,6 +357,7 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR,
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: process.env.NEXT_PUBLIC_TRIGGER_DEV_ENABLED,
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED,
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,

View File

@@ -80,6 +80,12 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
*/
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
/**
* Is credential sets (email polling) enabled via env var override
* This bypasses plan requirements for self-hosted deployments
*/
export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED)
/**
* Is E2B enabled for remote code execution
*/

View File

@@ -595,26 +595,6 @@ describe('validateUrlWithDNS', () => {
expect(result.isValid).toBe(false)
})
})
describe('DNS resolution', () => {
it('should accept valid public URLs and return resolved IP', async () => {
const result = await validateUrlWithDNS('https://example.com')
expect(result.isValid).toBe(true)
expect(result.resolvedIP).toBeDefined()
expect(result.originalHostname).toBe('example.com')
})
it('should reject URLs that resolve to private IPs', async () => {
const result = await validateUrlWithDNS('https://localhost.localdomain')
expect(result.isValid).toBe(false)
})
it('should reject unresolvable hostnames', async () => {
const result = await validateUrlWithDNS('https://this-domain-does-not-exist-xyz123.invalid')
expect(result.isValid).toBe(false)
expect(result.error).toContain('could not be resolved')
})
})
})
describe('createPinnedUrl', () => {

View File

@@ -1,8 +1,9 @@
import { db } from '@sim/db'
import { account, webhook, workflow } from '@sim/db/schema'
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -141,9 +142,9 @@ export async function pollGmailWebhooks() {
try {
const metadata = webhookData.providerConfig as any
// Each webhook now has its own credentialId (credential sets are fanned out at save time)
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
const credentialSetId: string | undefined = metadata?.credentialSetId
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`)
@@ -152,6 +153,31 @@ export async function pollGmailWebhooks() {
return
}
if (credentialSetId) {
const [cs] = await db
.select({ organizationId: credentialSet.organizationId })
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (cs?.organizationId) {
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
if (!hasAccess) {
logger.error(
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
{
webhookId,
credentialSetId,
organizationId: cs.organizationId,
}
)
await markWebhookFailed(webhookId)
failureCount++
return
}
}
}
let accessToken: string | null = null
if (credentialId) {

View File

@@ -1,9 +1,10 @@
import { db } from '@sim/db'
import { account, webhook, workflow } from '@sim/db/schema'
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { htmlToText } from 'html-to-text'
import { nanoid } from 'nanoid'
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -192,6 +193,7 @@ export async function pollOutlookWebhooks() {
const metadata = webhookData.providerConfig as any
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
const credentialSetId: string | undefined = metadata?.credentialSetId
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
@@ -200,6 +202,31 @@ export async function pollOutlookWebhooks() {
return
}
if (credentialSetId) {
const [cs] = await db
.select({ organizationId: credentialSet.organizationId })
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (cs?.organizationId) {
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
if (!hasAccess) {
logger.error(
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
{
webhookId,
credentialSetId,
organizationId: cs.organizationId,
}
)
await markWebhookFailed(webhookId)
failureCount++
return
}
}
}
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)

View File

@@ -2546,7 +2546,6 @@ export async function syncWebhooksForCredentialSet(params: {
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
const useUniquePaths = pollingProviders.includes(provider)
// Get all credentials in the set
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
if (credentials.length === 0) {

View File

@@ -0,0 +1,2 @@
DROP INDEX "member_user_id_idx";--> statement-breakpoint
CREATE UNIQUE INDEX "member_user_id_unique" ON "member" USING btree ("user_id");

File diff suppressed because it is too large Load Diff

View File

@@ -946,6 +946,13 @@
"when": 1767737974016,
"tag": "0135_stormy_puff_adder",
"breakpoints": true
},
{
"idx": 136,
"version": "7",
"when": 1767905804764,
"tag": "0136_pretty_jack_flag",
"breakpoints": true
}
]
}

View File

@@ -824,7 +824,7 @@ export const member = pgTable(
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('member_user_id_idx').on(table.userId),
userIdUnique: uniqueIndex('member_user_id_unique').on(table.userId), // Users can only belong to one org
organizationIdIdx: index('member_organization_id_idx').on(table.organizationId),
})
)