mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
12 Commits
fix/chat-t
...
fix/tool-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a08b0a33 | ||
|
|
5d3b216922 | ||
|
|
431f206930 | ||
|
|
7443e28054 | ||
|
|
c962e3b398 | ||
|
|
d5b95cbd33 | ||
|
|
0fb084b9e4 | ||
|
|
95b9ca4670 | ||
|
|
746ff68a2e | ||
|
|
75a5b43252 | ||
|
|
8d0e50fd0d | ||
|
|
f7d1b06d75 |
@@ -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,
|
||||
|
||||
173
apps/docs/app/api/og/route.tsx
Normal file
173
apps/docs/app/api/og/route.tsx
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
apps/docs/public/static/og-background.png
Normal file
BIN
apps/docs/public/static/og-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 583 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { isDev } from '@/lib/core/config/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'
|
||||
@@ -282,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' }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
106
apps/sim/app/api/tools/webflow/items/route.ts
Normal file
106
apps/sim/app/api/tools/webflow/items/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -762,7 +762,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
outputs: {
|
||||
// Common outputs across all Jira operations
|
||||
ts: { type: 'string', description: 'Timestamp of the operation' },
|
||||
success: { type: 'boolean', description: 'Whether the operation was successful' },
|
||||
|
||||
// jira_retrieve (read) outputs
|
||||
issueKey: { type: 'string', description: 'Issue key (e.g., PROJ-123)' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
104
apps/sim/lib/auth/anonymous.ts
Normal file
104
apps/sim/lib/auth/anonymous.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
apps/sim/lib/auth/constants.ts
Normal file
10
apps/sim/lib/auth/constants.ts
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextConfig } from 'next'
|
||||
import { env, getEnv, isTruthy } from './lib/core/config/env'
|
||||
import { isDev, isHosted } from './lib/core/config/environment'
|
||||
import { isDev, isHosted } from './lib/core/config/feature-flags'
|
||||
import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/core/security/csp'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCostMultiplier } from '@/lib/core/config/environment'
|
||||
import { getCostMultiplier } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import type { ProviderRequest, ProviderResponse } from '@/providers/types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as environmentModule from '@/lib/core/config/environment'
|
||||
import * as environmentModule from '@/lib/core/config/feature-flags'
|
||||
import {
|
||||
calculateCost,
|
||||
extractAndParseJSON,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { anthropicProvider } from '@/providers/anthropic'
|
||||
import { azureOpenAIProvider } from '@/providers/azure-openai'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getSessionCookie } from 'better-auth/cookies'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { isHosted } from './lib/core/config/environment'
|
||||
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
|
||||
import { generateRuntimeCSP } from './lib/core/security/csp'
|
||||
import { createLogger } from './lib/logs/console/logger'
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function proxy(request: NextRequest) {
|
||||
const url = request.nextUrl
|
||||
|
||||
const sessionCookie = getSessionCookie(request)
|
||||
const hasActiveSession = !!sessionCookie
|
||||
const hasActiveSession = isAuthDisabled || !!sessionCookie
|
||||
|
||||
const redirect = handleRootPathRedirects(request, hasActiveSession)
|
||||
if (redirect) return redirect
|
||||
|
||||
@@ -5,7 +5,7 @@ import { db } from '@sim/db'
|
||||
import { docsEmbeddings } from '@sim/db/schema'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { type DocChunk, DocsChunker } from '@/lib/chunkers'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ProcessDocs')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import { Server } from 'socket.io'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Socket } from 'socket.io'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SocketAuth')
|
||||
|
||||
// Extend Socket interface to include user data
|
||||
/**
|
||||
* Authenticated socket with user data attached.
|
||||
*/
|
||||
export interface AuthenticatedSocket extends Socket {
|
||||
userId?: string
|
||||
userName?: string
|
||||
@@ -13,9 +17,21 @@ export interface AuthenticatedSocket extends Socket {
|
||||
userImage?: string | null
|
||||
}
|
||||
|
||||
// Enhanced authentication middleware
|
||||
/**
|
||||
* Socket.IO authentication middleware.
|
||||
* Handles both anonymous mode (DISABLE_AUTH=true) and normal token-based auth.
|
||||
*/
|
||||
export async function authenticateSocket(socket: AuthenticatedSocket, next: any) {
|
||||
try {
|
||||
if (isAuthDisabled) {
|
||||
socket.userId = ANONYMOUS_USER_ID
|
||||
socket.userName = ANONYMOUS_USER.name
|
||||
socket.userEmail = ANONYMOUS_USER.email
|
||||
socket.userImage = ANONYMOUS_USER.image
|
||||
logger.debug(`Socket ${socket.id} authenticated as anonymous`)
|
||||
return next()
|
||||
}
|
||||
|
||||
// Extract authentication data from socket handshake
|
||||
const token = socket.handshake.auth?.token
|
||||
const origin = socket.handshake.headers.origin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTest } from '@/lib/core/config/environment'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { TableRow } from '@/tools/types'
|
||||
|
||||
@@ -163,14 +163,9 @@ export const jiraAddCommentTool: ToolConfig<JiraAddCommentParams, JiraAddComment
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Operation success status',
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Comment details with timestamp, issue key, comment ID, body, and success status',
|
||||
},
|
||||
ts: { type: 'string', description: 'Timestamp of the operation' },
|
||||
issueKey: { type: 'string', description: 'Issue key the comment was added to' },
|
||||
commentId: { type: 'string', description: 'Created comment ID' },
|
||||
body: { type: 'string', description: 'Comment text content' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -141,14 +141,8 @@ export const jiraAddWatcherTool: ToolConfig<JiraAddWatcherParams, JiraAddWatcher
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Operation success status',
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Watcher details with timestamp, issue key, watcher account ID, and success status',
|
||||
},
|
||||
ts: { type: 'string', description: 'Timestamp of the operation' },
|
||||
issueKey: { type: 'string', description: 'Issue key' },
|
||||
watcherAccountId: { type: 'string', description: 'Added watcher account ID' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -191,14 +191,9 @@ export const jiraAddWorklogTool: ToolConfig<JiraAddWorklogParams, JiraAddWorklog
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Operation success status',
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Worklog details with timestamp, issue key, worklog ID, time spent in seconds, and success status',
|
||||
},
|
||||
ts: { type: 'string', description: 'Timestamp of the operation' },
|
||||
issueKey: { type: 'string', description: 'Issue key the worklog was added to' },
|
||||
worklogId: { type: 'string', description: 'Created worklog ID' },
|
||||
timeSpentSeconds: { type: 'number', description: 'Time spent in seconds' },
|
||||
},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user