mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
3 Commits
fix/google
...
fix/linear
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e75f44b65 | ||
|
|
780b4fe22d | ||
|
|
c2180bf8a0 |
75
apps/docs/content/docs/en/enterprise/index.mdx
Normal file
75
apps/docs/content/docs/en/enterprise/index.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Enterprise
|
||||
description: Enterprise features for organizations with advanced security and compliance requirements
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
|
||||
|
||||
---
|
||||
|
||||
## Bring Your Own Key (BYOK)
|
||||
|
||||
Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
| Provider | Usage |
|
||||
|----------|-------|
|
||||
| OpenAI | Knowledge Base embeddings, Agent block |
|
||||
| Anthropic | Agent block |
|
||||
| Google | Agent block |
|
||||
| Mistral | Knowledge Base OCR |
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **BYOK** in your workspace
|
||||
2. Click **Add Key** for your provider
|
||||
3. Enter your API key and save
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK keys are encrypted at rest. Only organization admins and owners can manage keys.
|
||||
</Callout>
|
||||
|
||||
When configured, workflows use your key instead of Sim Studio's hosted keys. If removed, workflows automatically fall back to hosted keys.
|
||||
|
||||
---
|
||||
|
||||
## Single Sign-On (SSO)
|
||||
|
||||
Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
- Okta
|
||||
- Azure AD / Entra ID
|
||||
- Google Workspace
|
||||
- OneLogin
|
||||
- Any SAML 2.0 or OIDC provider
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **SSO** in your workspace
|
||||
2. Choose your identity provider
|
||||
3. Configure the connection using your IdP's metadata
|
||||
4. Enable SSO for your organization
|
||||
|
||||
<Callout type="info">
|
||||
Once SSO is enabled, team members authenticate through your identity provider instead of email/password.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted
|
||||
|
||||
For self-hosted deployments, enterprise features can be enabled via environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
|
||||
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
|
||||
</Callout>
|
||||
@@ -15,6 +15,7 @@
|
||||
"permissions",
|
||||
"sdks",
|
||||
"self-hosting",
|
||||
"./enterprise/index",
|
||||
"./keyboard-shortcuts/index"
|
||||
],
|
||||
"defaultOpen": false
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { auth, getSession } from '@/lib/auth'
|
||||
import { hasSSOAccess } from '@/lib/billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
|
||||
@@ -63,10 +64,22 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// SSO plugin must be enabled in Better Auth
|
||||
if (!env.SSO_ENABLED) {
|
||||
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check plan access (enterprise) or env var override
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const hasAccess = await hasSSOAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
|
||||
}
|
||||
|
||||
const rawBody = await request.json()
|
||||
|
||||
const parseResult = ssoRegistrationSchema.safeParse(rawBody)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -45,6 +46,15 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, invitationId } = await params
|
||||
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -47,6 +48,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getCredentialSetWithAccess(id, session.user.id)
|
||||
|
||||
@@ -69,6 +79,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
@@ -178,6 +197,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const invitationId = searchParams.get('invitationId')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
|
||||
const logger = createLogger('CredentialSetMembers')
|
||||
@@ -39,6 +40,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getCredentialSetWithAccess(id, session.user.id)
|
||||
|
||||
@@ -110,6 +120,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const memberId = searchParams.get('memberId')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('CredentialSet')
|
||||
|
||||
@@ -49,6 +50,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getCredentialSetWithAccess(id, session.user.id)
|
||||
|
||||
@@ -66,6 +76,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
@@ -129,6 +148,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, count, desc, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('CredentialSets')
|
||||
|
||||
@@ -22,6 +23,15 @@ export async function GET(req: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
@@ -85,6 +95,15 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check plan access (team/enterprise) or env var override
|
||||
const hasAccess = await hasCredentialSetsAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential sets require a Team or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body)
|
||||
|
||||
199
apps/sim/app/api/v1/admin/byok/route.ts
Normal file
199
apps/sim/app/api/v1/admin/byok/route.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Admin BYOK Keys API
|
||||
*
|
||||
* GET /api/v1/admin/byok
|
||||
* List all BYOK keys with optional filtering.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId?: string - Filter by organization ID (finds all workspaces billed to this org)
|
||||
* - workspaceId?: string - Filter by specific workspace ID
|
||||
*
|
||||
* Response: { data: AdminBYOKKey[], pagination: PaginationMeta }
|
||||
*
|
||||
* DELETE /api/v1/admin/byok
|
||||
* Delete BYOK keys for an organization or workspace.
|
||||
* Used when an enterprise plan churns to clean up BYOK keys.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId: string - Delete all BYOK keys for workspaces billed to this org
|
||||
* - workspaceId?: string - Delete keys for a specific workspace only (optional)
|
||||
*
|
||||
* Response: { success: true, deletedCount: number, workspacesAffected: string[] }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { user, workspace, workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray, sql } from 'drizzle-orm'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminBYOKAPI')
|
||||
|
||||
export interface AdminBYOKKey {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
organizationId: string
|
||||
providerId: string
|
||||
createdAt: string
|
||||
createdByUserId: string | null
|
||||
createdByEmail: string | null
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
let workspaceIds: string[] = []
|
||||
|
||||
if (workspaceId) {
|
||||
workspaceIds = [workspaceId]
|
||||
} else if (organizationId) {
|
||||
const workspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.billedAccountUserId, organizationId))
|
||||
|
||||
workspaceIds = workspaces.map((w) => w.id)
|
||||
}
|
||||
|
||||
const query = db
|
||||
.select({
|
||||
id: workspaceBYOKKeys.id,
|
||||
workspaceId: workspaceBYOKKeys.workspaceId,
|
||||
workspaceName: workspace.name,
|
||||
organizationId: workspace.billedAccountUserId,
|
||||
providerId: workspaceBYOKKeys.providerId,
|
||||
createdAt: workspaceBYOKKeys.createdAt,
|
||||
createdByUserId: workspaceBYOKKeys.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(workspaceBYOKKeys)
|
||||
.innerJoin(workspace, eq(workspaceBYOKKeys.workspaceId, workspace.id))
|
||||
.leftJoin(user, eq(workspaceBYOKKeys.createdBy, user.id))
|
||||
|
||||
let keys
|
||||
if (workspaceIds.length > 0) {
|
||||
keys = await query.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
|
||||
} else {
|
||||
keys = await query
|
||||
}
|
||||
|
||||
const formattedKeys: AdminBYOKKey[] = keys.map((k) => ({
|
||||
id: k.id,
|
||||
workspaceId: k.workspaceId,
|
||||
workspaceName: k.workspaceName,
|
||||
organizationId: k.organizationId,
|
||||
providerId: k.providerId,
|
||||
createdAt: k.createdAt.toISOString(),
|
||||
createdByUserId: k.createdByUserId,
|
||||
createdByEmail: k.createdByEmail,
|
||||
}))
|
||||
|
||||
logger.info('Admin API: Listed BYOK keys', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
count: formattedKeys.length,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
data: formattedKeys,
|
||||
pagination: {
|
||||
total: formattedKeys.length,
|
||||
limit: formattedKeys.length,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list BYOK keys', { error, organizationId, workspaceId })
|
||||
return internalErrorResponse('Failed to list BYOK keys')
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
|
||||
|
||||
if (!organizationId && !workspaceId) {
|
||||
return badRequestResponse('Either organizationId or workspaceId is required')
|
||||
}
|
||||
|
||||
try {
|
||||
let workspaceIds: string[] = []
|
||||
|
||||
if (workspaceId) {
|
||||
workspaceIds = [workspaceId]
|
||||
} else if (organizationId) {
|
||||
const workspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.billedAccountUserId, organizationId))
|
||||
|
||||
workspaceIds = workspaces.map((w) => w.id)
|
||||
}
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
logger.info('Admin API: No workspaces found for BYOK cleanup', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
})
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
workspacesAffected: [],
|
||||
message: 'No workspaces found for the given organization/workspace ID',
|
||||
})
|
||||
}
|
||||
|
||||
const countResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(workspaceBYOKKeys)
|
||||
.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
|
||||
|
||||
const totalToDelete = Number(countResult[0]?.count ?? 0)
|
||||
|
||||
if (totalToDelete === 0) {
|
||||
logger.info('Admin API: No BYOK keys to delete', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
workspaceIds,
|
||||
})
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
workspacesAffected: [],
|
||||
message: 'No BYOK keys found for the specified workspaces',
|
||||
})
|
||||
}
|
||||
|
||||
await db.delete(workspaceBYOKKeys).where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
|
||||
|
||||
logger.info('Admin API: Deleted BYOK keys', {
|
||||
organizationId,
|
||||
workspaceId,
|
||||
workspaceIds,
|
||||
deletedCount: totalToDelete,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: totalToDelete,
|
||||
workspacesAffected: workspaceIds,
|
||||
reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete BYOK keys', { error, organizationId, workspaceId })
|
||||
return internalErrorResponse('Failed to delete BYOK keys')
|
||||
}
|
||||
})
|
||||
@@ -51,6 +51,10 @@
|
||||
* GET /api/v1/admin/subscriptions - List all subscriptions
|
||||
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
||||
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
|
||||
*
|
||||
* BYOK Keys:
|
||||
* GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
|
||||
* DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
|
||||
*/
|
||||
|
||||
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
|
||||
|
||||
@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -56,6 +58,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let byokEnabled = true
|
||||
if (isHosted) {
|
||||
byokEnabled = await isEnterpriseOrgAdminOrOwner(userId)
|
||||
}
|
||||
|
||||
if (!byokEnabled) {
|
||||
return NextResponse.json({ keys: [], byokEnabled: false })
|
||||
}
|
||||
|
||||
const byokKeys = await db
|
||||
.select({
|
||||
id: workspaceBYOKKeys.id,
|
||||
@@ -97,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ keys: formattedKeys })
|
||||
return NextResponse.json({ keys: formattedKeys, byokEnabled: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] BYOK keys GET error`, error)
|
||||
return NextResponse.json(
|
||||
@@ -120,6 +131,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
if (isHosted) {
|
||||
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
|
||||
if (!canManageBYOK) {
|
||||
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
@@ -220,6 +245,20 @@ export async function DELETE(
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
if (isHosted) {
|
||||
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
|
||||
if (!canManageBYOK) {
|
||||
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Crown, Eye, EyeOff } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -81,7 +81,9 @@ export function BYOK() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
|
||||
const { data: keys = [], isLoading } = useBYOKKeys(workspaceId)
|
||||
const { data, isLoading } = useBYOKKeys(workspaceId)
|
||||
const keys = data?.keys ?? []
|
||||
const byokEnabled = data?.byokEnabled ?? true
|
||||
const upsertKey = useUpsertBYOKKey()
|
||||
const deleteKey = useDeleteBYOKKey()
|
||||
|
||||
@@ -96,6 +98,31 @@ export function BYOK() {
|
||||
return keys.find((k) => k.providerId === providerId)
|
||||
}
|
||||
|
||||
// Show enterprise-only gate if BYOK is not enabled
|
||||
if (!isLoading && !byokEnabled) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[16px] py-[32px]'>
|
||||
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-6)]'>
|
||||
<Crown className='h-[24px] w-[24px] text-[var(--amber-9)]' />
|
||||
</div>
|
||||
<div className='flex flex-col items-center gap-[8px] text-center'>
|
||||
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>Enterprise Feature</h3>
|
||||
<p className='max-w-[320px] text-[13px] text-[var(--text-secondary)]'>
|
||||
Bring Your Own Key (BYOK) is available exclusively on the Enterprise plan. Upgrade to
|
||||
use your own API keys and eliminate the 2x cost multiplier.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
onClick={() => window.open('https://sim.ai/enterprise', '_blank')}
|
||||
>
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingProvider || !apiKeyInput.trim()) return
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
@@ -86,8 +87,8 @@ type NavigationItem = {
|
||||
hideWhenBillingDisabled?: boolean
|
||||
requiresTeam?: boolean
|
||||
requiresEnterprise?: boolean
|
||||
requiresOwner?: boolean
|
||||
requiresHosted?: boolean
|
||||
selfHostedOverride?: boolean
|
||||
}
|
||||
|
||||
const sectionConfig: { key: NavigationSection; title: string }[] = [
|
||||
@@ -113,6 +114,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
icon: Users,
|
||||
section: 'subscription',
|
||||
hideWhenBillingDisabled: true,
|
||||
requiresHosted: true,
|
||||
requiresTeam: true,
|
||||
},
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
@@ -123,7 +125,8 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'Email Polling',
|
||||
icon: Mail,
|
||||
section: 'system',
|
||||
requiresTeam: true,
|
||||
requiresHosted: true,
|
||||
selfHostedOverride: isCredentialSetsEnabled,
|
||||
},
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
@@ -134,6 +137,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
icon: KeySquare,
|
||||
section: 'system',
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
},
|
||||
{
|
||||
id: 'copilot',
|
||||
@@ -148,9 +152,9 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'Single Sign-On',
|
||||
icon: LogIn,
|
||||
section: 'system',
|
||||
requiresTeam: true,
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
requiresOwner: true,
|
||||
selfHostedOverride: isSSOEnabled,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -173,8 +177,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
const isOwner = userRole === 'owner'
|
||||
const isAdmin = userRole === 'admin'
|
||||
const canManageSSO = isOwner || isAdmin
|
||||
const isOrgAdminOrOwner = isOwner || isAdmin
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
|
||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||
const hasOrganization = !!activeOrganization?.id
|
||||
|
||||
@@ -192,29 +197,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
// SSO has special logic that must be checked before requiresTeam
|
||||
if (item.id === 'sso') {
|
||||
if (isHosted) {
|
||||
return hasOrganization && hasEnterprisePlan && canManageSSO
|
||||
if (item.selfHostedOverride && !isHosted) {
|
||||
if (item.id === 'sso') {
|
||||
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
|
||||
return !hasProviders || isSSOProviderOwner === true
|
||||
}
|
||||
// For self-hosted, only show SSO tab if explicitly enabled via environment variable
|
||||
if (!isSSOEnabled) return false
|
||||
// Show tab if user is the SSO provider owner, or if no providers exist yet (to allow initial setup)
|
||||
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
|
||||
return !hasProviders || isSSOProviderOwner === true
|
||||
return true
|
||||
}
|
||||
|
||||
if (item.requiresTeam) {
|
||||
const isMember = userRole === 'member' || isAdmin
|
||||
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
|
||||
|
||||
if (isMember) return true
|
||||
if (isOwner && hasTeamPlan) return true
|
||||
|
||||
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresEnterprise && !hasEnterprisePlan) {
|
||||
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -222,24 +217,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresOwner && !isOwner) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [
|
||||
hasOrganization,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
canManageSSO,
|
||||
isOrgAdminOrOwner,
|
||||
isSSOProviderOwner,
|
||||
isSSOEnabled,
|
||||
ssoProvidersData?.providers?.length,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
userRole,
|
||||
subscriptionStatus.isTeam,
|
||||
subscriptionStatus.isEnterprise,
|
||||
])
|
||||
|
||||
// Memoized callbacks to prevent infinite loops in child components
|
||||
|
||||
@@ -77,7 +77,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
// Project Update Operations
|
||||
{ label: 'Create Project Update', id: 'linear_create_project_update' },
|
||||
{ label: 'List Project Updates', id: 'linear_list_project_updates' },
|
||||
{ label: 'Create Project Link', id: 'linear_create_project_link' },
|
||||
// Notification Operations
|
||||
{ label: 'List Notifications', id: 'linear_list_notifications' },
|
||||
{ label: 'Update Notification', id: 'linear_update_notification' },
|
||||
@@ -227,6 +226,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_update_project',
|
||||
'linear_archive_project',
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
],
|
||||
},
|
||||
@@ -239,6 +239,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_update_project',
|
||||
'linear_archive_project',
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_list_project_labels',
|
||||
],
|
||||
@@ -261,7 +262,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_create_project_link',
|
||||
],
|
||||
},
|
||||
condition: {
|
||||
@@ -275,7 +275,6 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
'linear_delete_project',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_create_project_link',
|
||||
'linear_list_project_labels',
|
||||
],
|
||||
},
|
||||
@@ -625,7 +624,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_attachment', 'linear_create_project_link'],
|
||||
value: ['linear_create_attachment'],
|
||||
},
|
||||
},
|
||||
// Attachment title
|
||||
@@ -1221,6 +1220,36 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
value: ['linear_create_project_status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projectStatusType',
|
||||
title: 'Status Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Backlog', id: 'backlog' },
|
||||
{ label: 'Planned', id: 'planned' },
|
||||
{ label: 'Started', id: 'started' },
|
||||
{ label: 'Paused', id: 'paused' },
|
||||
{ label: 'Completed', id: 'completed' },
|
||||
{ label: 'Canceled', id: 'canceled' },
|
||||
],
|
||||
value: () => 'started',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_project_status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projectStatusPosition',
|
||||
title: 'Position',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter position (e.g. 0, 1, 2...)',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['linear_create_project_status'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projectStatusId',
|
||||
title: 'Status ID',
|
||||
@@ -1326,7 +1355,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
'linear_list_favorites',
|
||||
'linear_create_project_update',
|
||||
'linear_list_project_updates',
|
||||
'linear_create_project_link',
|
||||
'linear_list_notifications',
|
||||
'linear_update_notification',
|
||||
'linear_create_customer',
|
||||
@@ -1772,17 +1800,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
projectId: effectiveProjectId,
|
||||
}
|
||||
|
||||
case 'linear_create_project_link':
|
||||
if (!effectiveProjectId || !params.url?.trim()) {
|
||||
throw new Error('Project ID and URL are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
projectId: effectiveProjectId,
|
||||
url: params.url.trim(),
|
||||
label: params.name,
|
||||
}
|
||||
|
||||
case 'linear_list_notifications':
|
||||
return baseParams
|
||||
|
||||
@@ -2033,22 +2050,22 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
}
|
||||
|
||||
case 'linear_add_label_to_project':
|
||||
if (!effectiveProjectId || !params.projectLabelId?.trim()) {
|
||||
if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
|
||||
throw new Error('Project ID and label ID are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
projectId: effectiveProjectId,
|
||||
projectId: params.projectIdForMilestone.trim(),
|
||||
labelId: params.projectLabelId.trim(),
|
||||
}
|
||||
|
||||
case 'linear_remove_label_from_project':
|
||||
if (!effectiveProjectId || !params.projectLabelId?.trim()) {
|
||||
if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
|
||||
throw new Error('Project ID and label ID are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
projectId: effectiveProjectId,
|
||||
projectId: params.projectIdForMilestone.trim(),
|
||||
labelId: params.projectLabelId.trim(),
|
||||
}
|
||||
|
||||
@@ -2097,13 +2114,20 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
|
||||
// Project Status Operations
|
||||
case 'linear_create_project_status':
|
||||
if (!params.projectStatusName?.trim() || !params.statusColor?.trim()) {
|
||||
throw new Error('Project status name and color are required.')
|
||||
if (
|
||||
!params.projectStatusName?.trim() ||
|
||||
!params.projectStatusType?.trim() ||
|
||||
!params.statusColor?.trim() ||
|
||||
!params.projectStatusPosition?.trim()
|
||||
) {
|
||||
throw new Error('Project status name, type, color, and position are required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
name: params.projectStatusName.trim(),
|
||||
type: params.projectStatusType.trim(),
|
||||
color: params.statusColor.trim(),
|
||||
position: Number.parseFloat(params.projectStatusPosition.trim()),
|
||||
description: params.projectStatusDescription?.trim() || undefined,
|
||||
indefinite: params.projectStatusIndefinite === 'true',
|
||||
}
|
||||
@@ -2270,7 +2294,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
// Project update outputs
|
||||
update: { type: 'json', description: 'Project update data' },
|
||||
updates: { type: 'json', description: 'Project updates list' },
|
||||
link: { type: 'json', description: 'Project link data' },
|
||||
// Notification outputs
|
||||
notification: { type: 'json', description: 'Notification data' },
|
||||
notifications: { type: 'json', description: 'Notifications list' },
|
||||
|
||||
@@ -15,18 +15,26 @@ export interface BYOKKey {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BYOKKeysResponse {
|
||||
keys: BYOKKey[]
|
||||
byokEnabled: boolean
|
||||
}
|
||||
|
||||
export const byokKeysKeys = {
|
||||
all: ['byok-keys'] as const,
|
||||
workspace: (workspaceId: string) => [...byokKeysKeys.all, 'workspace', workspaceId] as const,
|
||||
}
|
||||
|
||||
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKey[]> {
|
||||
async function fetchBYOKKeys(workspaceId: string): Promise<BYOKKeysResponse> {
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load BYOK keys: ${response.statusText}`)
|
||||
}
|
||||
const { keys } = await response.json()
|
||||
return keys
|
||||
const data = await response.json()
|
||||
return {
|
||||
keys: data.keys ?? [],
|
||||
byokEnabled: data.byokEnabled ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
export function useBYOKKeys(workspaceId: string) {
|
||||
@@ -36,6 +44,7 @@ export function useBYOKKeys(workspaceId: string) {
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
select: (data) => data,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import { db } from '@sim/db'
|
||||
import { workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getHostedModels } from '@/providers/models'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
const logger = createLogger('BYOKKeys')
|
||||
|
||||
@@ -51,9 +56,6 @@ export async function getApiKeyWithBYOK(
|
||||
workspaceId: string | undefined | null,
|
||||
userProvidedKey?: string
|
||||
): Promise<{ apiKey: string; isBYOK: boolean }> {
|
||||
const { isHosted } = await import('@/lib/core/config/feature-flags')
|
||||
const { useProvidersStore } = await import('@/stores/providers/store')
|
||||
|
||||
const isOllamaModel =
|
||||
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
|
||||
if (isOllamaModel) {
|
||||
@@ -83,23 +85,27 @@ export async function getApiKeyWithBYOK(
|
||||
workspaceId &&
|
||||
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)
|
||||
) {
|
||||
const { getHostedModels } = await import('@/providers/models')
|
||||
const hostedModels = getHostedModels()
|
||||
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
|
||||
|
||||
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
|
||||
|
||||
if (isModelHosted || isMistralModel) {
|
||||
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key', { provider, model, workspaceId })
|
||||
return byokResult
|
||||
const hasEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
|
||||
if (hasEnterprise) {
|
||||
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key', { provider, model, workspaceId })
|
||||
return byokResult
|
||||
}
|
||||
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
|
||||
} else {
|
||||
logger.debug('Workspace not on enterprise plan, skipping BYOK', { workspaceId })
|
||||
}
|
||||
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
|
||||
|
||||
if (isModelHosted) {
|
||||
try {
|
||||
const { getRotatingApiKey } = await import('@/lib/core/config/api-keys')
|
||||
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
|
||||
return { apiKey: serverKey, isBYOK: false }
|
||||
} catch (_error) {
|
||||
|
||||
53
apps/sim/lib/billing/core/plan.ts
Normal file
53
apps/sim/lib/billing/core/plan.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
const logger = createLogger('PlanLookup')
|
||||
|
||||
/**
|
||||
* Get the highest priority active subscription for a user
|
||||
* Priority: Enterprise > Team > Pro > Free
|
||||
*/
|
||||
export async function getHighestPrioritySubscription(userId: string) {
|
||||
try {
|
||||
const personalSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
|
||||
|
||||
let orgSubs: typeof personalSubs = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
if (allSubs.length === 0) return null
|
||||
|
||||
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
|
||||
if (enterpriseSub) return enterpriseSub
|
||||
|
||||
const teamSub = allSubs.find((s) => checkTeamPlan(s))
|
||||
if (teamSub) return teamSub
|
||||
|
||||
const proSub = allSubs.find((s) => checkProPlan(s))
|
||||
if (proSub) return proSub
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error getting highest priority subscription', { error, userId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { member, subscription, user, userStats, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { getUserUsageLimit } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
checkEnterprisePlan,
|
||||
checkProPlan,
|
||||
@@ -10,65 +12,17 @@ import {
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import {
|
||||
isCredentialSetsEnabled,
|
||||
isHosted,
|
||||
isProd,
|
||||
isSsoEnabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('SubscriptionCore')
|
||||
|
||||
/**
|
||||
* Core subscription management - single source of truth
|
||||
* Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the highest priority active subscription for a user
|
||||
* Priority: Enterprise > Team > Pro > Free
|
||||
*/
|
||||
export async function getHighestPrioritySubscription(userId: string) {
|
||||
try {
|
||||
// Get direct subscriptions
|
||||
const personalSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
|
||||
// Get organization memberships
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
|
||||
|
||||
// Get organization subscriptions
|
||||
let orgSubs: any[] = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
if (allSubs.length === 0) return null
|
||||
|
||||
// Return highest priority subscription
|
||||
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
|
||||
if (enterpriseSub) return enterpriseSub
|
||||
|
||||
const teamSub = allSubs.find((s) => checkTeamPlan(s))
|
||||
if (teamSub) return teamSub
|
||||
|
||||
const proSub = allSubs.find((s) => checkProPlan(s))
|
||||
if (proSub) return proSub
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error getting highest priority subscription', { error, userId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
export { getHighestPrioritySubscription }
|
||||
|
||||
/**
|
||||
* Check if user is on Pro plan (direct or via organization)
|
||||
@@ -144,6 +98,224 @@ export async function isEnterprisePlan(userId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is an admin or owner of an enterprise organization
|
||||
* Returns true if:
|
||||
* - User is a member of an enterprise organization AND
|
||||
* - User's role in that organization is 'owner' or 'admin'
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [memberRecord] = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!memberRecord) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
|
||||
return false
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, memberRecord.organizationId),
|
||||
eq(subscription.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
|
||||
|
||||
if (isEnterprise) {
|
||||
logger.info('User is enterprise org admin/owner', {
|
||||
userId,
|
||||
organizationId: memberRecord.organizationId,
|
||||
role: memberRecord.role,
|
||||
})
|
||||
}
|
||||
|
||||
return !!isEnterprise
|
||||
} catch (error) {
|
||||
logger.error('Error checking enterprise org admin/owner status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is an admin or owner of a team or enterprise organization
|
||||
* Returns true if:
|
||||
* - User is a member of a team/enterprise organization AND
|
||||
* - User's role in that organization is 'owner' or 'admin'
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function isTeamOrgAdminOrOwner(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [memberRecord] = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!memberRecord) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
|
||||
return false
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, memberRecord.organizationId),
|
||||
eq(subscription.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
|
||||
|
||||
if (hasTeamPlan) {
|
||||
logger.info('User is team org admin/owner', {
|
||||
userId,
|
||||
organizationId: memberRecord.organizationId,
|
||||
role: memberRecord.role,
|
||||
plan: orgSub.plan,
|
||||
})
|
||||
}
|
||||
|
||||
return !!hasTeamPlan
|
||||
} catch (error) {
|
||||
logger.error('Error checking team org admin/owner status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workspace has access to enterprise features (BYOK)
|
||||
* Used at execution time to determine if BYOK keys should be used
|
||||
* Returns true if workspace's billed account is on enterprise plan
|
||||
*/
|
||||
export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [ws] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!ws) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isEnterprisePlan(ws.billedAccountUserId)
|
||||
} catch (error) {
|
||||
logger.error('Error checking workspace enterprise status', { error, workspaceId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an organization has team or enterprise plan
|
||||
* Used at execution time (e.g., polling services) to check org billing directly
|
||||
*/
|
||||
export async function isOrganizationOnTeamOrEnterprisePlan(
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isCredentialSetsEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
|
||||
} catch (error) {
|
||||
logger.error('Error checking organization plan status', { error, organizationId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to credential sets (email polling) feature
|
||||
* Returns true if:
|
||||
* - CREDENTIAL_SETS_ENABLED env var is set (self-hosted override), OR
|
||||
* - User is admin/owner of a team/enterprise organization
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function hasCredentialSetsAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (isCredentialSetsEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isTeamOrgAdminOrOwner(userId)
|
||||
} catch (error) {
|
||||
logger.error('Error checking credential sets access', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to SSO feature
|
||||
* Returns true if:
|
||||
* - SSO_ENABLED env var is set (self-hosted override), OR
|
||||
* - User is admin/owner of an enterprise organization
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function hasSSOAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (isSsoEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isEnterpriseOrgAdminOrOwner(userId)
|
||||
} catch (error) {
|
||||
logger.error('Error checking SSO access', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded their cost limit based on current period usage
|
||||
*/
|
||||
@@ -160,7 +332,6 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
if (subscription) {
|
||||
// Team/Enterprise: Use organization limit
|
||||
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
|
||||
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
|
||||
limit = await getUserUsageLimit(userId)
|
||||
logger.info('Using organization limit', {
|
||||
userId,
|
||||
@@ -221,14 +392,16 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
// Determine plan types based on subscription (avoid redundant DB calls)
|
||||
const isPro =
|
||||
!isProd ||
|
||||
(subscription &&
|
||||
!!(
|
||||
subscription &&
|
||||
(checkProPlan(subscription) ||
|
||||
checkTeamPlan(subscription) ||
|
||||
checkEnterprisePlan(subscription)))
|
||||
checkEnterprisePlan(subscription))
|
||||
)
|
||||
const isTeam =
|
||||
!isProd ||
|
||||
(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
|
||||
const isEnterprise = !isProd || (subscription && checkEnterprisePlan(subscription))
|
||||
!!(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
|
||||
const isEnterprise = !isProd || !!(subscription && checkEnterprisePlan(subscription))
|
||||
const isFree = !isPro && !isTeam && !isEnterprise
|
||||
|
||||
// Determine plan name
|
||||
@@ -244,7 +417,6 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
if (subscription) {
|
||||
// Team/Enterprise: Use organization limit
|
||||
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
|
||||
const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
|
||||
limit = await getUserUsageLimit(userId)
|
||||
} else {
|
||||
// Pro/Free: Use individual limit
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
renderFreeTierUpgradeEmail,
|
||||
renderUsageThresholdEmail,
|
||||
} from '@/components/emails'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import {
|
||||
canEditUsageLimit,
|
||||
getFreeTierLimit,
|
||||
|
||||
@@ -10,9 +10,15 @@ export * from '@/lib/billing/core/subscription'
|
||||
export {
|
||||
getHighestPrioritySubscription as getActiveSubscription,
|
||||
getUserSubscriptionState as getSubscriptionState,
|
||||
hasCredentialSetsAccess,
|
||||
hasSSOAccess,
|
||||
isEnterpriseOrgAdminOrOwner,
|
||||
isEnterprisePlan as hasEnterprisePlan,
|
||||
isOrganizationOnTeamOrEnterprisePlan,
|
||||
isProPlan as hasProPlan,
|
||||
isTeamOrgAdminOrOwner,
|
||||
isTeamPlan as hasTeamPlan,
|
||||
isWorkspaceOnEnterprisePlan,
|
||||
sendPlanWelcomeEmail,
|
||||
} from '@/lib/billing/core/subscription'
|
||||
export * from '@/lib/billing/core/usage'
|
||||
|
||||
@@ -248,6 +248,9 @@ export const env = createEnv({
|
||||
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
|
||||
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
|
||||
|
||||
// Credential Sets (Email Polling) - for self-hosted deployments
|
||||
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
|
||||
|
||||
// SSO Configuration (for script-based registration)
|
||||
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
|
||||
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type
|
||||
@@ -325,6 +328,7 @@ export const env = createEnv({
|
||||
// Feature Flags
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
|
||||
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
|
||||
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
|
||||
},
|
||||
|
||||
@@ -353,6 +357,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR,
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: process.env.NEXT_PUBLIC_TRIGGER_DEV_ENABLED,
|
||||
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
|
||||
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED,
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
|
||||
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
|
||||
|
||||
@@ -80,6 +80,12 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
*/
|
||||
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
|
||||
|
||||
/**
|
||||
* Is credential sets (email polling) enabled via env var override
|
||||
* This bypasses plan requirements for self-hosted deployments
|
||||
*/
|
||||
export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED)
|
||||
|
||||
/**
|
||||
* Is E2B enabled for remote code execution
|
||||
*/
|
||||
|
||||
@@ -595,26 +595,6 @@ describe('validateUrlWithDNS', () => {
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DNS resolution', () => {
|
||||
it('should accept valid public URLs and return resolved IP', async () => {
|
||||
const result = await validateUrlWithDNS('https://example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.resolvedIP).toBeDefined()
|
||||
expect(result.originalHostname).toBe('example.com')
|
||||
})
|
||||
|
||||
it('should reject URLs that resolve to private IPs', async () => {
|
||||
const result = await validateUrlWithDNS('https://localhost.localdomain')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject unresolvable hostnames', async () => {
|
||||
const result = await validateUrlWithDNS('https://this-domain-does-not-exist-xyz123.invalid')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.error).toContain('could not be resolved')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPinnedUrl', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, webhook, workflow } from '@sim/db/schema'
|
||||
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
||||
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -141,9 +142,9 @@ export async function pollGmailWebhooks() {
|
||||
|
||||
try {
|
||||
const metadata = webhookData.providerConfig as any
|
||||
// Each webhook now has its own credentialId (credential sets are fanned out at save time)
|
||||
const credentialId: string | undefined = metadata?.credentialId
|
||||
const userId: string | undefined = metadata?.userId
|
||||
const credentialSetId: string | undefined = metadata?.credentialSetId
|
||||
|
||||
if (!credentialId && !userId) {
|
||||
logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`)
|
||||
@@ -152,6 +153,31 @@ export async function pollGmailWebhooks() {
|
||||
return
|
||||
}
|
||||
|
||||
if (credentialSetId) {
|
||||
const [cs] = await db
|
||||
.select({ organizationId: credentialSet.organizationId })
|
||||
.from(credentialSet)
|
||||
.where(eq(credentialSet.id, credentialSetId))
|
||||
.limit(1)
|
||||
|
||||
if (cs?.organizationId) {
|
||||
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
|
||||
if (!hasAccess) {
|
||||
logger.error(
|
||||
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
|
||||
{
|
||||
webhookId,
|
||||
credentialSetId,
|
||||
organizationId: cs.organizationId,
|
||||
}
|
||||
)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (credentialId) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, webhook, workflow } from '@sim/db/schema'
|
||||
import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { htmlToText } from 'html-to-text'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
||||
import { pollingIdempotency } from '@/lib/core/idempotency'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
@@ -192,6 +193,7 @@ export async function pollOutlookWebhooks() {
|
||||
const metadata = webhookData.providerConfig as any
|
||||
const credentialId: string | undefined = metadata?.credentialId
|
||||
const userId: string | undefined = metadata?.userId
|
||||
const credentialSetId: string | undefined = metadata?.credentialSetId
|
||||
|
||||
if (!credentialId && !userId) {
|
||||
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
|
||||
@@ -200,6 +202,31 @@ export async function pollOutlookWebhooks() {
|
||||
return
|
||||
}
|
||||
|
||||
if (credentialSetId) {
|
||||
const [cs] = await db
|
||||
.select({ organizationId: credentialSet.organizationId })
|
||||
.from(credentialSet)
|
||||
.where(eq(credentialSet.id, credentialSetId))
|
||||
.limit(1)
|
||||
|
||||
if (cs?.organizationId) {
|
||||
const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
|
||||
if (!hasAccess) {
|
||||
logger.error(
|
||||
`[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
|
||||
{
|
||||
webhookId,
|
||||
credentialSetId,
|
||||
organizationId: cs.organizationId,
|
||||
}
|
||||
)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null
|
||||
if (credentialId) {
|
||||
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
@@ -2546,7 +2546,6 @@ export async function syncWebhooksForCredentialSet(params: {
|
||||
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
|
||||
const useUniquePaths = pollingProviders.includes(provider)
|
||||
|
||||
// Get all credentials in the set
|
||||
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
|
||||
|
||||
if (credentials.length === 0) {
|
||||
|
||||
@@ -19,12 +19,6 @@ export const linearCreateProjectLabelTool: ToolConfig<
|
||||
},
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'The project for this label',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -71,7 +65,6 @@ export const linearCreateProjectLabelTool: ToolConfig<
|
||||
},
|
||||
body: (params) => {
|
||||
const input: Record<string, any> = {
|
||||
projectId: params.projectId,
|
||||
name: params.name,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type {
|
||||
LinearCreateProjectLinkParams,
|
||||
LinearCreateProjectLinkResponse,
|
||||
} from '@/tools/linear/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const linearCreateProjectLinkTool: ToolConfig<
|
||||
LinearCreateProjectLinkParams,
|
||||
LinearCreateProjectLinkResponse
|
||||
> = {
|
||||
id: 'linear_create_project_link',
|
||||
name: 'Linear Create Project Link',
|
||||
description: 'Add an external link to a project in Linear',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'linear',
|
||||
},
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Project ID to add link to',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'URL of the external link',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Link label/title',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.linear.app/graphql',
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Missing access token for Linear API request')
|
||||
}
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
const input: Record<string, any> = {
|
||||
projectId: params.projectId,
|
||||
url: params.url,
|
||||
}
|
||||
|
||||
if (params.label != null && params.label !== '') input.label = params.label
|
||||
|
||||
return {
|
||||
query: `
|
||||
mutation CreateProjectLink($input: ProjectLinkCreateInput!) {
|
||||
projectLinkCreate(input: $input) {
|
||||
success
|
||||
projectLink {
|
||||
id
|
||||
url
|
||||
label
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.errors[0]?.message || 'Failed to create project link',
|
||||
output: {},
|
||||
}
|
||||
}
|
||||
|
||||
const result = data.data.projectLinkCreate
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Project link creation was not successful',
|
||||
output: {},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
link: result.projectLink,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
link: {
|
||||
type: 'object',
|
||||
description: 'The created project link',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Link ID' },
|
||||
url: { type: 'string', description: 'Link URL' },
|
||||
label: { type: 'string', description: 'Link label' },
|
||||
createdAt: { type: 'string', description: 'Creation timestamp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -19,24 +19,31 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
},
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'The project to create the status for',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Project status name',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Status type: "backlog", "planned", "started", "paused", "completed", or "canceled"',
|
||||
},
|
||||
color: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Status color (hex code)',
|
||||
},
|
||||
position: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Position in status list (e.g. 0, 1, 2...)',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -49,12 +56,6 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the status is indefinite',
|
||||
},
|
||||
position: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Position in status list',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -71,9 +72,10 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
},
|
||||
body: (params) => {
|
||||
const input: Record<string, any> = {
|
||||
projectId: params.projectId,
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
color: params.color,
|
||||
position: params.position,
|
||||
}
|
||||
|
||||
if (params.description != null && params.description !== '') {
|
||||
@@ -82,9 +84,6 @@ export const linearCreateProjectStatusTool: ToolConfig<
|
||||
if (params.indefinite != null) {
|
||||
input.indefinite = params.indefinite
|
||||
}
|
||||
if (params.position != null) {
|
||||
input.position = params.position
|
||||
}
|
||||
|
||||
return {
|
||||
query: `
|
||||
|
||||
@@ -16,7 +16,6 @@ import { linearCreateIssueRelationTool } from '@/tools/linear/create_issue_relat
|
||||
import { linearCreateLabelTool } from '@/tools/linear/create_label'
|
||||
import { linearCreateProjectTool } from '@/tools/linear/create_project'
|
||||
import { linearCreateProjectLabelTool } from '@/tools/linear/create_project_label'
|
||||
import { linearCreateProjectLinkTool } from '@/tools/linear/create_project_link'
|
||||
import { linearCreateProjectMilestoneTool } from '@/tools/linear/create_project_milestone'
|
||||
import { linearCreateProjectStatusTool } from '@/tools/linear/create_project_status'
|
||||
import { linearCreateProjectUpdateTool } from '@/tools/linear/create_project_update'
|
||||
@@ -138,7 +137,6 @@ export {
|
||||
linearListFavoritesTool,
|
||||
linearCreateProjectUpdateTool,
|
||||
linearListProjectUpdatesTool,
|
||||
linearCreateProjectLinkTool,
|
||||
linearListNotificationsTool,
|
||||
linearUpdateNotificationTool,
|
||||
linearCreateCustomerTool,
|
||||
|
||||
@@ -454,13 +454,6 @@ export interface LinearListProjectUpdatesParams {
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectLinkParams {
|
||||
projectId: string
|
||||
url: string
|
||||
label?: string
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export interface LinearListNotificationsParams {
|
||||
first?: number
|
||||
after?: string
|
||||
@@ -843,19 +836,6 @@ export interface LinearListProjectUpdatesResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinearProjectLink {
|
||||
id: string
|
||||
url: string
|
||||
label: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectLinkResponse extends ToolResponse {
|
||||
output: {
|
||||
link?: LinearProjectLink
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinearNotification {
|
||||
id: string
|
||||
type: string
|
||||
@@ -1205,7 +1185,6 @@ export interface LinearProjectLabel {
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectLabelParams {
|
||||
projectId: string
|
||||
name: string
|
||||
color?: string
|
||||
description?: string
|
||||
@@ -1358,12 +1337,12 @@ export interface LinearProjectStatus {
|
||||
}
|
||||
|
||||
export interface LinearCreateProjectStatusParams {
|
||||
projectId: string
|
||||
name: string
|
||||
type: 'backlog' | 'planned' | 'started' | 'paused' | 'completed' | 'canceled'
|
||||
color: string
|
||||
position: number
|
||||
description?: string
|
||||
indefinite?: boolean
|
||||
position?: number
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
@@ -1468,7 +1447,6 @@ export type LinearResponse =
|
||||
| LinearListFavoritesResponse
|
||||
| LinearCreateProjectUpdateResponse
|
||||
| LinearListProjectUpdatesResponse
|
||||
| LinearCreateProjectLinkResponse
|
||||
| LinearListNotificationsResponse
|
||||
| LinearUpdateNotificationResponse
|
||||
| LinearCreateCustomerResponse
|
||||
|
||||
@@ -567,7 +567,6 @@ import {
|
||||
linearCreateIssueTool,
|
||||
linearCreateLabelTool,
|
||||
linearCreateProjectLabelTool,
|
||||
linearCreateProjectLinkTool,
|
||||
linearCreateProjectMilestoneTool,
|
||||
linearCreateProjectStatusTool,
|
||||
linearCreateProjectTool,
|
||||
@@ -2187,7 +2186,6 @@ export const tools: Record<string, ToolConfig> = {
|
||||
linear_list_favorites: linearListFavoritesTool,
|
||||
linear_create_project_update: linearCreateProjectUpdateTool,
|
||||
linear_list_project_updates: linearListProjectUpdatesTool,
|
||||
linear_create_project_link: linearCreateProjectLinkTool,
|
||||
linear_list_notifications: linearListNotificationsTool,
|
||||
linear_update_notification: linearUpdateNotificationTool,
|
||||
linear_create_customer: linearCreateCustomerTool,
|
||||
|
||||
2
packages/db/migrations/0136_pretty_jack_flag.sql
Normal file
2
packages/db/migrations/0136_pretty_jack_flag.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP INDEX "member_user_id_idx";--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "member_user_id_unique" ON "member" USING btree ("user_id");
|
||||
9333
packages/db/migrations/meta/0136_snapshot.json
Normal file
9333
packages/db/migrations/meta/0136_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -946,6 +946,13 @@
|
||||
"when": 1767737974016,
|
||||
"tag": "0135_stormy_puff_adder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 136,
|
||||
"version": "7",
|
||||
"when": 1767905804764,
|
||||
"tag": "0136_pretty_jack_flag",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -824,7 +824,7 @@ export const member = pgTable(
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('member_user_id_idx').on(table.userId),
|
||||
userIdUnique: uniqueIndex('member_user_id_unique').on(table.userId), // Users can only belong to one org
|
||||
organizationIdIdx: index('member_organization_id_idx').on(table.organizationId),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user