v0.5.29: chat voice mode, opengraph for docs, option to disable auth

This commit is contained in:
Waleed
2025-12-13 19:50:06 -08:00
committed by GitHub
115 changed files with 1690 additions and 857 deletions

View File

@@ -243,6 +243,9 @@ export async function generateMetadata(props: {
const baseUrl = 'https://docs.sim.ai'
const fullUrl = `${baseUrl}${page.url}`
const description = page.data.description || ''
const ogImageUrl = `${baseUrl}/api/og?title=${encodeURIComponent(page.data.title)}&category=DOCUMENTATION${description ? `&description=${encodeURIComponent(description)}` : ''}`
return {
title: page.data.title,
description:
@@ -272,12 +275,23 @@ export async function generateMetadata(props: {
alternateLocale: ['en', 'es', 'fr', 'de', 'ja', 'zh']
.filter((lang) => lang !== params.lang)
.map((lang) => (lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}`)),
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: page.data.title,
},
],
},
twitter: {
card: 'summary',
card: 'summary_large_image',
title: page.data.title,
description:
page.data.description || 'Sim visual workflow builder for AI applications documentation',
images: [ogImageUrl],
creator: '@simdotai',
site: '@simdotai',
},
robots: {
index: true,

View File

@@ -0,0 +1,173 @@
import { ImageResponse } from 'next/og'
import type { NextRequest } from 'next/server'
export const runtime = 'edge'
const TITLE_FONT_SIZE = {
large: 64,
medium: 56,
small: 48,
} as const
function getTitleFontSize(title: string): number {
if (title.length > 45) return TITLE_FONT_SIZE.small
if (title.length > 30) return TITLE_FONT_SIZE.medium
return TITLE_FONT_SIZE.large
}
/**
* Loads a Google Font dynamically by fetching the CSS and extracting the font URL.
*/
async function loadGoogleFont(font: string, weights: string, text: string): Promise<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,6 +56,14 @@ export const metadata = {
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
description:
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
images: [
{
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION',
width: 1200,
height: 630,
alt: 'Sim Documentation',
},
],
},
twitter: {
card: 'summary_large_image',
@@ -64,7 +72,7 @@ export const metadata = {
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
creator: '@simdotai',
site: '@simdotai',
images: ['/og-image.png'],
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION'],
},
robots: {
index: true,

File diff suppressed because one or more lines are too long

View File

@@ -119,116 +119,116 @@ import {
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
calendly: CalendlyIcon,
mailchimp: MailchimpIcon,
postgresql: PostgresIcon,
twilio_voice: TwilioIcon,
elasticsearch: ElasticsearchIcon,
rds: RDSIcon,
translate: TranslateIcon,
dynamodb: DynamoDBIcon,
wordpress: WordpressIcon,
tavily: TavilyIcon,
zoom: ZoomIcon,
zep: ZepIcon,
zendesk: ZendeskIcon,
youtube: YouTubeIcon,
supabase: SupabaseIcon,
vision: EyeIcon,
zoom: ZoomIcon,
confluence: ConfluenceIcon,
arxiv: ArxivIcon,
webflow: WebflowIcon,
pinecone: PineconeIcon,
apollo: ApolloIcon,
whatsapp: WhatsAppIcon,
typeform: TypeformIcon,
qdrant: QdrantIcon,
shopify: ShopifyIcon,
asana: AsanaIcon,
sqs: SQSIcon,
apify: ApifyIcon,
memory: BrainIcon,
gitlab: GitLabIcon,
polymarket: PolymarketIcon,
serper: SerperIcon,
linear: LinearIcon,
exa: ExaAIIcon,
telegram: TelegramIcon,
salesforce: SalesforceIcon,
hubspot: HubspotIcon,
hunter: HunterIOIcon,
linkup: LinkupIcon,
mongodb: MongoDBIcon,
airtable: AirtableIcon,
discord: DiscordIcon,
ahrefs: AhrefsIcon,
neo4j: Neo4jIcon,
tts: TTSIcon,
jina: JinaAIIcon,
google_docs: GoogleDocsIcon,
perplexity: PerplexityIcon,
google_search: GoogleIcon,
x: xIcon,
kalshi: KalshiIcon,
google_calendar: GoogleCalendarIcon,
zep: ZepIcon,
posthog: PosthogIcon,
grafana: GrafanaIcon,
google_slides: GoogleSlidesIcon,
microsoft_planner: MicrosoftPlannerIcon,
thinking: BrainIcon,
pipedrive: PipedriveIcon,
dropbox: DropboxIcon,
stagehand: StagehandIcon,
google_forms: GoogleFormsIcon,
file: DocumentIcon,
mistral_parse: MistralIcon,
gmail: GmailIcon,
openai: OpenAIIcon,
outlook: OutlookIcon,
incidentio: IncidentioIcon,
onedrive: MicrosoftOneDriveIcon,
resend: ResendIcon,
google_vault: GoogleVaultIcon,
sharepoint: MicrosoftSharepointIcon,
huggingface: HuggingFaceIcon,
sendgrid: SendgridIcon,
video_generator: VideoIcon,
smtp: SmtpIcon,
google_groups: GoogleGroupsIcon,
mailgun: MailgunIcon,
clay: ClayIcon,
jira: JiraIcon,
search: SearchIcon,
linkedin: LinkedInIcon,
wealthbox: WealthboxIcon,
notion: NotionIcon,
elevenlabs: ElevenLabsIcon,
microsoft_teams: MicrosoftTeamsIcon,
github: GithubIcon,
sftp: SftpIcon,
ssh: SshIcon,
google_drive: GoogleDriveIcon,
sentry: SentryIcon,
reddit: RedditIcon,
parallel_ai: ParallelIcon,
spotify: SpotifyIcon,
stripe: StripeIcon,
s3: S3Icon,
trello: TrelloIcon,
mem0: Mem0Icon,
knowledge: PackageSearchIcon,
intercom: IntercomIcon,
twilio_sms: TwilioIcon,
duckduckgo: DuckDuckGoIcon,
slack: SlackIcon,
datadog: DatadogIcon,
microsoft_excel: MicrosoftExcelIcon,
image_generator: ImageIcon,
google_sheets: GoogleSheetsIcon,
wordpress: WordpressIcon,
wikipedia: WikipediaIcon,
cursor: CursorIcon,
firecrawl: FirecrawlIcon,
mysql: MySQLIcon,
browser_use: BrowserUseIcon,
whatsapp: WhatsAppIcon,
webflow: WebflowIcon,
wealthbox: WealthboxIcon,
vision: EyeIcon,
video_generator: VideoIcon,
typeform: TypeformIcon,
twilio_voice: TwilioIcon,
twilio_sms: TwilioIcon,
tts: TTSIcon,
trello: TrelloIcon,
translate: TranslateIcon,
thinking: BrainIcon,
telegram: TelegramIcon,
tavily: TavilyIcon,
supabase: SupabaseIcon,
stt: STTIcon,
stripe: StripeIcon,
stagehand: StagehandIcon,
ssh: SshIcon,
sqs: SQSIcon,
spotify: SpotifyIcon,
smtp: SmtpIcon,
slack: SlackIcon,
shopify: ShopifyIcon,
sharepoint: MicrosoftSharepointIcon,
sftp: SftpIcon,
serper: SerperIcon,
sentry: SentryIcon,
sendgrid: SendgridIcon,
search: SearchIcon,
salesforce: SalesforceIcon,
s3: S3Icon,
resend: ResendIcon,
reddit: RedditIcon,
rds: RDSIcon,
qdrant: QdrantIcon,
posthog: PosthogIcon,
postgresql: PostgresIcon,
polymarket: PolymarketIcon,
pipedrive: PipedriveIcon,
pinecone: PineconeIcon,
perplexity: PerplexityIcon,
parallel_ai: ParallelIcon,
outlook: OutlookIcon,
openai: OpenAIIcon,
onedrive: MicrosoftOneDriveIcon,
notion: NotionIcon,
neo4j: Neo4jIcon,
mysql: MySQLIcon,
mongodb: MongoDBIcon,
mistral_parse: MistralIcon,
microsoft_teams: MicrosoftTeamsIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_excel: MicrosoftExcelIcon,
memory: BrainIcon,
mem0: Mem0Icon,
mailgun: MailgunIcon,
mailchimp: MailchimpIcon,
linkup: LinkupIcon,
linkedin: LinkedInIcon,
linear: LinearIcon,
knowledge: PackageSearchIcon,
kalshi: KalshiIcon,
jira: JiraIcon,
jina: JinaAIIcon,
intercom: IntercomIcon,
incidentio: IncidentioIcon,
image_generator: ImageIcon,
hunter: HunterIOIcon,
huggingface: HuggingFaceIcon,
hubspot: HubspotIcon,
grafana: GrafanaIcon,
google_vault: GoogleVaultIcon,
google_slides: GoogleSlidesIcon,
google_sheets: GoogleSheetsIcon,
google_groups: GoogleGroupsIcon,
google_forms: GoogleFormsIcon,
google_drive: GoogleDriveIcon,
google_docs: GoogleDocsIcon,
google_calendar: GoogleCalendarIcon,
google_search: GoogleIcon,
gmail: GmailIcon,
gitlab: GitLabIcon,
github: GithubIcon,
firecrawl: FirecrawlIcon,
file: DocumentIcon,
exa: ExaAIIcon,
elevenlabs: ElevenLabsIcon,
elasticsearch: ElasticsearchIcon,
dynamodb: DynamoDBIcon,
duckduckgo: DuckDuckGoIcon,
dropbox: DropboxIcon,
discord: DiscordIcon,
datadog: DatadogIcon,
cursor: CursorIcon,
confluence: ConfluenceIcon,
clay: ClayIcon,
calendly: CalendlyIcon,
browser_use: BrowserUseIcon,
asana: AsanaIcon,
arxiv: ArxivIcon,
apollo: ApolloIcon,
apify: ApifyIcon,
airtable: AirtableIcon,
ahrefs: AhrefsIcon,
}

View File

@@ -39,9 +39,10 @@ Alle Elemente aus einer Webflow CMS-Sammlung auflisten
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Ja | ID der Webflow-Website |
| `collectionId` | string | Ja | ID der Sammlung |
| `offset` | number | Nein | Offset für Paginierung \(optional\) |
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente \(optional, Standard: 100\) |
| `offset` | number | Nein | Offset für Paginierung (optional) |
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente (optional, Standard: 100) |
#### Ausgabe
@@ -58,6 +59,7 @@ Ein einzelnes Element aus einer Webflow CMS-Sammlung abrufen
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Ja | ID der Webflow-Website |
| `collectionId` | string | Ja | ID der Sammlung |
| `itemId` | string | Ja | ID des abzurufenden Elements |
@@ -76,8 +78,9 @@ Ein neues Element in einer Webflow CMS-Sammlung erstellen
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Ja | ID der Webflow-Website |
| `collectionId` | string | Ja | ID der Sammlung |
| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Die Schlüssel sollten mit den Feldnamen der Sammlung übereinstimmen. |
| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Schlüssel sollten mit den Sammlungsfeldnamen übereinstimmen. |
#### Ausgabe
@@ -94,6 +97,7 @@ Ein vorhandenes Element in einer Webflow CMS-Sammlung aktualisieren
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Ja | ID der Webflow-Website |
| `collectionId` | string | Ja | ID der Sammlung |
| `itemId` | string | Ja | ID des zu aktualisierenden Elements |
| `fieldData` | json | Ja | Zu aktualisierende Felddaten als JSON-Objekt. Nur Felder einschließen, die geändert werden sollen. |
@@ -113,6 +117,7 @@ Ein Element aus einer Webflow CMS-Sammlung löschen
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Ja | ID der Webflow-Website |
| `collectionId` | string | Ja | ID der Sammlung |
| `itemId` | string | Ja | ID des zu löschenden Elements |

View File

@@ -42,6 +42,7 @@ List all items from a Webflow CMS collection
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Yes | ID of the Webflow site |
| `collectionId` | string | Yes | ID of the collection |
| `offset` | number | No | Offset for pagination \(optional\) |
| `limit` | number | No | Maximum number of items to return \(optional, default: 100\) |
@@ -61,6 +62,7 @@ Get a single item from a Webflow CMS collection
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Yes | ID of the Webflow site |
| `collectionId` | string | Yes | ID of the collection |
| `itemId` | string | Yes | ID of the item to retrieve |
@@ -79,6 +81,7 @@ Create a new item in a Webflow CMS collection
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Yes | ID of the Webflow site |
| `collectionId` | string | Yes | ID of the collection |
| `fieldData` | json | Yes | Field data for the new item as a JSON object. Keys should match collection field names. |
@@ -97,6 +100,7 @@ Update an existing item in a Webflow CMS collection
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Yes | ID of the Webflow site |
| `collectionId` | string | Yes | ID of the collection |
| `itemId` | string | Yes | ID of the item to update |
| `fieldData` | json | Yes | Field data to update as a JSON object. Only include fields you want to change. |
@@ -116,6 +120,7 @@ Delete an item from a Webflow CMS collection
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Yes | ID of the Webflow site |
| `collectionId` | string | Yes | ID of the collection |
| `itemId` | string | Yes | ID of the item to delete |

View File

@@ -39,6 +39,7 @@ Listar todos los elementos de una colección del CMS de Webflow
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Sí | ID del sitio de Webflow |
| `collectionId` | string | Sí | ID de la colección |
| `offset` | number | No | Desplazamiento para paginación \(opcional\) |
| `limit` | number | No | Número máximo de elementos a devolver \(opcional, predeterminado: 100\) |
@@ -58,6 +59,7 @@ Obtener un solo elemento de una colección del CMS de Webflow
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Sí | ID del sitio de Webflow |
| `collectionId` | string | Sí | ID de la colección |
| `itemId` | string | Sí | ID del elemento a recuperar |
@@ -76,6 +78,7 @@ Crear un nuevo elemento en una colección del CMS de Webflow
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Sí | ID del sitio de Webflow |
| `collectionId` | string | Sí | ID de la colección |
| `fieldData` | json | Sí | Datos de campo para el nuevo elemento como objeto JSON. Las claves deben coincidir con los nombres de campo de la colección. |
@@ -94,6 +97,7 @@ Actualizar un elemento existente en una colección CMS de Webflow
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Sí | ID del sitio de Webflow |
| `collectionId` | string | Sí | ID de la colección |
| `itemId` | string | Sí | ID del elemento a actualizar |
| `fieldData` | json | Sí | Datos de campo para actualizar como objeto JSON. Solo incluye los campos que quieres cambiar. |
@@ -113,6 +117,7 @@ Eliminar un elemento de una colección CMS de Webflow
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Sí | ID del sitio de Webflow |
| `collectionId` | string | Sí | ID de la colección |
| `itemId` | string | Sí | ID del elemento a eliminar |

View File

@@ -38,7 +38,8 @@ Lister tous les éléments d'une collection CMS Webflow
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | -------- | ----------- |
| --------- | ---- | ---------- | ----------- |
| `siteId` | string | Oui | ID du site Webflow |
| `collectionId` | string | Oui | ID de la collection |
| `offset` | number | Non | Décalage pour la pagination \(facultatif\) |
| `limit` | number | Non | Nombre maximum d'éléments à retourner \(facultatif, par défaut : 100\) |
@@ -57,7 +58,8 @@ Obtenir un seul élément d'une collection CMS Webflow
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | -------- | ----------- |
| --------- | ---- | ---------- | ----------- |
| `siteId` | string | Oui | ID du site Webflow |
| `collectionId` | string | Oui | ID de la collection |
| `itemId` | string | Oui | ID de l'élément à récupérer |
@@ -76,8 +78,9 @@ Créer un nouvel élément dans une collection CMS Webflow
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ---------- | ----------- |
| `siteId` | string | Oui | ID du site Webflow |
| `collectionId` | string | Oui | ID de la collection |
| `fieldData` | json | Oui | Données de champ pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. |
| `fieldData` | json | Oui | Données des champs pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. |
#### Sortie
@@ -94,9 +97,10 @@ Mettre à jour un élément existant dans une collection CMS Webflow
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ---------- | ----------- |
| `siteId` | string | Oui | ID du site Webflow |
| `collectionId` | string | Oui | ID de la collection |
| `itemId` | string | Oui | ID de l'élément à mettre à jour |
| `fieldData` | json | Oui | Données de champ à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
| `fieldData` | json | Oui | Données des champs à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
#### Sortie
@@ -113,6 +117,7 @@ Supprimer un élément d'une collection CMS Webflow
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ---------- | ----------- |
| `siteId` | string | Oui | ID du site Webflow |
| `collectionId` | string | Oui | ID de la collection |
| `itemId` | string | Oui | ID de l'élément à supprimer |

View File

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

View File

@@ -38,9 +38,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `offset` | number | 否 | 分页偏移量(可选) |
| `limit` | number | 否 | 返回的最大项目数可选默认值100 |
| `offset` | number | 否 | 分页偏移量(可选) |
| `limit` | number | 否 | 返回的最大项目数可选默认值100 |
#### 输出
@@ -57,8 +58,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `itemId` | string | 是 | 要检索项目 ID |
| `itemId` | string | 是 | 要检索项目 ID |
#### 输出
@@ -75,8 +77,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 |
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 |
#### 输出
@@ -93,9 +96,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `itemId` | string | 是 | 要更新项目的 ID |
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含您想更改的字段。 |
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含需要更改的字段。 |
#### 输出
@@ -112,6 +116,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `siteId` | string | 是 | Webflow 网站的 ID |
| `collectionId` | string | 是 | 集合的 ID |
| `itemId` | string | 是 | 要删除项目的 ID |

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

View File

@@ -4,10 +4,13 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
# POSTGRES_PORT=5432
# Authentication (Required)
# Authentication (Required unless DISABLE_AUTH=true)
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation
BETTER_AUTH_URL=http://localhost:3000
# Authentication Bypass (Optional - for self-hosted deployments behind private networks)
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.
# NextJS (Required)
NEXT_PUBLIC_APP_URL=http://localhost:3000

View File

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

View File

@@ -1,7 +1,6 @@
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form'
// Force dynamic rendering to avoid prerender errors with search params
export const dynamic = 'force-dynamic'
export default async function LoginPage() {

View File

@@ -1,16 +1,16 @@
import { env, isTruthy } from '@/lib/core/config/env'
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import SignupForm from '@/app/(auth)/signup/signup-form'
export const dynamic = 'force-dynamic'
export default async function SignupPage() {
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
if (isTruthy(env.DISABLE_REGISTRATION)) {
if (isRegistrationDisabled) {
return <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/environment'
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
import { hasEmailService } from '@/lib/messaging/email/mailer'
import { VerifyContent } from '@/app/(auth)/verify/verify-content'

View File

@@ -1,6 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
const DEFAULT_STARS = '18.6k'
const DEFAULT_STARS = '19.4k'
const logger = createLogger('GitHubStars')

View File

@@ -13,7 +13,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { isHosted } from '@/lib/core/config/environment'
import { isHosted } from '@/lib/core/config/feature-flags'
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/environment'
import { isHosted } from '@/lib/core/config/feature-flags'
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/environment'
import { isHosted } from '@/lib/core/config/feature-flags'
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,6 +1,23 @@
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'
export const { GET, POST } = toNextJsHandler(auth.handler)
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

View File

@@ -1,9 +1,14 @@
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 { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SSO-Providers')
export async function GET(req: NextRequest) {
export async function GET() {
try {
const session = await auth.api.getSession({ headers: req.headers })
const session = await getSession()
let providers
if (session?.user?.id) {
@@ -38,8 +38,6 @@ export async function GET(req: NextRequest) {
: ('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/environment'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -132,7 +132,7 @@ export async function POST(
if ((password || email) && !input) {
const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
setChatAuthCookie(response, deployment.id, deployment.authType)
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
return response
}
@@ -315,7 +315,7 @@ export async function GET(
if (
deployment.authType !== 'public' &&
authCookie &&
validateAuthToken(authCookie.value, deployment.id)
validateAuthToken(authCookie.value, deployment.id, deployment.password)
) {
return addCorsHeaders(
createSuccessResponse({

View File

@@ -6,6 +6,12 @@ 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()
@@ -24,7 +30,6 @@ describe('Chat Edit API Route', () => {
beforeEach(() => {
vi.resetModules()
// Set default return values
mockLimit.mockResolvedValue([])
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
@@ -77,10 +82,6 @@ describe('Chat Edit API Route', () => {
getEmailDomain: vi.fn().mockReturnValue('localhost:3000'),
}))
vi.doMock('@/lib/core/config/environment', () => ({
isDev: true,
}))
vi.doMock('@/app/api/chat/utils', () => ({
checkChatAccess: mockCheckChatAccess,
}))
@@ -254,7 +255,6 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
// Reset and reconfigure mockLimit to return the conflict
mockLimit.mockReset()
mockLimit.mockResolvedValue([{ id: 'other-chat-id', identifier: 'new-identifier' }])
mockWhere.mockReturnValue({ limit: mockLimit })
@@ -291,7 +291,7 @@ describe('Chat Edit API Route', () => {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password' }), // No password provided
body: JSON.stringify({ authType: 'password' }),
})
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
@@ -316,9 +316,8 @@ describe('Chat Edit API Route', () => {
workflowId: 'workflow-123',
}
// User doesn't own chat but has workspace admin access
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
mockLimit.mockResolvedValueOnce([])
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
@@ -399,7 +398,6 @@ describe('Chat Edit API Route', () => {
}),
}))
// User doesn't own chat but has workspace admin access
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)

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

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

View File

@@ -44,6 +44,12 @@ vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn(),
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
isDev: true,
isHosted: false,
isProd: false,
}))
describe('Chat API Utils', () => {
beforeEach(() => {
vi.doMock('@/lib/logs/console/logger', () => ({
@@ -62,11 +68,6 @@ describe('Chat API Utils', () => {
NODE_ENV: 'development',
},
})
vi.doMock('@/lib/core/config/environment', () => ({
isDev: true,
isHosted: false,
}))
})
afterEach(() => {

View File

@@ -1,14 +1,19 @@
import { createHash } from 'crypto'
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { isDev } from '@/lib/core/config/environment'
import { isDev } from '@/lib/core/config/feature-flags'
import { decryptSecret } from '@/lib/core/security/encryption'
import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('ChatAuthUtils')
function hashPassword(encryptedPassword: string): string {
return createHash('sha256').update(encryptedPassword).digest('hex').substring(0, 8)
}
/**
* Check if user has permission to create a chat for a specific workflow
* Either the user owns the workflow directly OR has admin permission for the workflow's workspace
@@ -77,14 +82,20 @@ export async function checkChatAccess(
return { hasAccess: false }
}
const encryptAuthToken = (chatId: string, type: string): string => {
return Buffer.from(`${chatId}:${type}:${Date.now()}`).toString('base64')
function encryptAuthToken(chatId: string, type: string, encryptedPassword?: string | null): string {
const pwHash = encryptedPassword ? hashPassword(encryptedPassword) : ''
return Buffer.from(`${chatId}:${type}:${Date.now()}:${pwHash}`).toString('base64')
}
export const validateAuthToken = (token: string, chatId: string): boolean => {
export function validateAuthToken(
token: string,
chatId: string,
encryptedPassword?: string | null
): boolean {
try {
const decoded = Buffer.from(token, 'base64').toString()
const [storedId, _type, timestamp] = decoded.split(':')
const parts = decoded.split(':')
const [storedId, _type, timestamp, storedPwHash] = parts
if (storedId !== chatId) {
return false
@@ -92,20 +103,32 @@ export const validateAuthToken = (token: string, chatId: string): boolean => {
const createdAt = Number.parseInt(timestamp)
const now = Date.now()
const expireTime = 24 * 60 * 60 * 1000 // 24 hours
const expireTime = 24 * 60 * 60 * 1000
if (now - createdAt > expireTime) {
return false
}
if (encryptedPassword) {
const currentPwHash = hashPassword(encryptedPassword)
if (storedPwHash !== currentPwHash) {
return false
}
}
return true
} catch (_e) {
return false
}
}
export const setChatAuthCookie = (response: NextResponse, chatId: string, type: string): void => {
const token = encryptAuthToken(chatId, type)
export function setChatAuthCookie(
response: NextResponse,
chatId: string,
type: string,
encryptedPassword?: string | null
): void {
const token = encryptAuthToken(chatId, type, encryptedPassword)
response.cookies.set({
name: `chat_auth_${chatId}`,
value: token,
@@ -113,7 +136,7 @@ export const setChatAuthCookie = (response: NextResponse, chatId: string, type:
secure: !isDev,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24, // 24 hours
maxAge: 60 * 60 * 24,
})
}
@@ -145,7 +168,7 @@ export async function validateChatAuth(
const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)
if (authCookie && validateAuthToken(authCookie.value, deployment.id)) {
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
return { authorized: true }
}
@@ -259,8 +282,8 @@ export async function validateChatAuth(
return { authorized: false, error: 'Email not authorized for SSO access' }
}
const { auth } = await import('@/lib/auth')
const session = await auth.api.getSession({ headers: request.headers })
const { getSession } = await import('@/lib/auth')
const session = await getSession()
if (!session || !session.user) {
return { authorized: false, error: 'auth_required_sso' }

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 { auth } from '@/lib/auth'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotAutoAllowedToolsAPI')
@@ -10,9 +10,9 @@ const logger = createLogger('CopilotAutoAllowedToolsAPI')
/**
* GET - Fetch user's auto-allowed integration tools
*/
export async function GET(request: NextRequest) {
export async function GET() {
try {
const session = await auth.api.getSession({ headers: request.headers })
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -31,7 +31,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ autoAllowedTools })
}
// If no settings record exists, create one with empty array
await db.insert(settings).values({
id: userId,
userId,
@@ -50,7 +49,7 @@ export async function GET(request: NextRequest) {
*/
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({ headers: request.headers })
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -65,13 +64,11 @@ export async function POST(request: NextRequest) {
const toolId = body.toolId
// Get existing settings
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (existing) {
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
// Add tool if not already present
if (!currentTools.includes(toolId)) {
const updatedTools = [...currentTools, toolId]
await db
@@ -89,7 +86,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, autoAllowedTools: currentTools })
}
// Create new settings record with the tool
await db.insert(settings).values({
id: userId,
userId,
@@ -109,7 +105,7 @@ export async function POST(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const session = await auth.api.getSession({ headers: request.headers })
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -123,7 +119,6 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 })
}
// Get existing settings
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (existing) {

View File

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

View File

@@ -1,6 +1,6 @@
import { createContext, Script } from 'vm'
import { type NextRequest, NextResponse } from 'next/server'
import { env, isTruthy } from '@/lib/core/config/env'
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
import { validateProxyUrl } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { executeInE2B } from '@/lib/execution/e2b'
@@ -701,7 +701,6 @@ export async function POST(req: NextRequest) {
resolvedCode = codeResolution.resolvedCode
const contextVariables = codeResolution.contextVariables
const e2bEnabled = isTruthy(env.E2B_ENABLED)
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
@@ -722,14 +721,14 @@ export async function POST(req: NextRequest) {
}
// Python always requires E2B
if (lang === CodeLanguage.Python && !e2bEnabled) {
if (lang === CodeLanguage.Python && !isE2bEnabled) {
throw new Error(
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
)
}
// JavaScript with imports requires E2B
if (lang === CodeLanguage.JavaScript && hasImports && !e2bEnabled) {
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
throw new Error(
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
)
@@ -740,13 +739,13 @@ export async function POST(req: NextRequest) {
// - Not a custom tool AND
// - (Python OR JavaScript with imports)
const useE2B =
e2bEnabled &&
isE2bEnabled &&
!isCustomTool &&
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports))
if (useE2B) {
logger.info(`[${requestId}] E2B status`, {
enabled: e2bEnabled,
enabled: isE2bEnabled,
hasApiKey: Boolean(process.env.E2B_API_KEY),
language: lang,
})

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

View File

@@ -1,26 +1,81 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { validateAuthToken } from '@/app/api/chat/utils'
const logger = createLogger('ProxyTTSStreamAPI')
export async function POST(request: NextRequest) {
/**
* Validates chat-based authentication for deployed chat voice mode
* Checks if the user has a valid chat auth cookie for the given chatId
*/
async function validateChatAuth(request: NextRequest, chatId: string): Promise<boolean> {
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.error('Authentication failed for TTS stream proxy:', authResult.error)
return new Response('Unauthorized', { status: 401 })
const chatResult = await db
.select({
id: chat.id,
isActive: chat.isActive,
authType: chat.authType,
password: chat.password,
})
.from(chat)
.where(eq(chat.id, chatId))
.limit(1)
if (chatResult.length === 0 || !chatResult[0].isActive) {
logger.warn('Chat not found or inactive for TTS auth:', chatId)
return false
}
const body = await request.json()
const { text, voiceId, modelId = 'eleven_turbo_v2_5' } = body
const chatData = chatResult[0]
if (chatData.authType === 'public') {
return true
}
const cookieName = `chat_auth_${chatId}`
const authCookie = request.cookies.get(cookieName)
if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) {
return true
}
return false
} catch (error) {
logger.error('Error validating chat auth for TTS:', error)
return false
}
}
export async function POST(request: NextRequest) {
try {
let body: any
try {
body = await request.json()
} catch {
return new Response('Invalid request body', { status: 400 })
}
const { text, voiceId, modelId = 'eleven_turbo_v2_5', chatId } = body
if (!chatId) {
return new Response('chatId is required', { status: 400 })
}
if (!text || !voiceId) {
return new Response('Missing required parameters', { status: 400 })
}
const isChatAuthed = await validateChatAuth(request, chatId)
if (!isChatAuthed) {
logger.warn('Chat authentication failed for TTS, chatId:', chatId)
return new Response('Unauthorized', { status: 401 })
}
const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255)
if (!voiceIdValidation.isValid) {
logger.error(`Invalid voice ID: ${voiceIdValidation.error}`)

View File

@@ -1,6 +1,7 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { StorageService } from '@/lib/uploads'
@@ -147,6 +148,10 @@ export async function POST(request: NextRequest) {
{ status: 400 }
)
}
const voiceIdValidation = validateAlphanumericId(body.voiceId, 'voiceId')
if (!voiceIdValidation.isValid) {
return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 })
}
const result = await synthesizeWithElevenLabs({
text,
apiKey,

View File

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

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

View File

@@ -23,13 +23,13 @@ export async function GET() {
if (!response.ok) {
console.warn('GitHub API request failed:', response.status)
return NextResponse.json({ stars: formatStarCount(14500) })
return NextResponse.json({ stars: formatStarCount(19400) })
}
const data = await response.json()
return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 14500)) })
return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 19400)) })
} catch (error) {
console.warn('Error fetching GitHub stars:', error)
return NextResponse.json({ stars: formatStarCount(14500) })
return NextResponse.json({ stars: formatStarCount(19400) })
}
}

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

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateNumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
@@ -41,6 +42,17 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const validatedData = DiscordSendMessageSchema.parse(body)
const channelIdValidation = validateNumericId(validatedData.channelId, 'channelId')
if (!channelIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid channelId format`, {
error: channelIdValidation.error,
})
return NextResponse.json(
{ success: false, error: channelIdValidation.error },
{ status: 400 }
)
}
logger.info(`[${requestId}] Sending Discord message`, {
channelId: validatedData.channelId,
hasFiles: !!(validatedData.files && validatedData.files.length > 0),

View File

@@ -1,32 +1,55 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebflowCollectionsAPI')
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const requestId = generateRequestId()
const body = await request.json()
const { credential, workflowId, siteId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const { searchParams } = new URL(request.url)
const siteId = searchParams.get('siteId')
if (!siteId) {
return NextResponse.json({ error: 'Missing siteId parameter' }, { status: 400 })
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
if (!siteIdValidation.isValid) {
logger.error('Invalid siteId', { error: siteIdValidation.error })
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
}
const accessToken = await getOAuthToken(session.user.id, 'webflow')
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
{ status: 404 }
{
error: 'Could not retrieve access token',
authRequired: true,
},
{ status: 401 }
)
}
@@ -58,11 +81,11 @@ export async function GET(request: NextRequest) {
name: collection.displayName || collection.slug || collection.id,
}))
return NextResponse.json({ collections: formattedCollections }, { status: 200 })
} catch (error: any) {
logger.error('Error fetching Webflow collections', error)
return NextResponse.json({ collections: formattedCollections })
} catch (error) {
logger.error('Error processing Webflow collections request:', error)
return NextResponse.json(
{ error: 'Internal server error', details: error.message },
{ error: 'Failed to retrieve Webflow collections', details: (error as Error).message },
{ status: 500 }
)
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebflowItemsAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
try {
const requestId = generateRequestId()
const body = await request.json()
const { credential, workflowId, collectionId, search } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const collectionIdValidation = validateAlphanumericId(collectionId, 'collectionId')
if (!collectionIdValidation.isValid) {
logger.error('Invalid collectionId', { error: collectionIdValidation.error })
return NextResponse.json({ error: collectionIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',
authRequired: true,
},
{ status: 401 }
)
}
const response = await fetch(
`https://api.webflow.com/v2/collections/${collectionId}/items?limit=100`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
accept: 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Webflow items', {
status: response.status,
error: errorData,
collectionId,
})
return NextResponse.json(
{ error: 'Failed to fetch Webflow items', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const items = data.items || []
let formattedItems = items.map((item: any) => {
const fieldData = item.fieldData || {}
const name = fieldData.name || fieldData.title || fieldData.slug || item.id
return {
id: item.id,
name,
}
})
if (search) {
const searchLower = search.toLowerCase()
formattedItems = formattedItems.filter((item: { id: string; name: string }) =>
item.name.toLowerCase().includes(searchLower)
)
}
return NextResponse.json({ items: formattedItems })
} catch (error) {
logger.error('Error processing Webflow items request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Webflow items', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -1,25 +1,48 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebflowSitesAPI')
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const requestId = generateRequestId()
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const accessToken = await getOAuthToken(session.user.id, 'webflow')
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
{ status: 404 }
{
error: 'Could not retrieve access token',
authRequired: true,
},
{ status: 401 }
)
}
@@ -50,11 +73,11 @@ export async function GET(request: NextRequest) {
name: site.displayName || site.shortName || site.id,
}))
return NextResponse.json({ sites: formattedSites }, { status: 200 })
} catch (error: any) {
logger.error('Error fetching Webflow sites', error)
return NextResponse.json({ sites: formattedSites })
} catch (error) {
logger.error('Error processing Webflow sites request:', error)
return NextResponse.json(
{ error: 'Internal server error', details: error.message },
{ error: 'Failed to retrieve Webflow sites', details: (error as Error).message },
{ status: 500 }
)
}

View File

@@ -35,19 +35,18 @@
* GET /api/v1/admin/organizations/:id - Get organization details
* PATCH /api/v1/admin/organizations/:id - Update organization
* GET /api/v1/admin/organizations/:id/members - List organization members
* POST /api/v1/admin/organizations/:id/members - Add/update member in organization
* POST /api/v1/admin/organizations/:id/members - Add/update member (validates seat availability)
* GET /api/v1/admin/organizations/:id/members/:mid - Get member details
* PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role
* DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member
* GET /api/v1/admin/organizations/:id/billing - Get org billing summary
* PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit
* GET /api/v1/admin/organizations/:id/seats - Get seat analytics
* PATCH /api/v1/admin/organizations/:id/seats - Update seat count
*
* Subscriptions:
* GET /api/v1/admin/subscriptions - List all subscriptions
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* PATCH /api/v1/admin/subscriptions/:id - Update subscription
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'

View File

@@ -12,6 +12,9 @@
* POST /api/v1/admin/organizations/[id]/members
*
* Add a user to an organization with full billing logic.
* Validates seat availability before adding (uses same logic as invitation flow):
* - Team plans: checks seats column
* - Enterprise plans: checks metadata.seats
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
* If user is already a member, updates their role if different.
*
@@ -29,6 +32,7 @@ import { db } from '@sim/db'
import { member, organization, user, userStats } from '@sim/db/schema'
import { count, eq } from 'drizzle-orm'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -223,6 +227,29 @@ export const POST = withAdminAuthParams<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,26 +4,12 @@
* 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,
@@ -75,122 +61,3 @@ 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>
*
* PATCH /api/v1/admin/subscriptions/[id]
* DELETE /api/v1/admin/subscriptions/[id]
*
* Update subscription details with optional side effects.
* Cancel a subscription by triggering Stripe cancellation.
* The Stripe webhook handles all cleanup (same as platform cancellation):
* - Updates subscription status to canceled
* - Bills final period overages
* - Resets usage
* - Restores member Pro subscriptions (for team/enterprise)
* - Deletes organization (for team/enterprise)
* - Syncs usage limits to free tier
*
* Body:
* - plan?: string - New plan (free, pro, team, enterprise)
* - status?: string - New status (active, canceled, etc.)
* - seats?: number - Seat count (for team plans)
* - metadata?: object - Subscription metadata (for enterprise)
* - periodStart?: string - Period start (ISO date)
* - periodEnd?: string - Period end (ISO date)
* - cancelAtPeriodEnd?: boolean - Cancel at period end flag
* - syncLimits?: boolean - Sync usage limits for affected users (default: false)
* - reason?: string - Reason for the change (for audit logging)
* Query Parameters:
* - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false)
* - reason?: string - Reason for cancellation (for audit logging)
*
* Response: AdminSingleResponse<AdminSubscription & { sideEffects }>
* Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean }
*/
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { subscription } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -43,9 +43,6 @@ interface RouteParams {
id: string
}
const VALID_PLANS = ['free', 'pro', 'team', 'enterprise']
const VALID_STATUSES = ['active', 'canceled', 'past_due', 'unpaid', 'trialing', 'incomplete']
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: subscriptionId } = await context.params
@@ -69,14 +66,13 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
export const DELETE = 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)
@@ -87,150 +83,70 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
return notFoundResponse('Subscription')
}
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.status === 'canceled') {
return badRequestResponse('Subscription is already canceled')
}
if (body.status !== undefined) {
if (!VALID_STATUSES.includes(body.status)) {
return badRequestResponse(`status must be one of: ${VALID_STATUSES.join(', ')}`)
}
if (body.status !== existing.status) {
warnings.push(
`Status change from ${existing.status} to ${body.status}. This does NOT update Stripe - manual sync required.`
)
}
updateData.status = body.status
if (!existing.stripeSubscriptionId) {
return badRequestResponse('Subscription has no Stripe subscription ID')
}
if (body.seats !== undefined) {
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
return badRequestResponse('seats must be a positive integer')
}
updateData.seats = body.seats
const stripe = requireStripeClient()
if (atPeriodEnd) {
// Schedule cancellation at period end
await stripe.subscriptions.update(existing.stripeSubscriptionId, {
cancel_at_period_end: true,
})
// Update DB (webhooks don't sync cancelAtPeriodEnd)
await db
.update(subscription)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscription.id, subscriptionId))
logger.info('Admin API: Scheduled subscription cancellation at period end', {
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
plan: existing.plan,
referenceId: existing.referenceId,
periodEnd: existing.periodEnd,
reason,
})
return singleResponse({
success: true,
message: 'Subscription scheduled to cancel at period end.',
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
atPeriodEnd: true,
periodEnd: existing.periodEnd?.toISOString() ?? null,
})
}
if (body.metadata !== undefined) {
if (typeof body.metadata !== 'object' || body.metadata === null) {
return badRequestResponse('metadata must be an object')
}
updateData.metadata = {
...((existing.metadata as Record<string, unknown>) || {}),
...body.metadata,
}
}
// Immediate cancellation
await stripe.subscriptions.cancel(existing.stripeSubscriptionId, {
prorate: true,
invoice_now: true,
})
if (body.periodStart !== undefined) {
const date = new Date(body.periodStart)
if (Number.isNaN(date.getTime())) {
return badRequestResponse('periodStart must be a valid ISO date')
}
updateData.periodStart = date
}
if (body.periodEnd !== undefined) {
const date = new Date(body.periodEnd)
if (Number.isNaN(date.getTime())) {
return badRequestResponse('periodEnd must be a valid ISO date')
}
updateData.periodEnd = date
}
if (body.cancelAtPeriodEnd !== undefined) {
if (typeof body.cancelAtPeriodEnd !== 'boolean') {
return badRequestResponse('cancelAtPeriodEnd must be a boolean')
}
updateData.cancelAtPeriodEnd = body.cancelAtPeriodEnd
}
if (Object.keys(updateData).length === 0) {
return badRequestResponse('No valid fields to update')
}
const [updated] = await db
.update(subscription)
.set(updateData)
.where(eq(subscription.id, subscriptionId))
.returning()
const sideEffects: {
limitsSynced: boolean
usersAffected: string[]
errors: string[]
} = {
limitsSynced: false,
usersAffected: [],
errors: [],
}
if (syncLimits) {
try {
const referenceId = updated.referenceId
if (['free', 'pro'].includes(updated.plan)) {
await syncUsageLimitsFromSubscription(referenceId)
sideEffects.usersAffected.push(referenceId)
sideEffects.limitsSynced = true
} else if (['team', 'enterprise'].includes(updated.plan)) {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, referenceId))
for (const m of members) {
try {
await syncUsageLimitsFromSubscription(m.userId)
sideEffects.usersAffected.push(m.userId)
} catch (memberError) {
sideEffects.errors.push(`Failed to sync limits for user ${m.userId}`)
logger.error('Admin API: Failed to sync limits for member', {
userId: m.userId,
error: memberError,
})
}
}
sideEffects.limitsSynced = members.length > 0
}
logger.info('Admin API: Synced usage limits after subscription update', {
subscriptionId,
usersAffected: sideEffects.usersAffected.length,
})
} catch (syncError) {
sideEffects.errors.push('Failed to sync usage limits')
logger.error('Admin API: Failed to sync usage limits', {
subscriptionId,
error: syncError,
})
}
}
logger.info(`Admin API: Updated subscription ${subscriptionId}`, {
fields: Object.keys(updateData),
previousPlan: existing.plan,
previousStatus: existing.status,
syncLimits,
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
plan: existing.plan,
referenceId: existing.referenceId,
reason,
})
return singleResponse({
...toAdminSubscription(updated),
sideEffects,
warnings,
success: true,
message: 'Subscription cancellation triggered. Webhook will complete cleanup.',
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
atPeriodEnd: false,
})
} catch (error) {
logger.error('Admin API: Failed to update subscription', { error, subscriptionId })
return internalErrorResponse('Failed to update subscription')
logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId })
return internalErrorResponse('Failed to cancel subscription')
}
})

View File

@@ -1,5 +1,7 @@
import type { NextRequest } from 'next/server'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('V1Auth')
@@ -13,6 +15,14 @@ export interface AuthResult {
}
export async function authenticateV1Request(request: NextRequest): Promise<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/environment'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { getModelPricing } from '@/providers/utils'

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 { env, isTruthy } from '@/lib/core/config/env'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -236,9 +236,8 @@ type AsyncExecutionParams = {
*/
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, input, triggerType } = params
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (!useTrigger) {
if (!isTriggerDevEnabled) {
logger.warn(`[${requestId}] Async mode requested but TRIGGER_DEV_ENABLED is false`)
return NextResponse.json(
{ error: 'Async execution is not enabled. Set TRIGGER_DEV_ENABLED=true to use async mode.' },

View File

@@ -39,6 +39,7 @@ interface ChatConfig {
interface AudioStreamingOptions {
voiceId: string
chatId?: string
onError: (error: Error) => void
}
@@ -62,16 +63,19 @@ function fileToBase64(file: File): Promise<string> {
* Creates an audio stream handler for text-to-speech conversion
* @param streamTextToAudio - Function to stream text to audio
* @param voiceId - The voice ID to use for TTS
* @param chatId - Optional chat ID for deployed chat authentication
* @returns Audio stream handler function or undefined
*/
function createAudioStreamHandler(
streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise<void>,
voiceId: string
voiceId: string,
chatId?: string
) {
return async (text: string) => {
try {
await streamTextToAudio(text, {
voiceId,
chatId,
onError: (error: Error) => {
logger.error('Audio streaming error:', error)
},
@@ -113,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const [starCount, setStarCount] = useState('3.4k')
const [starCount, setStarCount] = useState('19.4k')
const [conversationId, setConversationId] = useState('')
const [showScrollButton, setShowScrollButton] = useState(false)
@@ -391,7 +395,11 @@ export default function ChatClient({ identifier }: { identifier: string }) {
// Use the streaming hook with audio support
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
const audioHandler = shouldPlayAudio
? createAudioStreamHandler(streamTextToAudio, DEFAULT_VOICE_SETTINGS.voiceId)
? createAudioStreamHandler(
streamTextToAudio,
DEFAULT_VOICE_SETTINGS.voiceId,
chatConfig?.id
)
: undefined
logger.info('Starting to handle streamed response:', { shouldPlayAudio })

View File

@@ -68,7 +68,6 @@ export function VoiceInterface({
messages = [],
className,
}: VoiceInterfaceProps) {
// Simple state machine
const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle')
const [isInitialized, setIsInitialized] = useState(false)
const [isMuted, setIsMuted] = useState(false)
@@ -76,12 +75,10 @@ export function VoiceInterface({
const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>(
'prompt'
)
// Current turn transcript (subtitle)
const [currentTranscript, setCurrentTranscript] = useState('')
// State tracking
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
const isCallEndedRef = useRef(false)
useEffect(() => {
currentStateRef.current = state
@@ -98,12 +95,10 @@ export function VoiceInterface({
const isSupported =
typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition)
// Update muted ref
useEffect(() => {
isMutedRef.current = isMuted
}, [isMuted])
// Timeout to handle cases where agent doesn't provide audio response
const setResponseTimeout = useCallback(() => {
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
@@ -113,7 +108,7 @@ export function VoiceInterface({
if (currentStateRef.current === 'listening') {
setState('idle')
}
}, 5000) // 5 second timeout (increased from 3)
}, 5000)
}, [])
const clearResponseTimeout = useCallback(() => {
@@ -123,14 +118,12 @@ export function VoiceInterface({
}
}, [])
// Sync with external state
useEffect(() => {
if (isPlayingAudio && state !== 'agent_speaking') {
clearResponseTimeout() // Clear timeout since agent is responding
clearResponseTimeout()
setState('agent_speaking')
setCurrentTranscript('')
// Mute microphone immediately
setIsMuted(true)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
@@ -138,7 +131,6 @@ export function VoiceInterface({
})
}
// Stop speech recognition completely
if (recognitionRef.current) {
try {
recognitionRef.current.abort()
@@ -150,7 +142,6 @@ export function VoiceInterface({
setState('idle')
setCurrentTranscript('')
// Re-enable microphone
setIsMuted(false)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
@@ -160,7 +151,6 @@ export function VoiceInterface({
}
}, [isPlayingAudio, state, clearResponseTimeout])
// Audio setup
const setupAudio = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
@@ -175,7 +165,6 @@ export function VoiceInterface({
setPermissionStatus('granted')
mediaStreamRef.current = stream
// Setup audio context for visualization
if (!audioContextRef.current) {
const AudioContext = window.AudioContext || window.webkitAudioContext
audioContextRef.current = new AudioContext()
@@ -194,7 +183,6 @@ export function VoiceInterface({
source.connect(analyser)
analyserRef.current = analyser
// Start visualization
const updateVisualization = () => {
if (!analyserRef.current) return
@@ -223,7 +211,6 @@ export function VoiceInterface({
}
}, [])
// Speech recognition setup
const setupSpeechRecognition = useCallback(() => {
if (!isSupported) return
@@ -259,14 +246,11 @@ export function VoiceInterface({
}
}
// Update live transcript
setCurrentTranscript(interimTranscript || finalTranscript)
// Send final transcript (but keep listening state until agent responds)
if (finalTranscript.trim()) {
setCurrentTranscript('') // Clear transcript
setCurrentTranscript('')
// Stop recognition to avoid interference while waiting for response
if (recognitionRef.current) {
try {
recognitionRef.current.stop()
@@ -275,7 +259,6 @@ export function VoiceInterface({
}
}
// Start timeout in case agent doesn't provide audio response
setResponseTimeout()
onVoiceTranscript?.(finalTranscript)
@@ -283,13 +266,14 @@ export function VoiceInterface({
}
recognition.onend = () => {
if (isCallEndedRef.current) return
const currentState = currentStateRef.current
// Only restart recognition if we're in listening state and not muted
if (currentState === 'listening' && !isMutedRef.current) {
// Add a delay to avoid immediate restart after sending transcript
setTimeout(() => {
// Double-check state hasn't changed during delay
if (isCallEndedRef.current) return
if (
recognitionRef.current &&
currentStateRef.current === 'listening' &&
@@ -301,14 +285,12 @@ export function VoiceInterface({
logger.debug('Error restarting speech recognition:', error)
}
}
}, 1000) // Longer delay to give agent time to respond
}, 1000)
}
}
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
// Filter out "aborted" errors - these are expected when we intentionally stop recognition
if (event.error === 'aborted') {
// Ignore
return
}
@@ -320,7 +302,6 @@ export function VoiceInterface({
recognitionRef.current = recognition
}, [isSupported, onVoiceTranscript, setResponseTimeout])
// Start/stop listening
const startListening = useCallback(() => {
if (!isInitialized || isMuted || state !== 'idle') {
return
@@ -351,17 +332,12 @@ export function VoiceInterface({
}
}, [])
// Handle interrupt
const handleInterrupt = useCallback(() => {
if (state === 'agent_speaking') {
// Clear any subtitle timeouts and text
// (No longer needed after removing subtitle system)
onInterrupt?.()
setState('listening')
setCurrentTranscript('')
// Unmute microphone for user input
setIsMuted(false)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
@@ -369,7 +345,6 @@ export function VoiceInterface({
})
}
// Start listening immediately
if (recognitionRef.current) {
try {
recognitionRef.current.start()
@@ -380,14 +355,13 @@ export function VoiceInterface({
}
}, [state, onInterrupt])
// Handle call end with proper cleanup
const handleCallEnd = useCallback(() => {
// Stop everything immediately
isCallEndedRef.current = true
setState('idle')
setCurrentTranscript('')
setIsMuted(false)
// Stop speech recognition
if (recognitionRef.current) {
try {
recognitionRef.current.abort()
@@ -396,17 +370,11 @@ export function VoiceInterface({
}
}
// Clear timeouts
clearResponseTimeout()
// Stop audio playback and streaming immediately
onInterrupt?.()
// Call the original onCallEnd
onCallEnd?.()
}, [onCallEnd, onInterrupt, clearResponseTimeout])
// Keyboard handler
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === 'Space') {
@@ -419,7 +387,6 @@ export function VoiceInterface({
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleInterrupt])
// Mute toggle
const toggleMute = useCallback(() => {
if (state === 'agent_speaking') {
handleInterrupt()
@@ -442,7 +409,6 @@ export function VoiceInterface({
}
}, [isMuted, state, handleInterrupt, stopListening, startListening])
// Initialize
useEffect(() => {
if (isSupported) {
setupSpeechRecognition()
@@ -450,47 +416,40 @@ export function VoiceInterface({
}
}, [isSupported, setupSpeechRecognition, setupAudio])
// Auto-start listening when ready
useEffect(() => {
if (isInitialized && !isMuted && state === 'idle') {
startListening()
}
}, [isInitialized, isMuted, state, startListening])
// Cleanup when call ends or component unmounts
useEffect(() => {
return () => {
// Stop speech recognition
isCallEndedRef.current = true
if (recognitionRef.current) {
try {
recognitionRef.current.abort()
} catch (error) {
} catch (_e) {
// Ignore
}
recognitionRef.current = null
}
// Stop media stream
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => {
track.stop()
})
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
mediaStreamRef.current = null
}
// Stop audio context
if (audioContextRef.current) {
audioContextRef.current.close()
audioContextRef.current = null
}
// Cancel animation frame
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
// Clear timeouts
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
responseTimeoutRef.current = null
@@ -498,7 +457,6 @@ export function VoiceInterface({
}
}, [])
// Get status text
const getStatusText = () => {
switch (state) {
case 'listening':
@@ -510,7 +468,6 @@ export function VoiceInterface({
}
}
// Get button content
const getButtonContent = () => {
if (state === 'agent_speaking') {
return (
@@ -524,9 +481,7 @@ export function VoiceInterface({
return (
<div className={cn('fixed inset-0 z-[100] flex flex-col bg-white text-gray-900', className)}>
{/* Main content */}
<div className='flex flex-1 flex-col items-center justify-center px-8'>
{/* Voice visualization */}
<div className='relative mb-16'>
<ParticlesVisualization
audioLevels={audioLevels}
@@ -538,7 +493,6 @@ export function VoiceInterface({
/>
</div>
{/* Live transcript - subtitle style */}
<div className='mb-16 flex h-24 items-center justify-center'>
{currentTranscript && (
<div className='max-w-2xl px-8'>
@@ -549,17 +503,14 @@ export function VoiceInterface({
)}
</div>
{/* Status */}
<p className='mb-8 text-center text-gray-600 text-lg'>
{getStatusText()}
{isMuted && <span className='ml-2 text-gray-400 text-sm'>(Muted)</span>}
</p>
</div>
{/* Controls */}
<div className='px-8 pb-12'>
<div className='flex items-center justify-center space-x-12'>
{/* End call */}
<Button
onClick={handleCallEnd}
variant='outline'
@@ -569,7 +520,6 @@ export function VoiceInterface({
<Phone className='h-6 w-6 rotate-[135deg]' />
</Button>
{/* Mic/Stop button */}
<Button
onClick={toggleMute}
variant='outline'

View File

@@ -14,6 +14,7 @@ declare global {
interface AudioStreamingOptions {
voiceId: string
modelId?: string
chatId?: string
onAudioStart?: () => void
onAudioEnd?: () => void
onError?: (error: Error) => void
@@ -76,7 +77,14 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
}
const { text, options } = item
const { voiceId, modelId = 'eleven_turbo_v2_5', onAudioStart, onAudioEnd, onError } = options
const {
voiceId,
modelId = 'eleven_turbo_v2_5',
chatId,
onAudioStart,
onAudioEnd,
onError,
} = options
try {
const audioContext = getAudioContext()
@@ -93,6 +101,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
text,
voiceId,
modelId,
chatId,
}),
signal: abortControllerRef.current?.signal,
})

View File

@@ -262,6 +262,24 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'sharing.write': 'Share files and folders with others',
// WordPress.com scopes
global: 'Full access to manage your WordPress.com sites, posts, pages, media, and settings',
// Spotify scopes
'user-read-private': 'View your Spotify account details',
'user-read-email': 'View your email address on Spotify',
'user-library-read': 'View your saved tracks and albums',
'user-library-modify': 'Save and remove tracks and albums from your library',
'playlist-read-private': 'View your private playlists',
'playlist-read-collaborative': 'View collaborative playlists you have access to',
'playlist-modify-public': 'Create and manage your public playlists',
'playlist-modify-private': 'Create and manage your private playlists',
'user-read-playback-state': 'View your current playback state',
'user-modify-playback-state': 'Control playback on your Spotify devices',
'user-read-currently-playing': 'View your currently playing track',
'user-read-recently-played': 'View your recently played tracks',
'user-top-read': 'View your top artists and tracks',
'user-follow-read': 'View artists and users you follow',
'user-follow-modify': 'Follow and unfollow artists and users',
'user-read-playback-position': 'View your playback position in podcasts',
'ugc-image-upload': 'Upload images to your Spotify playlists',
}
function getScopeDescription(scope: string): string {

View File

@@ -47,12 +47,16 @@ 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'
@@ -75,6 +79,8 @@ 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,
@@ -84,6 +90,8 @@ export function FileSelectorInput({
projectIdValue,
planIdValue,
teamIdValue,
siteIdValue,
collectionIdValue,
])
const missingCredential = !normalizedCredentialId
@@ -97,6 +105,10 @@ 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 ||
@@ -105,6 +117,8 @@ export function FileSelectorInput({
missingDomain ||
missingProject ||
missingPlan ||
missingSite ||
missingCollection ||
!selectorResolution?.key
if (!selectorResolution?.key) {

View File

@@ -43,14 +43,12 @@ 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,
@@ -65,7 +63,6 @@ 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
@@ -84,19 +81,11 @@ export function ProjectSelectorInput({
const selectorResolution = useMemo(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl || undefined,
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
credentialId: (connectedCredential as string) || undefined,
domain,
teamId: (linearTeamId as string) || undefined,
})
}, [
subBlock,
workflowIdFromUrl,
isLinear,
linearCredential,
jiraCredential,
domain,
linearTeamId,
])
}, [subBlock, workflowIdFromUrl, connectedCredential, domain, linearTeamId])
const missingCredential = !selectorResolution?.context.credentialId

View File

@@ -47,12 +47,16 @@ 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'
@@ -75,6 +79,8 @@ 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,
@@ -84,6 +90,8 @@ export function FileSelectorInput({
projectIdValue,
planIdValue,
teamIdValue,
siteIdValue,
collectionIdValue,
])
const missingCredential = !normalizedCredentialId
@@ -97,6 +105,10 @@ 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 ||
@@ -105,6 +117,8 @@ export function FileSelectorInput({
missingDomain ||
missingProject ||
missingPlan ||
missingSite ||
missingCollection ||
!selectorResolution?.key
if (!selectorResolution?.key) {

View File

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

View File

@@ -16,6 +16,7 @@ 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'
@@ -59,6 +60,7 @@ 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)
@@ -461,10 +463,12 @@ export function General({ onOpenChange }: GeneralProps) {
</div>
)}
<div className='mt-auto flex items-center gap-[8px]'>
<Button onClick={handleSignOut}>Sign out</Button>
<Button onClick={() => setShowResetPasswordModal(true)}>Reset password</Button>
</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>
)}
{/* 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/environment'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'

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/environment'
import { isHosted } from '@/lib/core/config/feature-flags'
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/environment'
import { isHosted } from '@/lib/core/config/feature-flags'
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'

View File

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

View File

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

View File

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

View File

@@ -153,6 +153,14 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
value: () => 'spotify_search',
},
{
id: 'credential',
title: 'Spotify Account',
type: 'oauth-input',
serviceId: 'spotify',
required: true,
},
// === SEARCH ===
{
id: 'query',
@@ -647,15 +655,6 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
],
},
},
// === OAUTH CREDENTIAL ===
{
id: 'credential',
title: 'Spotify Account',
type: 'oauth-input',
serviceId: 'spotify',
required: true,
},
],
tools: {
access: [

View File

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

View File

@@ -39,19 +39,65 @@ 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',
dependsOn: ['credential'],
mode: 'advanced',
required: true,
},
{
id: 'itemId',
title: 'Item',
type: 'file-selector',
canonicalParamId: 'itemId',
serviceId: 'webflow',
placeholder: 'Select item',
dependsOn: ['credential', 'collectionId'],
mode: 'basic',
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
required: true,
},
{
id: 'manualItemId',
title: 'Item ID',
type: 'short-input',
placeholder: 'ID of the item',
canonicalParamId: 'itemId',
placeholder: 'Enter item ID',
mode: 'advanced',
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
required: true,
},
@@ -108,7 +154,17 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
}
},
params: (params) => {
const { credential, fieldData, ...rest } = params
const {
credential,
fieldData,
siteId,
manualSiteId,
collectionId,
manualCollectionId,
itemId,
manualItemId,
...rest
} = params
let parsedFieldData: any | undefined
try {
@@ -119,15 +175,46 @@ export const WebflowBlock: BlockConfig<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':
return { ...baseParams, fieldData: parsedFieldData }
if (params.operation === 'update' && !effectiveItemId) {
throw new Error('Item ID is required for update operation')
}
return {
...baseParams,
itemId: effectiveItemId || undefined,
fieldData: parsedFieldData,
}
case 'get':
case 'delete':
if (!effectiveItemId) {
throw new Error(`Item ID is required for ${params.operation} operation`)
}
return { ...baseParams, itemId: effectiveItemId }
default:
return baseParams
}
@@ -137,12 +224,15 @@ export const WebflowBlock: BlockConfig<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' },
// Conditional inputs
itemId: { type: 'string', description: 'Item identifier' }, // Required for get/update/delete
offset: { type: 'number', description: 'Pagination offset' }, // Optional for list
limit: { type: 'number', description: 'Maximum items to return' }, // Optional for list
fieldData: { type: 'json', description: 'Item field data' }, // Required for create/update
manualCollectionId: { type: 'string', description: 'Manual collection identifier' },
itemId: { type: 'string', description: 'Item identifier' },
manualItemId: { type: 'string', description: 'Manual item identifier' },
offset: { type: 'number', description: 'Pagination offset' },
limit: { type: 'number', description: 'Maximum items to return' },
fieldData: { type: 'json', description: 'Item field data' },
},
outputs: {
items: { type: 'json', description: 'Array of items (list operation)' },

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

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { isHosted } from '@/lib/core/config/environment'
import { getAllBlocks } from '@/blocks'
import { BlockType } from '@/executor/constants'
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
@@ -11,11 +10,11 @@ import { executeTool } from '@/tools'
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
vi.mock('@/lib/core/config/environment', () => ({
isHosted: vi.fn().mockReturnValue(false),
isProd: vi.fn().mockReturnValue(false),
isDev: vi.fn().mockReturnValue(true),
isTest: vi.fn().mockReturnValue(false),
vi.mock('@/lib/core/config/feature-flags', () => ({
isHosted: false,
isProd: false,
isDev: true,
isTest: false,
getCostMultiplier: vi.fn().mockReturnValue(1),
isEmailVerificationEnabled: false,
isBillingEnabled: false,
@@ -65,7 +64,6 @@ global.fetch = Object.assign(vi.fn(), { preconnect: vi.fn() }) as typeof fetch
const mockGetAllBlocks = getAllBlocks as Mock
const mockExecuteTool = executeTool as Mock
const mockIsHosted = isHosted as unknown as Mock
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockTransformBlockTool = transformBlockTool as Mock
const mockFetch = global.fetch as unknown as Mock
@@ -120,7 +118,6 @@ describe('AgentBlockHandler', () => {
loops: {},
} as SerializedWorkflow,
}
mockIsHosted.mockReturnValue(false)
mockGetProviderFromModel.mockReturnValue('mock-provider')
mockFetch.mockImplementation(() => {
@@ -552,8 +549,6 @@ describe('AgentBlockHandler', () => {
})
it('should not require API key for gpt-4o on hosted version', async () => {
mockIsHosted.mockReturnValue(true)
const inputs = {
model: 'gpt-4o',
systemPrompt: 'You are a helpful assistant.',

View File

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

View File

@@ -673,6 +673,99 @@ 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,6 +15,8 @@ export interface SelectorResolutionArgs {
planId?: string
teamId?: string
knowledgeBaseId?: string
siteId?: string
collectionId?: string
}
const defaultContext: SelectorContext = {}
@@ -52,6 +54,8 @@ function buildBaseContext(
planId: args.planId,
teamId: args.teamId,
knowledgeBaseId: args.knowledgeBaseId,
siteId: args.siteId,
collectionId: args.collectionId,
...extra,
}
}
@@ -106,6 +110,14 @@ function resolveFileSelector(
}
case 'sharepoint':
return { key: 'sharepoint.sites', context, allowSearch: true }
case 'webflow':
if (subBlock.id === 'collectionId') {
return { key: 'webflow.collections', context, allowSearch: false }
}
if (subBlock.id === 'itemId') {
return { key: 'webflow.items', context, allowSearch: true }
}
return { key: null, context, allowSearch: true }
default:
return { key: null, context, allowSearch: true }
}
@@ -159,6 +171,8 @@ function resolveProjectSelector(
}
case 'jira':
return { key: 'jira.projects', context, allowSearch: true }
case 'webflow':
return { key: 'webflow.sites', context, allowSearch: false }
default:
return { key: null, context, allowSearch: true }
}

View File

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

View File

@@ -0,0 +1,104 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'
const logger = createLogger('AnonymousAuth')
let anonymousUserEnsured = false
/**
* Ensures the anonymous user and their stats record exist in the database.
* Called when DISABLE_AUTH is enabled to ensure DB operations work.
*/
export async function ensureAnonymousUserExists(): Promise<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/environment'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider'
@@ -25,9 +25,9 @@ export const client = createAuthClient({
stripeClient({
subscription: true, // Enable subscription management
}),
organizationClient(),
]
: []),
organizationClient(),
...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []),
],
})
@@ -42,7 +42,9 @@ export function useSession(): SessionHookResult {
return ctx
}
export const { useActiveOrganization } = client
export const useActiveOrganization = isBillingEnabled
? client.useActiveOrganization
: () => ({ data: undefined, isPending: false, error: null })
export const useSubscription = () => {
return {

View File

@@ -38,13 +38,19 @@ import {
handleSubscriptionCreated,
handleSubscriptionDeleted,
} from '@/lib/billing/webhooks/subscription'
import { env, isTruthy } from '@/lib/core/config/env'
import { isBillingEnabled, isEmailVerificationEnabled } from '@/lib/core/config/environment'
import { env } from '@/lib/core/config/env'
import {
isAuthDisabled,
isBillingEnabled,
isEmailVerificationEnabled,
isRegistrationDisabled,
} from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth')
@@ -270,7 +276,7 @@ export const auth = betterAuth({
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith('/sign-up') && isTruthy(env.DISABLE_REGISTRATION))
if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled)
throw new Error('Registration is disabled, please contact your admin.')
if (
@@ -2087,14 +2093,6 @@ export const auth = betterAuth({
try {
await handleSubscriptionDeleted(subscription)
// Reset usage limits to free tier
await syncSubscriptionUsageLimits(subscription)
logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
})
} catch (error) {
logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', {
subscriptionId: subscription.id,
@@ -2193,6 +2191,11 @@ export const auth = betterAuth({
})
export async function getSession() {
if (isAuthDisabled) {
await ensureAnonymousUserExists()
return createAnonymousSession()
}
const hdrs = await headers()
return await auth.api.getSession({
headers: hdrs,

View File

@@ -0,0 +1,10 @@
/** Anonymous user ID used when DISABLE_AUTH is enabled */
export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000'
export const ANONYMOUS_USER = {
id: ANONYMOUS_USER_ID,
name: 'Anonymous',
email: 'anonymous@localhost',
emailVerified: true,
image: null,
} as const

View File

@@ -1 +1,4 @@
export type { AnonymousSession } from './anonymous'
export { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
export { auth, getSession, signIn, signUp } from './auth'
export { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'

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

View File

@@ -21,6 +21,131 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationMembership')
export interface RestoreProResult {
restored: boolean
usageRestored: boolean
subscriptionId?: string
}
/**
* Restore a user's personal Pro subscription if it was paused (cancelAtPeriodEnd=true).
* Also restores any snapshotted Pro usage from when they joined a team.
*
* Called when:
* - A member leaves a team (via removeUserFromOrganization)
* - A team subscription ends (members stay but get Pro restored)
*/
export async function restoreUserProSubscription(userId: string): Promise<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
@@ -409,7 +534,6 @@ export async function removeUserFromOrganization(
// STEP 3: Restore personal Pro if user has no remaining paid team memberships
if (!skipBillingLogic) {
try {
// Check for remaining paid team memberships
const remainingPaidTeams = await db
.select({ orgId: member.organizationId })
.from(member)
@@ -428,104 +552,10 @@ export async function removeUserFromOrganization(
)
}
// If no remaining paid teams, try to restore personal Pro
if (!hasAnyPaidTeam) {
const [personalPro] = await db
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, userId),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
)
)
.limit(1)
// Only restore if cancelAtPeriodEnd is true AND stripeSubscriptionId exists
if (
personalPro &&
personalPro.cancelAtPeriodEnd === true &&
personalPro.stripeSubscriptionId
) {
// Call Stripe API first (separate try/catch so failure doesn't prevent DB update)
try {
const stripe = requireStripeClient()
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
cancel_at_period_end: false,
})
} catch (stripeError) {
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
userId,
stripeSubscriptionId: personalPro.stripeSubscriptionId,
error: stripeError,
})
}
// Update DB (separate try/catch)
try {
await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, personalPro.id))
billingActions.proRestored = true
logger.info('Restored personal Pro after leaving last paid team', {
userId,
personalSubscriptionId: personalPro.id,
})
} catch (dbError) {
logger.error('DB update failed when restoring personal Pro', {
userId,
subscriptionId: personalPro.id,
error: dbError,
})
}
// Restore snapshotted Pro usage (separate try/catch)
try {
const [stats] = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (stats) {
const currentUsage = stats.currentPeriodCost || '0'
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
const currentNum = Number.parseFloat(currentUsage)
const snapshotNum = Number.parseFloat(snapshotUsage)
const restoredUsage = (currentNum + snapshotNum).toString()
await db
.update(userStats)
.set({
currentPeriodCost: restoredUsage,
proPeriodCostSnapshot: '0',
})
.where(eq(userStats.userId, userId))
billingActions.usageRestored = true
logger.info('Restored Pro usage after leaving team', {
userId,
previousUsage: currentUsage,
snapshotUsage: snapshotUsage,
restoredUsage: restoredUsage,
})
}
} catch (usageRestoreError) {
logger.error('Failed to restore Pro usage after leaving team', {
userId,
error: usageRestoreError,
})
}
}
const restoreResult = await restoreUserProSubscription(userId)
billingActions.proRestored = restoreResult.restored
billingActions.usageRestored = restoreResult.usageRestored
}
} catch (postRemoveError) {
logger.error('Post-removal personal Pro restore check failed', {

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

View File

@@ -1,7 +1,9 @@
import { db } from '@sim/db'
import { subscription } from '@sim/db/schema'
import { member, organization, subscription } from '@sim/db/schema'
import { and, eq, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
getBilledOverageForSubscription,
@@ -11,6 +13,71 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('StripeSubscriptionWebhooks')
/**
* Restore personal Pro subscriptions for all members of an organization
* when the team/enterprise subscription ends.
*/
async function restoreMemberProSubscriptions(organizationId: string): Promise<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
*/
@@ -98,12 +165,24 @@ export async function handleSubscriptionDeleted(subscription: {
const totalOverage = await calculateSubscriptionOverage(subscription)
const stripe = requireStripeClient()
// Enterprise plans have no overages - just reset usage
// Enterprise plans have no overages - reset usage and cleanup org
if (subscription.plan === 'enterprise') {
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
})
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
subscription.referenceId
)
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
restoredProCount,
organizationDeleted: true,
membersSynced,
})
return
}
@@ -209,13 +288,32 @@ export async function handleSubscriptionDeleted(subscription: {
referenceId: subscription.referenceId,
})
// Plan-specific cleanup after billing
let restoredProCount = 0
let organizationDeleted = false
let membersSynced = 0
if (subscription.plan === 'team') {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced
organizationDeleted = true
} else if (subscription.plan === 'pro') {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1
}
// Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler
// We only need to handle overage billing and usage reset
// We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup
logger.info('Successfully processed subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
plan: subscription.plan,
totalOverage,
restoredProCount,
organizationDeleted,
membersSynced,
})
} catch (error) {
logger.error('Failed to handle subscription deletion', {

View File

@@ -20,6 +20,7 @@ export const env = createEnv({
BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service
BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration
DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session)
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data

View File

@@ -35,6 +35,31 @@ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
*/
export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED)
/**
* Is authentication disabled (for self-hosted deployments behind private networks)
*/
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH)
/**
* Is user registration disabled
*/
export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION)
/**
* Is Trigger.dev enabled for async job processing
*/
export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
/**
* Is SSO enabled for enterprise authentication
*/
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
/**
* Is E2B enabled for remote code execution
*/
export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
/**
* Get cost multiplier based on environment
*/

View File

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

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 { env, isTruthy } from '@/lib/core/config/env'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkflowExecutionLog } from '@/lib/logs/types'
import {
@@ -140,9 +140,7 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog):
alertConfig: alertConfig || undefined,
}
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (useTrigger) {
if (isTriggerDevEnabled) {
await workspaceNotificationDeliveryTask.trigger(payload)
logger.info(
`Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev`

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/environment'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { filterForDisplay } from '@/lib/core/utils/display-filters'
import { createLogger } from '@/lib/logs/console/logger'

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

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 { env, isTruthy } from '@/lib/core/config/env'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { createLogger } from '@/lib/logs/console/logger'
import {
executeNotificationDelivery,
@@ -118,9 +118,7 @@ async function checkWorkflowInactivity(
alertConfig,
}
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
if (useTrigger) {
if (isTriggerDevEnabled) {
await workspaceNotificationDeliveryTask.trigger(payload)
} else {
void executeNotificationDelivery(payload).catch((error) => {

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

View File

@@ -11,7 +11,7 @@ export { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b
/**
* Horizontal spacing between layers (columns)
*/
export const DEFAULT_HORIZONTAL_SPACING = 350
export const DEFAULT_HORIZONTAL_SPACING = 250
/**
* Vertical spacing between blocks in the same layer

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